Completed
Pull Request — master (#6930)
by Daniel
09:37
created

DataObjectSchema   D

Complexity

Total Complexity 136

Size/Duplication

Total Lines 1064
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 9

Importance

Changes 0
Metric Value
dl 0
loc 1064
rs 4.4219
c 0
b 0
f 0
wmc 136
lcom 1
cbo 9

33 Methods

Rating   Name   Duplication   Size   Complexity  
A reset() 0 7 1
A getTableNames() 0 5 1
A sqlColumnForField() 0 8 2
A tableName() 0 9 2
A baseDataClass() 0 12 3
A baseDataTable() 0 4 1
C fieldSpecs() 0 33 7
A fieldSpec() 0 5 2
A tableClass() 0 19 4
B cacheTableNames() 0 22 5
A buildTableName() 0 12 2
A databaseFields() 0 17 3
A databaseField() 0 5 2
A databaseIndexes() 0 13 3
A classHasTable() 0 5 1
A compositeFields() 0 18 3
A compositeField() 0 5 2
D cacheDatabaseFields() 0 58 13
C cacheDatabaseIndexes() 0 57 13
A tableForField() 0 8 2
B classForField() 0 24 6
B manyManyComponent() 0 36 4
B parseBelongsManyManyComponent() 0 35 5
B manyManyExtraFieldsForComponent() 0 24 5
A hasManyComponent() 0 15 3
A hasOneComponent() 0 12 2
A belongsToComponent() 0 15 3
B parseManyManyComponent() 0 40 3
A getManyManyInverseRelationship() 0 13 4
C getRemoteJoinField() 0 68 12
C checkManyManyFieldClass() 0 47 7
A checkManyManyJoinClass() 0 16 3
C checkRelationClass() 0 30 7

How to fix   Complexity   

Complex Class

Complex classes like DataObjectSchema often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use DataObjectSchema, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace SilverStripe\ORM;
4
5
use Exception;
6
use SilverStripe\Core\Injector\Injectable;
7
use SilverStripe\Core\Config\Configurable;
8
use SilverStripe\Core\Injector\Injector;
9
use SilverStripe\ORM\FieldType\DBComposite;
10
use SilverStripe\Core\ClassInfo;
11
use SilverStripe\Core\Config\Config;
12
use InvalidArgumentException;
13
use LogicException;
14
15
/**
16
 * Provides dataobject and database schema mapping functionality
17
 */
18
class DataObjectSchema
19
{
20
    use Injectable;
21
    use Configurable;
22
23
    /**
24
     * Default separate for table namespaces. Can be set to any string for
25
     * databases that do not support some characters.
26
     *
27
     * @config
28
     * @var string
29
     */
30
    private static $table_namespace_separator = '_';
31
32
    /**
33
     * Cache of database fields
34
     *
35
     * @var array
36
     */
37
    protected $databaseFields = [];
38
39
    /**
40
     * Cache of database indexes
41
     *
42
     * @var array
43
     */
44
    protected $databaseIndexes = [];
45
46
    /**
47
     * Cache of composite database field
48
     *
49
     * @var array
50
     */
51
    protected $compositeFields = [];
52
53
    /**
54
     * Cache of table names
55
     *
56
     * @var array
57
     */
58
    protected $tableNames = [];
59
60
    /**
61
     * Clear cached table names
62
     */
63
    public function reset()
64
    {
65
        $this->tableNames = [];
66
        $this->databaseFields = [];
67
        $this->databaseIndexes = [];
68
        $this->compositeFields = [];
69
    }
70
71
    /**
72
     * Get all table names
73
     *
74
     * @return array
75
     */
76
    public function getTableNames()
77
    {
78
        $this->cacheTableNames();
79
        return $this->tableNames;
80
    }
81
82
    /**
83
     * Given a DataObject class and a field on that class, determine the appropriate SQL for
84
     * selecting / filtering on in a SQL string. Note that $class must be a valid class, not an
85
     * arbitrary table.
86
     *
87
     * The result will be a standard ANSI-sql quoted string in "Table"."Column" format.
88
     *
89
     * @param string $class Class name (not a table).
90
     * @param string $field Name of field that belongs to this class (or a parent class)
91
     * @return string The SQL identifier string for the corresponding column for this field
92
     */
93
    public function sqlColumnForField($class, $field)
94
    {
95
        $table = $this->tableForField($class, $field);
96
        if (!$table) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $table of type string|null is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
97
            throw new InvalidArgumentException("\"{$field}\" is not a field on class \"{$class}\"");
98
        }
99
        return "\"{$table}\".\"{$field}\"";
100
    }
101
102
    /**
103
     * Get table name for the given class.
104
     *
105
     * Note that this does not confirm a table actually exists (or should exist), but returns
106
     * the name that would be used if this table did exist.
107
     *
108
     * @param string $class
109
     * @return string Returns the table name, or null if there is no table
110
     */
111
    public function tableName($class)
112
    {
113
        $tables = $this->getTableNames();
114
        $class = ClassInfo::class_name($class);
115
        if (isset($tables[$class])) {
116
            return $tables[$class];
117
        }
118
        return null;
119
    }
120
    /**
121
     * Returns the root class (the first to extend from DataObject) for the
122
     * passed class.
123
     *
124
     * @param string|object $class
125
     * @return string
126
     * @throws InvalidArgumentException
127
     */
128
    public function baseDataClass($class)
129
    {
130
        $class = ClassInfo::class_name($class);
131
        $current = $class;
132
        while ($next = get_parent_class($current)) {
133
            if ($next === DataObject::class) {
134
                return $current;
135
            }
136
            $current = $next;
137
        }
138
        throw new InvalidArgumentException("$class is not a subclass of DataObject");
139
    }
140
141
    /**
142
     * Get the base table
143
     *
144
     * @param string|object $class
145
     * @return string
146
     */
147
    public function baseDataTable($class)
148
    {
149
        return $this->tableName($this->baseDataClass($class));
150
    }
151
152
    /**
153
     * fieldSpec should exclude virtual fields (such as composite fields), and only include fields with a db column.
154
     */
155
    const DB_ONLY = 1;
156
157
    /**
158
     * fieldSpec should only return fields that belong to this table, and not any ancestors
159
     */
160
    const UNINHERITED = 2;
161
162
    /**
163
     * fieldSpec should prefix all field specifications with the class name in RecordClass.Column(spec) format.
164
     */
165
    const INCLUDE_CLASS = 4;
166
167
    /**
168
     * Get all DB field specifications for a class, including ancestors and composite fields.
169
     *
170
     * @param string|DataObject $classOrInstance
171
     * @param int $options Bitmask of options
172
     *  - UNINHERITED Limit to only this table
173
     *  - DB_ONLY Exclude virtual fields (such as composite fields), and only include fields with a db column.
174
     *  - INCLUDE_CLASS Prefix the field specification with the class name in RecordClass.Column(spec) format.
175
     * @return array List of fields, where the key is the field name and the value is the field specification.
176
     */
177
    public function fieldSpecs($classOrInstance, $options = 0)
178
    {
179
        $class = ClassInfo::class_name($classOrInstance);
180
181
        // Validate options
182
        if (!is_int($options)) {
183
            throw new InvalidArgumentException("Invalid options " . var_export($options, true));
184
        }
185
        $uninherited = ($options & self::UNINHERITED) === self::UNINHERITED;
186
        $dbOnly = ($options & self::DB_ONLY) === self::DB_ONLY;
187
        $includeClass = ($options & self::INCLUDE_CLASS) === self::INCLUDE_CLASS;
188
189
        // Walk class hierarchy
190
        $db = [];
191
        $classes = $uninherited ? [$class] : ClassInfo::ancestry($class);
192
        foreach ($classes as $tableClass) {
193
            // Find all fields on this class
194
            $fields = $this->databaseFields($tableClass, false);
195
196
            // Merge with composite fields
197
            if (!$dbOnly) {
198
                $compositeFields = $this->compositeFields($tableClass, false);
199
                $fields = array_merge($fields, $compositeFields);
200
            }
201
202
            // Record specification
203
            foreach ($fields as $name => $specification) {
204
                $prefix = $includeClass ? "{$tableClass}." : "";
205
                $db[$name] =  $prefix . $specification;
206
            }
207
        }
208
        return $db;
209
    }
210
211
212
    /**
213
     * Get specifications for a single class field
214
     *
215
     * @param string|DataObject $classOrInstance Name or instance of class
216
     * @param string $fieldName Name of field to retrieve
217
     * @param int $options Bitmask of options
218
     *  - UNINHERITED Limit to only this table
219
     *  - DB_ONLY Exclude virtual fields (such as composite fields), and only include fields with a db column.
220
     *  - INCLUDE_CLASS Prefix the field specification with the class name in RecordClass.Column(spec) format.
221
     * @return string|null Field will be a string in FieldClass(args) format, or
222
     * RecordClass.FieldClass(args) format if using INCLUDE_CLASS. Will be null if no field is found.
223
     */
224
    public function fieldSpec($classOrInstance, $fieldName, $options = 0)
225
    {
226
        $specs = $this->fieldSpecs($classOrInstance, $options);
227
        return isset($specs[$fieldName]) ? $specs[$fieldName] : null;
228
    }
229
230
    /**
231
     * Find the class for the given table
232
     *
233
     * @param string $table
234
     * @return string|null The FQN of the class, or null if not found
235
     */
236
    public function tableClass($table)
237
    {
238
        $tables = $this->getTableNames();
239
        $class = array_search($table, $tables, true);
240
        if ($class) {
241
            return $class;
242
        }
243
244
        // If there is no class for this table, strip table modifiers (e.g. _Live / _Versions)
245
        // from the end and re-attempt a search.
246
        if (preg_match('/^(?<class>.+)(_[^_]+)$/i', $table, $matches)) {
247
            $table = $matches['class'];
248
            $class = array_search($table, $tables, true);
249
            if ($class) {
250
                return $class;
251
            }
252
        }
253
        return null;
254
    }
255
256
    /**
257
     * Cache all table names if necessary
258
     */
259
    protected function cacheTableNames()
260
    {
261
        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...
262
            return;
263
        }
264
        $this->tableNames = [];
265
        foreach (ClassInfo::subclassesFor(DataObject::class) as $class) {
266
            if ($class === DataObject::class) {
267
                continue;
268
            }
269
            $table = $this->buildTableName($class);
270
271
            // Check for conflicts
272
            $conflict = array_search($table, $this->tableNames, true);
273
            if ($conflict) {
274
                throw new LogicException(
275
                    "Multiple classes (\"{$class}\", \"{$conflict}\") map to the same table: \"{$table}\""
276
                );
277
            }
278
            $this->tableNames[$class] = $table;
279
        }
280
    }
281
282
    /**
283
     * Generate table name for a class.
284
     *
285
     * Note: some DB schema have a hard limit on table name length. This is not enforced by this method.
286
     * See dev/build errors for details in case of table name violation.
287
     *
288
     * @param string $class
289
     * @return string
290
     */
291
    protected function buildTableName($class)
292
    {
293
        $table = Config::inst()->get($class, 'table_name', Config::UNINHERITED);
294
295
        // Generate default table name
296
        if (!$table) {
297
            $separator = DataObjectSchema::config()->uninherited('table_namespace_separator');
0 ignored issues
show
Coding Style introduced by
As per coding style, self should be used for accessing local static members.

This check looks for accesses to local static members using the fully qualified name instead of self::.

<?php

class Certificate {
    const TRIPLEDES_CBC = 'ASDFGHJKL';

    private $key;

    public function __construct()
    {
        $this->key = Certificate::TRIPLEDES_CBC;
    }
}

While this is perfectly valid, the fully qualified name of Certificate::TRIPLEDES_CBC could just as well be replaced by self::TRIPLEDES_CBC. Referencing local members with self:: assured the access will still work when the class is renamed, makes it perfectly clear that the member is in fact local and will usually be shorter.

Loading history...
298
            $table = str_replace('\\', $separator, trim($class, '\\'));
299
        }
300
301
        return $table;
302
    }
303
304
    /**
305
     * Return the complete map of fields to specification on this object, including fixed_fields.
306
     * "ID" will be included on every table.
307
     *
308
     * @param string $class Class name to query from
309
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
310
     * @return array Map of fieldname to specification, similiar to {@link DataObject::$db}.
311
     */
312
    public function databaseFields($class, $aggregated = true)
313
    {
314
        $class = ClassInfo::class_name($class);
315
        if ($class === DataObject::class) {
316
            return [];
317
        }
318
        $this->cacheDatabaseFields($class);
319
        $fields = $this->databaseFields[$class];
320
321
        if (!$aggregated) {
322
            return $fields;
323
        }
324
325
        // Recursively merge
326
        $parentFields = $this->databaseFields(get_parent_class($class));
327
        return array_merge($fields, array_diff_key($parentFields, $fields));
328
    }
329
330
    /**
331
     * Gets a single database field.
332
     *
333
     * @param string $class Class name to query from
334
     * @param string $field Field name
335
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
336
     * @return string|null Field specification, or null if not a field
337
     */
338
    public function databaseField($class, $field, $aggregated = true)
339
    {
340
        $fields = $this->databaseFields($class, $aggregated);
341
        return isset($fields[$field]) ? $fields[$field] : null;
342
    }
343
344
    /**
345
     * @param string $class
346
     * @param bool $aggregated
347
     *
348
     * @return array
349
     */
350
    public function databaseIndexes($class, $aggregated = true)
351
    {
352
        $class = ClassInfo::class_name($class);
353
        if ($class === DataObject::class) {
354
            return [];
355
        }
356
        $this->cacheDatabaseIndexes($class);
357
        $indexes = $this->databaseIndexes[$class];
358
        if (!$aggregated) {
359
            return $indexes;
360
        }
361
        return array_merge($indexes, $this->databaseIndexes(get_parent_class($class)));
362
    }
363
364
    /**
365
     * Check if the given class has a table
366
     *
367
     * @param string $class
368
     * @return bool
369
     */
370
    public function classHasTable($class)
371
    {
372
        $fields = $this->databaseFields($class, false);
373
        return !empty($fields);
374
    }
375
376
    /**
377
     * Returns a list of all the composite if the given db field on the class is a composite field.
378
     * Will check all applicable ancestor classes and aggregate results.
379
     *
380
     * Can be called directly on an object. E.g. Member::composite_fields(), or Member::composite_fields(null, true)
381
     * to aggregate.
382
     *
383
     * Includes composite has_one (Polymorphic) fields
384
     *
385
     * @param string $class Name of class to check
386
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
387
     * @return array List of composite fields and their class spec
388
     */
389
    public function compositeFields($class, $aggregated = true)
390
    {
391
        $class = ClassInfo::class_name($class);
392
        if ($class === DataObject::class) {
393
            return [];
394
        }
395
        $this->cacheDatabaseFields($class);
396
397
        // Get fields for this class
398
        $compositeFields = $this->compositeFields[$class];
399
        if (!$aggregated) {
400
            return $compositeFields;
401
        }
402
403
        // Recursively merge
404
        $parentFields = $this->compositeFields(get_parent_class($class));
405
        return array_merge($compositeFields, array_diff_key($parentFields, $compositeFields));
406
    }
407
408
    /**
409
     * Get a composite field for a class
410
     *
411
     * @param string $class Class name to query from
412
     * @param string $field Field name
413
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
414
     * @return string|null Field specification, or null if not a field
415
     */
416
    public function compositeField($class, $field, $aggregated = true)
417
    {
418
        $fields = $this->compositeFields($class, $aggregated);
419
        return isset($fields[$field]) ? $fields[$field] : null;
420
    }
421
422
    /**
423
     * Cache all database and composite fields for the given class.
424
     * Will do nothing if already cached
425
     *
426
     * @param string $class Class name to cache
427
     */
428
    protected function cacheDatabaseFields($class)
429
    {
430
        // Skip if already cached
431
        if (isset($this->databaseFields[$class]) && isset($this->compositeFields[$class])) {
432
            return;
433
        }
434
        $compositeFields = array();
435
        $dbFields = array();
436
437
        // Ensure fixed fields appear at the start
438
        $fixedFields = DataObject::config()->uninherited('fixed_fields');
439
        if (get_parent_class($class) === DataObject::class) {
440
            // Merge fixed with ClassName spec and custom db fields
441
            $dbFields = $fixedFields;
442
        } else {
443
            $dbFields['ID'] = $fixedFields['ID'];
444
        }
445
446
        // Check each DB value as either a field or composite field
447
        $db = Config::inst()->get($class, 'db', Config::UNINHERITED) ?: array();
448
        foreach ($db as $fieldName => $fieldSpec) {
449
            $fieldClass = strtok($fieldSpec, '(');
450
            if (singleton($fieldClass) instanceof DBComposite) {
451
                $compositeFields[$fieldName] = $fieldSpec;
452
            } else {
453
                $dbFields[$fieldName] = $fieldSpec;
454
            }
455
        }
456
457
        // Add in all has_ones
458
        $hasOne = Config::inst()->get($class, 'has_one', Config::UNINHERITED) ?: array();
459
        foreach ($hasOne as $fieldName => $hasOneClass) {
460
            if ($hasOneClass === DataObject::class) {
461
                $compositeFields[$fieldName] = 'PolymorphicForeignKey';
462
            } else {
463
                $dbFields["{$fieldName}ID"] = 'ForeignKey';
464
            }
465
        }
466
467
        // Merge composite fields into DB
468
        foreach ($compositeFields as $fieldName => $fieldSpec) {
469
            $fieldObj = Injector::inst()->create($fieldSpec, $fieldName);
470
            $fieldObj->setTable($class);
471
            $nestedFields = $fieldObj->compositeDatabaseFields();
472
            foreach ($nestedFields as $nestedName => $nestedSpec) {
473
                $dbFields["{$fieldName}{$nestedName}"] = $nestedSpec;
474
            }
475
        }
476
477
        // Prevent field-less tables with only 'ID'
478
        if (count($dbFields) < 2) {
479
            $dbFields = [];
480
        }
481
482
        // Return cached results
483
        $this->databaseFields[$class] = $dbFields;
484
        $this->compositeFields[$class] = $compositeFields;
485
    }
486
487
    /**
488
     * Cache all indexes for the given class.
489
     * Will do nothing if already cached
490
     *
491
     * @param $class
492
     */
493
    protected function cacheDatabaseIndexes($class)
494
    {
495
        if (array_key_exists($class, $this->databaseIndexes)) {
496
            return;
497
        }
498
        $indexes = [];
499
500
        // look for indexable field types
501
        foreach ($this->databaseFields($class, false) as $field => $type) {
502
            if ($type === 'ForeignKey' || $type === 'DBClassName') {
503
                $indexes[$field] = [
504
                    'type' => 'index',
505
                    'columns' => [$field],
506
                ];
507
            }
508
        }
509
510
        // look for custom indexes declared on the class
511
        $classIndexes = Config::inst()->get($class, 'indexes', Config::UNINHERITED) ?: [];
512
        foreach ($classIndexes as $indexName => $indexSpec) {
513
            if (array_key_exists($indexName, $indexes)) {
514
                throw new InvalidArgumentException(sprintf(
515
                    'Index named "%s" already exists on class %s',
516
                    $indexName,
517
                    $class
518
                ));
519
            }
520
            if (is_array($indexSpec)) {
521
                if (!ArrayLib::is_associative($indexSpec)) {
522
                    $indexSpec = [
523
                        'columns' => $indexSpec,
524
                    ];
525
                }
526
                if (!isset($indexSpec['type'])) {
527
                    $indexSpec['type'] = 'index';
528
                }
529
                if (!isset($indexSpec['columns'])) {
530
                    $indexSpec['columns'] = [$indexName];
531
                } elseif (!is_array($indexSpec['columns'])) {
532
                    throw new InvalidArgumentException(sprintf(
533
                        'Index %s on %s is not valid. columns should be an array %s given',
534
                        var_export($indexName, true),
535
                        var_export($class, true),
536
                        var_export($indexSpec['columns'], true)
537
                    ));
538
                }
539
            } else {
540
                $indexSpec = [
541
                    'type' => 'index',
542
                    'columns' => [$indexName],
543
                ];
544
            }
545
            $indexes[$indexName] = $indexSpec;
546
        }
547
548
        $this->databaseIndexes[$class] = $indexes;
549
    }
550
551
    /**
552
     * Returns the table name in the class hierarchy which contains a given
553
     * field column for a {@link DataObject}. If the field does not exist, this
554
     * will return null.
555
     *
556
     * @param string $candidateClass
557
     * @param string $fieldName
558
     * @return string
559
     */
560
    public function tableForField($candidateClass, $fieldName)
561
    {
562
        $class = $this->classForField($candidateClass, $fieldName);
563
        if ($class) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $class of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
564
            return $this->tableName($class);
565
        }
566
        return null;
567
    }
568
569
    /**
570
     * Returns the class name in the class hierarchy which contains a given
571
     * field column for a {@link DataObject}. If the field does not exist, this
572
     * will return null.
573
     *
574
     * @param string $candidateClass
575
     * @param string $fieldName
576
     * @return string
577
     */
578
    public function classForField($candidateClass, $fieldName)
579
    {
580
        // normalise class name
581
        $candidateClass = ClassInfo::class_name($candidateClass);
582
        if ($candidateClass === DataObject::class) {
583
            return null;
584
        }
585
586
        // Short circuit for fixed fields
587
        $fixed = DataObject::config()->uninherited('fixed_fields');
588
        if (isset($fixed[$fieldName])) {
589
            return $this->baseDataClass($candidateClass);
590
        }
591
592
        // Find regular field
593
        while ($candidateClass && $candidateClass !== DataObject::class) {
594
            $fields = $this->databaseFields($candidateClass, false);
595
            if (isset($fields[$fieldName])) {
596
                return $candidateClass;
597
            }
598
            $candidateClass = get_parent_class($candidateClass);
599
        }
600
        return null;
601
    }
602
603
    /**
604
     * Return information about a specific many_many component. Returns a numeric array.
605
     * The first item in the array will be the class name of the relation.
606
     *
607
     * Standard many_many return type is:
608
     *
609
     * array(
610
     *  <manyManyClass>,        Name of class for relation. E.g. "Categories"
611
     *  <classname>,            The class that relation is defined in e.g. "Product"
612
     *  <candidateName>,        The target class of the relation e.g. "Category"
613
     *  <parentField>,          The field name pointing to <classname>'s table e.g. "ProductID".
614
     *  <childField>,           The field name pointing to <candidatename>'s table e.g. "CategoryID".
615
     *  <joinTableOrRelation>   The join table between the two classes e.g. "Product_Categories".
616
     *                          If the class name is 'ManyManyThroughList' then this is the name of the
617
     *                          has_many relation.
618
     * )
619
     * @param string $class Name of class to get component for
620
     * @param string $component The component name
621
     * @return array|null
622
     */
623
    public function manyManyComponent($class, $component)
624
    {
625
        $classes = ClassInfo::ancestry($class);
626
        foreach ($classes as $parentClass) {
627
            // Check if the component is defined in many_many on this class
628
            $otherManyMany = Config::inst()->get($parentClass, 'many_many', Config::UNINHERITED);
629
            if (isset($otherManyMany[$component])) {
630
                return $this->parseManyManyComponent($parentClass, $component, $otherManyMany[$component]);
631
            }
632
633
            // Check if the component is defined in belongs_many_many on this class
634
            $belongsManyMany = Config::inst()->get($parentClass, 'belongs_many_many', Config::UNINHERITED);
635
            if (!isset($belongsManyMany[$component])) {
636
                continue;
637
            }
638
639
            // Extract class and relation name from dot-notation
640
            $belongs = $this->parseBelongsManyManyComponent(
641
                $parentClass,
642
                $component,
643
                $belongsManyMany[$component]
644
            );
645
646
            // Build inverse relationship from other many_many, and swap parent/child
647
            $otherManyMany = $this->manyManyComponent($belongs['childClass'], $belongs['relationName']);
648
            return [
649
                'relationClass' => $otherManyMany['relationClass'],
650
                'parentClass' => $otherManyMany['childClass'],
651
                'childClass' => $otherManyMany['parentClass'],
652
                'parentField' => $otherManyMany['childField'],
653
                'childField' => $otherManyMany['parentField'],
654
                'join' => $otherManyMany['join'],
655
            ];
656
        }
657
        return null;
658
    }
659
660
661
662
    /**
663
     * Parse a belongs_many_many component to extract class and relationship name
664
     *
665
     * @param string $parentClass Name of class
666
     * @param string $component Name of relation on class
667
     * @param string $specification specification for this belongs_many_many
668
     * @return array Array with child class and relation name
669
     */
670
    protected function parseBelongsManyManyComponent($parentClass, $component, $specification)
671
    {
672
        $childClass = $specification;
673
        $relationName = null;
674
        if (strpos($specification, '.') !== false) {
675
            list($childClass, $relationName) = explode('.', $specification, 2);
676
        }
677
678
        // Check child class exists
679
        if (!class_exists($childClass)) {
680
            throw new LogicException(
681
                "belongs_many_many relation {$parentClass}.{$component} points to "
682
                . "{$childClass} which does not exist"
683
            );
684
        }
685
686
        // We need to find the inverse component name, if not explicitly given
687
        if (!$relationName) {
688
            $relationName = $this->getManyManyInverseRelationship($childClass, $parentClass);
689
        }
690
691
        // Check valid relation found
692
        if (!$relationName) {
693
            throw new LogicException(
694
                "belongs_many_many relation {$parentClass}.{$component} points to "
695
                . "{$specification} without matching many_many"
696
            );
697
        }
698
699
        // Return relatios
700
        return [
701
            'childClass' => $childClass,
702
            'relationName' => $relationName
703
        ];
704
    }
705
706
    /**
707
     * Return the many-to-many extra fields specification for a specific component.
708
     *
709
     * @param string $class
710
     * @param string $component
711
     * @return array|null
712
     */
713
    public function manyManyExtraFieldsForComponent($class, $component)
714
    {
715
        // Get directly declared many_many_extraFields
716
        $extraFields = Config::inst()->get($class, 'many_many_extraFields');
717
        if (isset($extraFields[$component])) {
718
            return $extraFields[$component];
719
        }
720
721
        // If not belongs_many_many then there are no components
722
        while ($class && ($class !== DataObject::class)) {
723
            $belongsManyMany = Config::inst()->get($class, 'belongs_many_many', Config::UNINHERITED);
724
            if (isset($belongsManyMany[$component])) {
725
                // Reverse relationship and find extrafields from child class
726
                $belongs = $this->parseBelongsManyManyComponent(
727
                    $class,
728
                    $component,
729
                    $belongsManyMany[$component]
730
                );
731
                return $this->manyManyExtraFieldsForComponent($belongs['childClass'], $belongs['relationName']);
732
            }
733
            $class = get_parent_class($class);
734
        }
735
        return null;
736
    }
737
738
    /**
739
     * Return data for a specific has_many component.
740
     *
741
     * @param string $class Parent class
742
     * @param string $component
743
     * @param bool $classOnly If this is TRUE, than any has_many relationships in the form
744
     * "ClassName.Field" will have the field data stripped off. It defaults to TRUE.
745
     * @return string|null
746
     */
747
    public function hasManyComponent($class, $component, $classOnly = true)
748
    {
749
        $hasMany = (array)Config::inst()->get($class, 'has_many');
750
        if (!isset($hasMany[$component])) {
751
            return null;
752
        }
753
754
        // Remove has_one specifier if given
755
        $hasMany = $hasMany[$component];
756
        $hasManyClass = strtok($hasMany, '.');
757
758
        // Validate
759
        $this->checkRelationClass($class, $component, $hasManyClass, 'has_many');
760
        return $classOnly ? $hasManyClass : $hasMany;
761
    }
762
763
    /**
764
     * Return data for a specific has_one component.
765
     *
766
     * @param string $class
767
     * @param string $component
768
     * @return string|null
769
     */
770
    public function hasOneComponent($class, $component)
771
    {
772
        $hasOnes = Config::forClass($class)->get('has_one');
773
        if (!isset($hasOnes[$component])) {
774
            return null;
775
        }
776
777
        // Validate
778
        $relationClass = $hasOnes[$component];
779
        $this->checkRelationClass($class, $component, $relationClass, 'has_one');
780
        return $relationClass;
781
    }
782
783
    /**
784
     * Return data for a specific belongs_to component.
785
     *
786
     * @param string $class
787
     * @param string $component
788
     * @param bool $classOnly If this is TRUE, than any has_many relationships in the
789
     * form "ClassName.Field" will have the field data stripped off. It defaults to TRUE.
790
     * @return string|null
791
     */
792
    public function belongsToComponent($class, $component, $classOnly = true)
793
    {
794
        $belongsTo = (array)Config::forClass($class)->get('belongs_to');
795
        if (!isset($belongsTo[$component])) {
796
            return null;
797
        }
798
799
        // Remove has_one specifier if given
800
        $belongsTo = $belongsTo[$component];
801
        $belongsToClass = strtok($belongsTo, '.');
802
803
        // Validate
804
        $this->checkRelationClass($class, $component, $belongsToClass, 'belongs_to');
805
        return $classOnly ? $belongsToClass : $belongsTo;
806
    }
807
808
    /**
809
     *
810
     * @param string $parentClass Parent class name
811
     * @param string $component ManyMany name
812
     * @param string|array $specification Declaration of many_many relation type
813
     * @return array
814
     */
815
    protected function parseManyManyComponent($parentClass, $component, $specification)
816
    {
817
        // Check if this is many_many_through
818
        if (is_array($specification)) {
819
            // Validate join, parent and child classes
820
            $joinClass = $this->checkManyManyJoinClass($parentClass, $component, $specification);
821
            $parentClass = $this->checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, 'from');
822
            $joinChildClass = $this->checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, 'to');
823
            return [
824
                'relationClass' => ManyManyThroughList::class,
825
                'parentClass' => $parentClass,
826
                'childClass' => $joinChildClass,
827
                'parentField' => $specification['from'] . 'ID',
828
                'childField' => $specification['to'] . 'ID',
829
                'join' => $joinClass,
830
            ];
831
        }
832
833
        // Validate $specification class is valid
834
        $this->checkRelationClass($parentClass, $component, $specification, 'many_many');
835
836
        // automatic scaffolded many_many table
837
        $classTable = $this->tableName($parentClass);
838
        $parentField = "{$classTable}ID";
839
        if ($parentClass === $specification) {
840
            $childField = "ChildID";
841
        } else {
842
            $candidateTable = $this->tableName($specification);
843
            $childField = "{$candidateTable}ID";
844
        }
845
        $joinTable = "{$classTable}_{$component}";
846
        return [
847
            'relationClass' => ManyManyList::class,
848
            'parentClass' => $parentClass,
849
            'childClass' => $specification,
850
            'parentField' => $parentField,
851
            'childField' => $childField,
852
            'join' => $joinTable,
853
        ];
854
    }
855
856
    /**
857
     * Find a many_many on the child class that points back to this many_many
858
     *
859
     * @param string $childClass
860
     * @param string $parentClass
861
     * @return string|null
862
     */
863
    protected function getManyManyInverseRelationship($childClass, $parentClass)
864
    {
865
        $otherManyMany = Config::inst()->get($childClass, 'many_many', Config::UNINHERITED);
866
        if (!$otherManyMany) {
867
            return null;
868
        }
869
        foreach ($otherManyMany as $inverseComponentName => $nextClass) {
870
            if ($nextClass === $parentClass) {
871
                return $inverseComponentName;
872
            }
873
        }
874
        return null;
875
    }
876
877
    /**
878
     * Tries to find the database key on another object that is used to store a
879
     * relationship to this class. If no join field can be found it defaults to 'ParentID'.
880
     *
881
     * If the remote field is polymorphic then $polymorphic is set to true, and the return value
882
     * is in the form 'Relation' instead of 'RelationID', referencing the composite DBField.
883
     *
884
     * @param string $class
885
     * @param string $component Name of the relation on the current object pointing to the
886
     * remote object.
887
     * @param string $type the join type - either 'has_many' or 'belongs_to'
888
     * @param boolean $polymorphic Flag set to true if the remote join field is polymorphic.
889
     * @return string
890
     * @throws Exception
891
     */
892
    public function getRemoteJoinField($class, $component, $type = 'has_many', &$polymorphic = false)
893
    {
894
        // Extract relation from current object
895
        if ($type === 'has_many') {
896
            $remoteClass = $this->hasManyComponent($class, $component, false);
897
        } else {
898
            $remoteClass = $this->belongsToComponent($class, $component, false);
899
        }
900
901
        if (empty($remoteClass)) {
902
            throw new Exception("Unknown $type component '$component' on class '$class'");
903
        }
904
        if (!ClassInfo::exists(strtok($remoteClass, '.'))) {
905
            throw new Exception(
906
                "Class '$remoteClass' not found, but used in $type component '$component' on class '$class'"
907
            );
908
        }
909
910
        // If presented with an explicit field name (using dot notation) then extract field name
911
        $remoteField = null;
912
        if (strpos($remoteClass, '.') !== false) {
913
            list($remoteClass, $remoteField) = explode('.', $remoteClass);
914
        }
915
916
        // Reference remote has_one to check against
917
        $remoteRelations = Config::inst()->get($remoteClass, 'has_one');
918
919
        // Without an explicit field name, attempt to match the first remote field
920
        // with the same type as the current class
921
        if (empty($remoteField)) {
922
            // look for remote has_one joins on this class or any parent classes
923
            $remoteRelationsMap = array_flip($remoteRelations);
924
            foreach (array_reverse(ClassInfo::ancestry($class)) as $class) {
925
                if (array_key_exists($class, $remoteRelationsMap)) {
926
                    $remoteField = $remoteRelationsMap[$class];
927
                    break;
928
                }
929
            }
930
        }
931
932
        // In case of an indeterminate remote field show an error
933
        if (empty($remoteField)) {
934
            $polymorphic = false;
935
            $message = "No has_one found on class '$remoteClass'";
936
            if ($type == 'has_many') {
937
                // include a hint for has_many that is missing a has_one
938
                $message .= ", the has_many relation from '$class' to '$remoteClass'";
939
                $message .= " requires a has_one on '$remoteClass'";
940
            }
941
            throw new Exception($message);
942
        }
943
944
        // If given an explicit field name ensure the related class specifies this
945
        if (empty($remoteRelations[$remoteField])) {
946
            throw new Exception("Missing expected has_one named '$remoteField'
947
				on class '$remoteClass' referenced by $type named '$component'
948
				on class {$class}");
949
        }
950
951
        // Inspect resulting found relation
952
        if ($remoteRelations[$remoteField] === DataObject::class) {
953
            $polymorphic = true;
954
            return $remoteField; // Composite polymorphic field does not include 'ID' suffix
955
        } else {
956
            $polymorphic = false;
957
            return $remoteField . 'ID';
958
        }
959
    }
960
961
    /**
962
     * Validate the to or from field on a has_many mapping class
963
     *
964
     * @param string $parentClass Name of parent class
965
     * @param string $component Name of many_many component
966
     * @param string $joinClass Class for the joined table
967
     * @param array $specification Complete many_many specification
968
     * @param string $key Name of key to check ('from' or 'to')
969
     * @return string Class that matches the given relation
970
     * @throws InvalidArgumentException
971
     */
972
    protected function checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, $key)
973
    {
974
        // Ensure value for this key exists
975
        if (empty($specification[$key])) {
976
            throw new InvalidArgumentException(
977
                "many_many relation {$parentClass}.{$component} has missing {$key} which "
978
                . "should be a has_one on class {$joinClass}"
979
            );
980
        }
981
982
        // Check that the field exists on the given object
983
        $relation = $specification[$key];
984
        $relationClass = $this->hasOneComponent($joinClass, $relation);
985
        if (empty($relationClass)) {
986
            throw new InvalidArgumentException(
987
                "many_many through relation {$parentClass}.{$component} {$key} references a field name "
988
                . "{$joinClass}::{$relation} which is not a has_one"
989
            );
990
        }
991
992
        // Check for polymorphic
993
        if ($relationClass === DataObject::class) {
994
            throw new InvalidArgumentException(
995
                "many_many through relation {$parentClass}.{$component} {$key} references a polymorphic field "
996
                . "{$joinClass}::{$relation} which is not supported"
997
            );
998
        }
999
1000
        // Validate the join class isn't also the name of a field or relation on either side
1001
        // of the relation
1002
        $field = $this->fieldSpec($relationClass, $joinClass);
1003
        if ($field) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $field of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1004
            throw new InvalidArgumentException(
1005
                "many_many through relation {$parentClass}.{$component} {$key} class {$relationClass} "
1006
                . " cannot have a db field of the same name of the join class {$joinClass}"
1007
            );
1008
        }
1009
1010
        // Validate bad types on parent relation
1011
        if ($key === 'from' && $relationClass !== $parentClass) {
1012
            throw new InvalidArgumentException(
1013
                "many_many through relation {$parentClass}.{$component} {$key} references a field name "
1014
                . "{$joinClass}::{$relation} of type {$relationClass}; {$parentClass} expected"
1015
            );
1016
        }
1017
        return $relationClass;
1018
    }
1019
1020
    /**
1021
     * @param string $parentClass Name of parent class
1022
     * @param string $component Name of many_many component
1023
     * @param array $specification Complete many_many specification
1024
     * @return string Name of join class
1025
     */
1026
    protected function checkManyManyJoinClass($parentClass, $component, $specification)
1027
    {
1028
        if (empty($specification['through'])) {
1029
            throw new InvalidArgumentException(
1030
                "many_many relation {$parentClass}.{$component} has missing through which should be "
1031
                . "a DataObject class name to be used as a join table"
1032
            );
1033
        }
1034
        $joinClass = $specification['through'];
1035
        if (!class_exists($joinClass)) {
1036
            throw new InvalidArgumentException(
1037
                "many_many relation {$parentClass}.{$component} has through class \"{$joinClass}\" which does not exist"
1038
            );
1039
        }
1040
        return $joinClass;
1041
    }
1042
1043
    /**
1044
     * Validate a given class is valid for a relation
1045
     *
1046
     * @param string $class Parent class
1047
     * @param string $component Component name
1048
     * @param string $relationClass Candidate class to check
1049
     * @param string $type Relation type (e.g. has_one)
1050
     */
1051
    protected function checkRelationClass($class, $component, $relationClass, $type)
1052
    {
1053
        if (!is_string($component) || is_numeric($component)) {
1054
            throw new InvalidArgumentException(
1055
                "{$class} has invalid {$type} relation name"
1056
            );
1057
        }
1058
        if (!is_string($relationClass)) {
1059
            throw new InvalidArgumentException(
1060
                "{$type} relation {$class}.{$component} is not a class name"
1061
            );
1062
        }
1063
        if (!class_exists($relationClass)) {
1064
            throw new InvalidArgumentException(
1065
                "{$type} relation {$class}.{$component} references class {$relationClass} which doesn't exist"
1066
            );
1067
        }
1068
        // Support polymorphic has_one
1069
        if ($type === 'has_one') {
1070
            $valid = is_a($relationClass, DataObject::class, true);
1071
        } else {
1072
            $valid = is_subclass_of($relationClass, DataObject::class, true);
1073
        }
1074
        if (!$valid) {
1075
            throw new InvalidArgumentException(
1076
                "{$type} relation {$class}.{$component} references class {$relationClass} "
1077
                . " which is not a subclass of " . DataObject::class
1078
            );
1079
        }
1080
    }
1081
}
1082