Passed
Pull Request — master (#7621)
by
unknown
12:22 queued 04:52
created

DataObjectSchema::getLegacyTableNames()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 3
nc 1
nop 1
dl 0
loc 6
rs 9.4285
c 0
b 0
f 0
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 = '_';
0 ignored issues
show
introduced by
The private property $table_namespace_separator is not used, and could be removed.
Loading history...
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
     * Fields that should be indexed, by class name
48
     *
49
     * @var array
50
     */
51
    protected $defaultDatabaseIndexes = [];
52
53
    /**
54
     * Cache of composite database field
55
     *
56
     * @var array
57
     */
58
    protected $compositeFields = [];
59
60
    /**
61
     * Cache of table names
62
     *
63
     * @var array
64
     */
65
    protected $tableNames = [];
66
67
    /**
68
     * Clear cached table names
69
     */
70
    public function reset()
71
    {
72
        $this->tableNames = [];
73
        $this->databaseFields = [];
74
        $this->databaseIndexes = [];
75
        $this->defaultDatabaseIndexes = [];
76
        $this->compositeFields = [];
77
    }
78
79
    /**
80
     * Get all table names
81
     *
82
     * @return array
83
     */
84
    public function getTableNames()
85
    {
86
        $this->cacheTableNames();
87
        return $this->tableNames;
88
    }
89
90
    /**
91
     * Given a DataObject class and a field on that class, determine the appropriate SQL for
92
     * selecting / filtering on in a SQL string. Note that $class must be a valid class, not an
93
     * arbitrary table.
94
     *
95
     * The result will be a standard ANSI-sql quoted string in "Table"."Column" format.
96
     *
97
     * @param string $class Class name (not a table).
98
     * @param string $field Name of field that belongs to this class (or a parent class)
99
     * @param string $tablePrefix Optional prefix for table (alias)
100
     * @return string The SQL identifier string for the corresponding column for this field
101
     */
102
    public function sqlColumnForField($class, $field, $tablePrefix = null)
103
    {
104
        $table = $this->tableForField($class, $field);
105
        if (!$table) {
106
            throw new InvalidArgumentException("\"{$field}\" is not a field on class \"{$class}\"");
107
        }
108
        return "\"{$tablePrefix}{$table}\".\"{$field}\"";
109
    }
110
111
    /**
112
     * Get table name for the given class.
113
     *
114
     * Note that this does not confirm a table actually exists (or should exist), but returns
115
     * the name that would be used if this table did exist.
116
     *
117
     * @param string $class
118
     * @return string Returns the table name, or null if there is no table
119
     */
120
    public function tableName($class)
121
    {
122
        $tables = $this->getTableNames();
123
        $class = ClassInfo::class_name($class);
124
        if (isset($tables[$class])) {
125
            return $tables[$class];
126
        }
127
        return null;
128
    }
129
    /**
130
     * Returns the root class (the first to extend from DataObject) for the
131
     * passed class.
132
     *
133
     * @param string|object $class
134
     * @return string
135
     * @throws InvalidArgumentException
136
     */
137
    public function baseDataClass($class)
138
    {
139
        $class = ClassInfo::class_name($class);
140
        $current = $class;
141
        while ($next = get_parent_class($current)) {
142
            if ($next === DataObject::class) {
143
                return $current;
144
            }
145
            $current = $next;
146
        }
147
        throw new InvalidArgumentException("$class is not a subclass of DataObject");
148
    }
149
150
    /**
151
     * Get the base table
152
     *
153
     * @param string|object $class
154
     * @return string
155
     */
156
    public function baseDataTable($class)
157
    {
158
        return $this->tableName($this->baseDataClass($class));
159
    }
160
161
    /**
162
     * fieldSpec should exclude virtual fields (such as composite fields), and only include fields with a db column.
163
     */
164
    const DB_ONLY = 1;
165
166
    /**
167
     * fieldSpec should only return fields that belong to this table, and not any ancestors
168
     */
169
    const UNINHERITED = 2;
170
171
    /**
172
     * fieldSpec should prefix all field specifications with the class name in RecordClass.Column(spec) format.
173
     */
174
    const INCLUDE_CLASS = 4;
175
176
    /**
177
     * Get all DB field specifications for a class, including ancestors and composite fields.
178
     *
179
     * @param string|DataObject $classOrInstance
180
     * @param int $options Bitmask of options
181
     *  - UNINHERITED Limit to only this table
182
     *  - DB_ONLY Exclude virtual fields (such as composite fields), and only include fields with a db column.
183
     *  - INCLUDE_CLASS Prefix the field specification with the class name in RecordClass.Column(spec) format.
184
     * @return array List of fields, where the key is the field name and the value is the field specification.
185
     */
186
    public function fieldSpecs($classOrInstance, $options = 0)
187
    {
188
        $class = ClassInfo::class_name($classOrInstance);
189
190
        // Validate options
191
        if (!is_int($options)) {
192
            throw new InvalidArgumentException("Invalid options " . var_export($options, true));
193
        }
194
        $uninherited = ($options & self::UNINHERITED) === self::UNINHERITED;
195
        $dbOnly = ($options & self::DB_ONLY) === self::DB_ONLY;
196
        $includeClass = ($options & self::INCLUDE_CLASS) === self::INCLUDE_CLASS;
197
198
        // Walk class hierarchy
199
        $db = [];
200
        $classes = $uninherited ? [$class] : ClassInfo::ancestry($class);
201
        foreach ($classes as $tableClass) {
202
            // Find all fields on this class
203
            $fields = $this->databaseFields($tableClass, false);
204
            // Merge with composite fields
205
            if (!$dbOnly) {
206
                $compositeFields = $this->compositeFields($tableClass, false);
207
                $fields = array_merge($fields, $compositeFields);
208
            }
209
210
            // Record specification
211
            foreach ($fields as $name => $specification) {
212
                $prefix = $includeClass ? "{$tableClass}." : "";
213
                $db[$name] =  $prefix . $specification;
214
            }
215
        }
216
        return $db;
217
    }
218
219
220
    /**
221
     * Get specifications for a single class field
222
     *
223
     * @param string|DataObject $classOrInstance Name or instance of class
224
     * @param string $fieldName Name of field to retrieve
225
     * @param int $options Bitmask of options
226
     *  - UNINHERITED Limit to only this table
227
     *  - DB_ONLY Exclude virtual fields (such as composite fields), and only include fields with a db column.
228
     *  - INCLUDE_CLASS Prefix the field specification with the class name in RecordClass.Column(spec) format.
229
     * @return string|null Field will be a string in FieldClass(args) format, or
230
     * RecordClass.FieldClass(args) format if using INCLUDE_CLASS. Will be null if no field is found.
231
     */
232
    public function fieldSpec($classOrInstance, $fieldName, $options = 0)
233
    {
234
        $specs = $this->fieldSpecs($classOrInstance, $options);
235
        return isset($specs[$fieldName]) ? $specs[$fieldName] : null;
236
    }
237
238
    /**
239
     * Find the class for the given table
240
     *
241
     * @param string $table
242
     * @return string|null The FQN of the class, or null if not found
243
     */
244
    public function tableClass($table)
245
    {
246
        $tables = $this->getTableNames();
247
        $class = array_search($table, $tables, true);
248
        if ($class) {
249
            return $class;
250
        }
251
252
        // If there is no class for this table, strip table modifiers (e.g. _Live / _Versions)
253
        // from the end and re-attempt a search.
254
        if (preg_match('/^(?<class>.+)(_[^_]+)$/i', $table, $matches)) {
255
            $table = $matches['class'];
256
            $class = array_search($table, $tables, true);
257
            if ($class) {
258
                return $class;
259
            }
260
        }
261
        return null;
262
    }
263
264
    /**
265
     * Cache all table names if necessary
266
     */
267
    protected function cacheTableNames()
268
    {
269
        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...
270
            return;
271
        }
272
        $this->tableNames = [];
273
        foreach (ClassInfo::subclassesFor(DataObject::class) as $class) {
274
            if ($class === DataObject::class) {
275
                continue;
276
            }
277
            $table = $this->buildTableName($class);
278
279
            // Check for conflicts
280
            $conflict = array_search($table, $this->tableNames, true);
281
            if ($conflict) {
282
                throw new LogicException(
283
                    "Multiple classes (\"{$class}\", \"{$conflict}\") map to the same table: \"{$table}\""
284
                );
285
            }
286
            $this->tableNames[$class] = $table;
287
        }
288
    }
289
290
    /**
291
     * Generate table name for a class.
292
     *
293
     * Note: some DB schema have a hard limit on table name length. This is not enforced by this method.
294
     * See dev/build errors for details in case of table name violation.
295
     *
296
     * @param string $class
297
     * @return string
298
     */
299
    protected function buildTableName($class)
300
    {
301
        $table = Config::inst()->get($class, 'table_name', Config::UNINHERITED);
302
303
        // Generate default table name
304
        if (!$table) {
305
            $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...
306
            $parts = explode('\\', trim($class, '\\'));
307
            $vendor = array_slice($parts, 0, 1)[0];
308
            $base = array_slice($parts, -1, 1)[0];
309
            if ($vendor && $base && $vendor !== $base) {
310
                $table = "{$vendor}{$separator}{$base}";
311
            } elseif ($base) {
312
                $table = $base;
313
            } else {
314
                throw new InvalidArgumentException("Unable to build a table name for class '$class'");
315
            }
316
        }
317
318
        return $table;
319
    }
320
321
    /**
322
     * @param $class
323
     * @return array
324
     */
325
    public function getLegacyTableNames($class)
326
    {
327
        $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...
328
        $names[] = str_replace('\\', $separator, trim($class, '\\'));
0 ignored issues
show
Comprehensibility Best Practice introduced by
$names was never initialized. Although not strictly required by PHP, it is generally a good practice to add $names = array(); before regardless.
Loading history...
329
330
        return $names;
331
    }
332
333
    /**
334
     * Return the complete map of fields to specification on this object, including fixed_fields.
335
     * "ID" will be included on every table.
336
     *
337
     * @param string $class Class name to query from
338
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
339
     * @return array Map of fieldname to specification, similiar to {@link DataObject::$db}.
340
     */
341
    public function databaseFields($class, $aggregated = true)
342
    {
343
        $class = ClassInfo::class_name($class);
344
        if ($class === DataObject::class) {
345
            return [];
346
        }
347
        $this->cacheDatabaseFields($class);
348
        $fields = $this->databaseFields[$class];
349
350
        if (!$aggregated) {
351
            return $fields;
352
        }
353
354
        // Recursively merge
355
        $parentFields = $this->databaseFields(get_parent_class($class));
356
        return array_merge($fields, array_diff_key($parentFields, $fields));
357
    }
358
359
    /**
360
     * Gets a single database field.
361
     *
362
     * @param string $class Class name to query from
363
     * @param string $field Field name
364
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
365
     * @return string|null Field specification, or null if not a field
366
     */
367
    public function databaseField($class, $field, $aggregated = true)
368
    {
369
        $fields = $this->databaseFields($class, $aggregated);
370
        return isset($fields[$field]) ? $fields[$field] : null;
371
    }
372
373
    /**
374
     * @param string $class
375
     * @param bool $aggregated
376
     *
377
     * @return array
378
     */
379
    public function databaseIndexes($class, $aggregated = true)
380
    {
381
        $class = ClassInfo::class_name($class);
382
        if ($class === DataObject::class) {
383
            return [];
384
        }
385
        $this->cacheDatabaseIndexes($class);
386
        $indexes = $this->databaseIndexes[$class];
387
        if (!$aggregated) {
388
            return $indexes;
389
        }
390
        return array_merge($indexes, $this->databaseIndexes(get_parent_class($class)));
391
    }
392
393
    /**
394
     * Check if the given class has a table
395
     *
396
     * @param string $class
397
     * @return bool
398
     */
399
    public function classHasTable($class)
400
    {
401
        $fields = $this->databaseFields($class, false);
402
        return !empty($fields);
403
    }
404
405
    /**
406
     * Returns a list of all the composite if the given db field on the class is a composite field.
407
     * Will check all applicable ancestor classes and aggregate results.
408
     *
409
     * Can be called directly on an object. E.g. Member::composite_fields(), or Member::composite_fields(null, true)
410
     * to aggregate.
411
     *
412
     * Includes composite has_one (Polymorphic) fields
413
     *
414
     * @param string $class Name of class to check
415
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
416
     * @return array List of composite fields and their class spec
417
     */
418
    public function compositeFields($class, $aggregated = true)
419
    {
420
        $class = ClassInfo::class_name($class);
421
        if ($class === DataObject::class) {
422
            return [];
423
        }
424
        $this->cacheDatabaseFields($class);
425
426
        // Get fields for this class
427
        $compositeFields = $this->compositeFields[$class];
428
        if (!$aggregated) {
429
            return $compositeFields;
430
        }
431
432
        // Recursively merge
433
        $parentFields = $this->compositeFields(get_parent_class($class));
434
        return array_merge($compositeFields, array_diff_key($parentFields, $compositeFields));
435
    }
436
437
    /**
438
     * Get a composite field for a class
439
     *
440
     * @param string $class Class name to query from
441
     * @param string $field Field name
442
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
443
     * @return string|null Field specification, or null if not a field
444
     */
445
    public function compositeField($class, $field, $aggregated = true)
446
    {
447
        $fields = $this->compositeFields($class, $aggregated);
448
        return isset($fields[$field]) ? $fields[$field] : null;
449
    }
450
451
    /**
452
     * Cache all database and composite fields for the given class.
453
     * Will do nothing if already cached
454
     *
455
     * @param string $class Class name to cache
456
     */
457
    protected function cacheDatabaseFields($class)
458
    {
459
        // Skip if already cached
460
        if (isset($this->databaseFields[$class]) && isset($this->compositeFields[$class])) {
461
            return;
462
        }
463
        $compositeFields = array();
464
        $dbFields = array();
465
466
        // Ensure fixed fields appear at the start
467
        $fixedFields = DataObject::config()->uninherited('fixed_fields');
468
        if (get_parent_class($class) === DataObject::class) {
469
            // Merge fixed with ClassName spec and custom db fields
470
            $dbFields = $fixedFields;
471
        } else {
472
            $dbFields['ID'] = $fixedFields['ID'];
473
        }
474
475
        // Check each DB value as either a field or composite field
476
        $db = Config::inst()->get($class, 'db', Config::UNINHERITED) ?: array();
477
        foreach ($db as $fieldName => $fieldSpec) {
478
            $fieldClass = strtok($fieldSpec, '(');
479
            if (singleton($fieldClass) instanceof DBComposite) {
480
                $compositeFields[$fieldName] = $fieldSpec;
481
            } else {
482
                $dbFields[$fieldName] = $fieldSpec;
483
            }
484
        }
485
486
        // Add in all has_ones
487
        $hasOne = Config::inst()->get($class, 'has_one', Config::UNINHERITED) ?: array();
488
        foreach ($hasOne as $fieldName => $hasOneClass) {
489
            if ($hasOneClass === DataObject::class) {
490
                $compositeFields[$fieldName] = 'PolymorphicForeignKey';
491
            } else {
492
                $dbFields["{$fieldName}ID"] = 'ForeignKey';
493
            }
494
        }
495
496
        // Merge composite fields into DB
497
        foreach ($compositeFields as $fieldName => $fieldSpec) {
498
            $fieldObj = Injector::inst()->create($fieldSpec, $fieldName);
499
            $fieldObj->setTable($class);
500
            $nestedFields = $fieldObj->compositeDatabaseFields();
501
            foreach ($nestedFields as $nestedName => $nestedSpec) {
502
                $dbFields["{$fieldName}{$nestedName}"] = $nestedSpec;
503
            }
504
        }
505
506
        // Prevent field-less tables with only 'ID'
507
        if (count($dbFields) < 2) {
508
            $dbFields = [];
509
        }
510
511
        // Return cached results
512
        $this->databaseFields[$class] = $dbFields;
513
        $this->compositeFields[$class] = $compositeFields;
514
    }
515
516
    /**
517
     * Cache all indexes for the given class. Will do nothing if already cached.
518
     *
519
     * @param $class
520
     */
521
    protected function cacheDatabaseIndexes($class)
522
    {
523
        if (!array_key_exists($class, $this->databaseIndexes)) {
524
            $this->databaseIndexes[$class] = array_merge(
525
                $this->cacheDefaultDatabaseIndexes($class),
526
                $this->buildCustomDatabaseIndexes($class)
527
            );
528
        }
529
    }
530
531
    /**
532
     * Get "default" database indexable field types
533
     *
534
     * @param  string $class
535
     * @return array
536
     */
537
    protected function cacheDefaultDatabaseIndexes($class)
538
    {
539
        $indexes = [];
0 ignored issues
show
Unused Code introduced by
The assignment to $indexes is dead and can be removed.
Loading history...
540
        if (array_key_exists($class, $this->defaultDatabaseIndexes)) {
541
            return $this->defaultDatabaseIndexes[$class];
542
        }
543
        $this->defaultDatabaseIndexes[$class] = [];
544
545
        $fieldSpecs = $this->fieldSpecs($class, self::UNINHERITED);
546
        foreach ($fieldSpecs as $field => $spec) {
547
            /** @var DBField $fieldObj */
548
            $fieldObj = Injector::inst()->create($spec, $field);
549
            if ($indexSpecs = $fieldObj->getIndexSpecs()) {
550
                $this->defaultDatabaseIndexes[$class][$field] = $indexSpecs;
551
            }
552
        }
553
        return $this->defaultDatabaseIndexes[$class];
554
    }
555
556
    /**
557
     * Look for custom indexes declared on the class
558
     *
559
     * @param  string $class
560
     * @return array
561
     * @throws InvalidArgumentException If an index already exists on the class
562
     * @throws InvalidArgumentException If a custom index format is not valid
563
     */
564
    protected function buildCustomDatabaseIndexes($class)
565
    {
566
        $indexes = [];
567
        $classIndexes = Config::inst()->get($class, 'indexes', Config::UNINHERITED) ?: [];
568
        foreach ($classIndexes as $indexName => $indexSpec) {
569
            if (array_key_exists($indexName, $indexes)) {
570
                throw new InvalidArgumentException(sprintf(
571
                    'Index named "%s" already exists on class %s',
572
                    $indexName,
573
                    $class
574
                ));
575
            }
576
            if (is_array($indexSpec)) {
577
                if (!ArrayLib::is_associative($indexSpec)) {
578
                    $indexSpec = [
579
                        'columns' => $indexSpec,
580
                    ];
581
                }
582
                if (!isset($indexSpec['type'])) {
583
                    $indexSpec['type'] = 'index';
584
                }
585
                if (!isset($indexSpec['columns'])) {
586
                    $indexSpec['columns'] = [$indexName];
587
                } elseif (!is_array($indexSpec['columns'])) {
588
                    throw new InvalidArgumentException(sprintf(
589
                        'Index %s on %s is not valid. columns should be an array %s given',
590
                        var_export($indexName, true),
591
                        var_export($class, true),
592
                        var_export($indexSpec['columns'], true)
593
                    ));
594
                }
595
            } else {
596
                $indexSpec = [
597
                    'type' => 'index',
598
                    'columns' => [$indexName],
599
                ];
600
            }
601
            $indexes[$indexName] = $indexSpec;
602
        }
603
        return $indexes;
604
    }
605
606
    /**
607
     * Returns the table name in the class hierarchy which contains a given
608
     * field column for a {@link DataObject}. If the field does not exist, this
609
     * will return null.
610
     *
611
     * @param string $candidateClass
612
     * @param string $fieldName
613
     * @return string
614
     */
615
    public function tableForField($candidateClass, $fieldName)
616
    {
617
        $class = $this->classForField($candidateClass, $fieldName);
618
        if ($class) {
619
            return $this->tableName($class);
620
        }
621
        return null;
622
    }
623
624
    /**
625
     * Returns the class name in the class hierarchy which contains a given
626
     * field column for a {@link DataObject}. If the field does not exist, this
627
     * will return null.
628
     *
629
     * @param string $candidateClass
630
     * @param string $fieldName
631
     * @return string
632
     */
633
    public function classForField($candidateClass, $fieldName)
634
    {
635
        // normalise class name
636
        $candidateClass = ClassInfo::class_name($candidateClass);
637
        if ($candidateClass === DataObject::class) {
638
            return null;
639
        }
640
641
        // Short circuit for fixed fields
642
        $fixed = DataObject::config()->uninherited('fixed_fields');
643
        if (isset($fixed[$fieldName])) {
644
            return $this->baseDataClass($candidateClass);
645
        }
646
647
        // Find regular field
648
        while ($candidateClass && $candidateClass !== DataObject::class) {
649
            $fields = $this->databaseFields($candidateClass, false);
650
            if (isset($fields[$fieldName])) {
651
                return $candidateClass;
652
            }
653
            $candidateClass = get_parent_class($candidateClass);
654
        }
655
        return null;
656
    }
657
658
    /**
659
     * Return information about a specific many_many component. Returns a numeric array.
660
     * The first item in the array will be the class name of the relation.
661
     *
662
     * Standard many_many return type is:
663
     *
664
     * array(
665
     *  <manyManyClass>,        Name of class for relation. E.g. "Categories"
666
     *  <classname>,            The class that relation is defined in e.g. "Product"
667
     *  <candidateName>,        The target class of the relation e.g. "Category"
668
     *  <parentField>,          The field name pointing to <classname>'s table e.g. "ProductID".
669
     *  <childField>,           The field name pointing to <candidatename>'s table e.g. "CategoryID".
670
     *  <joinTableOrRelation>   The join table between the two classes e.g. "Product_Categories".
671
     *                          If the class name is 'ManyManyThroughList' then this is the name of the
672
     *                          has_many relation.
673
     * )
674
     * @param string $class Name of class to get component for
675
     * @param string $component The component name
676
     * @return array|null
677
     */
678
    public function manyManyComponent($class, $component)
679
    {
680
        $classes = ClassInfo::ancestry($class);
681
        foreach ($classes as $parentClass) {
682
            // Check if the component is defined in many_many on this class
683
            $otherManyMany = Config::inst()->get($parentClass, 'many_many', Config::UNINHERITED);
684
            if (isset($otherManyMany[$component])) {
685
                return $this->parseManyManyComponent($parentClass, $component, $otherManyMany[$component]);
686
            }
687
688
            // Check if the component is defined in belongs_many_many on this class
689
            $belongsManyMany = Config::inst()->get($parentClass, 'belongs_many_many', Config::UNINHERITED);
690
            if (!isset($belongsManyMany[$component])) {
691
                continue;
692
            }
693
694
            // Extract class and relation name from dot-notation
695
            $belongs = $this->parseBelongsManyManyComponent(
696
                $parentClass,
697
                $component,
698
                $belongsManyMany[$component]
699
            );
700
701
            // Build inverse relationship from other many_many, and swap parent/child
702
            $otherManyMany = $this->manyManyComponent($belongs['childClass'], $belongs['relationName']);
703
            return [
704
                'relationClass' => $otherManyMany['relationClass'],
705
                'parentClass' => $otherManyMany['childClass'],
706
                'childClass' => $otherManyMany['parentClass'],
707
                'parentField' => $otherManyMany['childField'],
708
                'childField' => $otherManyMany['parentField'],
709
                'join' => $otherManyMany['join'],
710
            ];
711
        }
712
        return null;
713
    }
714
715
716
717
    /**
718
     * Parse a belongs_many_many component to extract class and relationship name
719
     *
720
     * @param string $parentClass Name of class
721
     * @param string $component Name of relation on class
722
     * @param string $specification specification for this belongs_many_many
723
     * @return array Array with child class and relation name
724
     */
725
    protected function parseBelongsManyManyComponent($parentClass, $component, $specification)
726
    {
727
        $childClass = $specification;
728
        $relationName = null;
729
        if (strpos($specification, '.') !== false) {
730
            list($childClass, $relationName) = explode('.', $specification, 2);
731
        }
732
733
        // Check child class exists
734
        if (!class_exists($childClass)) {
735
            throw new LogicException(
736
                "belongs_many_many relation {$parentClass}.{$component} points to "
737
                . "{$childClass} which does not exist"
738
            );
739
        }
740
741
        // We need to find the inverse component name, if not explicitly given
742
        if (!$relationName) {
743
            $relationName = $this->getManyManyInverseRelationship($childClass, $parentClass);
744
        }
745
746
        // Check valid relation found
747
        if (!$relationName) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $relationName of type null|string 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...
748
            throw new LogicException(
749
                "belongs_many_many relation {$parentClass}.{$component} points to "
750
                . "{$specification} without matching many_many"
751
            );
752
        }
753
754
        // Return relatios
755
        return [
756
            'childClass' => $childClass,
757
            'relationName' => $relationName
758
        ];
759
    }
760
761
    /**
762
     * Return the many-to-many extra fields specification for a specific component.
763
     *
764
     * @param string $class
765
     * @param string $component
766
     * @return array|null
767
     */
768
    public function manyManyExtraFieldsForComponent($class, $component)
769
    {
770
        // Get directly declared many_many_extraFields
771
        $extraFields = Config::inst()->get($class, 'many_many_extraFields');
772
        if (isset($extraFields[$component])) {
773
            return $extraFields[$component];
774
        }
775
776
        // If not belongs_many_many then there are no components
777
        while ($class && ($class !== DataObject::class)) {
778
            $belongsManyMany = Config::inst()->get($class, 'belongs_many_many', Config::UNINHERITED);
779
            if (isset($belongsManyMany[$component])) {
780
                // Reverse relationship and find extrafields from child class
781
                $belongs = $this->parseBelongsManyManyComponent(
782
                    $class,
783
                    $component,
784
                    $belongsManyMany[$component]
785
                );
786
                return $this->manyManyExtraFieldsForComponent($belongs['childClass'], $belongs['relationName']);
787
            }
788
            $class = get_parent_class($class);
789
        }
790
        return null;
791
    }
792
793
    /**
794
     * Return data for a specific has_many component.
795
     *
796
     * @param string $class Parent class
797
     * @param string $component
798
     * @param bool $classOnly If this is TRUE, than any has_many relationships in the form
799
     * "ClassName.Field" will have the field data stripped off. It defaults to TRUE.
800
     * @return string|null
801
     */
802
    public function hasManyComponent($class, $component, $classOnly = true)
803
    {
804
        $hasMany = (array)Config::inst()->get($class, 'has_many');
805
        if (!isset($hasMany[$component])) {
806
            return null;
807
        }
808
809
        // Remove has_one specifier if given
810
        $hasMany = $hasMany[$component];
811
        $hasManyClass = strtok($hasMany, '.');
812
813
        // Validate
814
        $this->checkRelationClass($class, $component, $hasManyClass, 'has_many');
815
        return $classOnly ? $hasManyClass : $hasMany;
816
    }
817
818
    /**
819
     * Return data for a specific has_one component.
820
     *
821
     * @param string $class
822
     * @param string $component
823
     * @return string|null
824
     */
825
    public function hasOneComponent($class, $component)
826
    {
827
        $hasOnes = Config::forClass($class)->get('has_one');
828
        if (!isset($hasOnes[$component])) {
829
            return null;
830
        }
831
832
        // Validate
833
        $relationClass = $hasOnes[$component];
834
        $this->checkRelationClass($class, $component, $relationClass, 'has_one');
835
        return $relationClass;
836
    }
837
838
    /**
839
     * Return data for a specific belongs_to component.
840
     *
841
     * @param string $class
842
     * @param string $component
843
     * @param bool $classOnly If this is TRUE, than any has_many relationships in the
844
     * form "ClassName.Field" will have the field data stripped off. It defaults to TRUE.
845
     * @return string|null
846
     */
847
    public function belongsToComponent($class, $component, $classOnly = true)
848
    {
849
        $belongsTo = (array)Config::forClass($class)->get('belongs_to');
850
        if (!isset($belongsTo[$component])) {
851
            return null;
852
        }
853
854
        // Remove has_one specifier if given
855
        $belongsTo = $belongsTo[$component];
856
        $belongsToClass = strtok($belongsTo, '.');
857
858
        // Validate
859
        $this->checkRelationClass($class, $component, $belongsToClass, 'belongs_to');
860
        return $classOnly ? $belongsToClass : $belongsTo;
861
    }
862
863
    /**
864
     *
865
     * @param string $parentClass Parent class name
866
     * @param string $component ManyMany name
867
     * @param string|array $specification Declaration of many_many relation type
868
     * @return array
869
     */
870
    protected function parseManyManyComponent($parentClass, $component, $specification)
871
    {
872
        // Check if this is many_many_through
873
        if (is_array($specification)) {
874
            // Validate join, parent and child classes
875
            $joinClass = $this->checkManyManyJoinClass($parentClass, $component, $specification);
876
            $parentClass = $this->checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, 'from');
877
            $joinChildClass = $this->checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, 'to');
878
            return [
879
                'relationClass' => ManyManyThroughList::class,
880
                'parentClass' => $parentClass,
881
                'childClass' => $joinChildClass,
882
                'parentField' => $specification['from'] . 'ID',
883
                'childField' => $specification['to'] . 'ID',
884
                'join' => $joinClass,
885
            ];
886
        }
887
888
        // Validate $specification class is valid
889
        $this->checkRelationClass($parentClass, $component, $specification, 'many_many');
890
891
        // automatic scaffolded many_many table
892
        $classTable = $this->tableName($parentClass);
893
        $parentField = "{$classTable}ID";
894
        if ($parentClass === $specification) {
895
            $childField = "ChildID";
896
        } else {
897
            $candidateTable = $this->tableName($specification);
898
            $childField = "{$candidateTable}ID";
899
        }
900
        $joinTable = "{$classTable}_{$component}";
901
        return [
902
            'relationClass' => ManyManyList::class,
903
            'parentClass' => $parentClass,
904
            'childClass' => $specification,
905
            'parentField' => $parentField,
906
            'childField' => $childField,
907
            'join' => $joinTable,
908
        ];
909
    }
910
911
    /**
912
     * Find a many_many on the child class that points back to this many_many
913
     *
914
     * @param string $childClass
915
     * @param string $parentClass
916
     * @return string|null
917
     */
918
    protected function getManyManyInverseRelationship($childClass, $parentClass)
919
    {
920
        $otherManyMany = Config::inst()->get($childClass, 'many_many', Config::UNINHERITED);
921
        if (!$otherManyMany) {
922
            return null;
923
        }
924
        foreach ($otherManyMany as $inverseComponentName => $nextClass) {
925
            if ($nextClass === $parentClass) {
926
                return $inverseComponentName;
927
            }
928
        }
929
        return null;
930
    }
931
932
    /**
933
     * Tries to find the database key on another object that is used to store a
934
     * relationship to this class. If no join field can be found it defaults to 'ParentID'.
935
     *
936
     * If the remote field is polymorphic then $polymorphic is set to true, and the return value
937
     * is in the form 'Relation' instead of 'RelationID', referencing the composite DBField.
938
     *
939
     * @param string $class
940
     * @param string $component Name of the relation on the current object pointing to the
941
     * remote object.
942
     * @param string $type the join type - either 'has_many' or 'belongs_to'
943
     * @param boolean $polymorphic Flag set to true if the remote join field is polymorphic.
944
     * @return string
945
     * @throws Exception
946
     */
947
    public function getRemoteJoinField($class, $component, $type = 'has_many', &$polymorphic = false)
948
    {
949
        // Extract relation from current object
950
        if ($type === 'has_many') {
951
            $remoteClass = $this->hasManyComponent($class, $component, false);
952
        } else {
953
            $remoteClass = $this->belongsToComponent($class, $component, false);
954
        }
955
956
        if (empty($remoteClass)) {
957
            throw new Exception("Unknown $type component '$component' on class '$class'");
958
        }
959
        if (!ClassInfo::exists(strtok($remoteClass, '.'))) {
960
            throw new Exception(
961
                "Class '$remoteClass' not found, but used in $type component '$component' on class '$class'"
962
            );
963
        }
964
965
        // If presented with an explicit field name (using dot notation) then extract field name
966
        $remoteField = null;
967
        if (strpos($remoteClass, '.') !== false) {
968
            list($remoteClass, $remoteField) = explode('.', $remoteClass);
969
        }
970
971
        // Reference remote has_one to check against
972
        $remoteRelations = Config::inst()->get($remoteClass, 'has_one');
973
974
        // Without an explicit field name, attempt to match the first remote field
975
        // with the same type as the current class
976
        if (empty($remoteField)) {
977
            // look for remote has_one joins on this class or any parent classes
978
            $remoteRelationsMap = array_flip($remoteRelations);
979
            foreach (array_reverse(ClassInfo::ancestry($class)) as $ancestryClass) {
980
                if (array_key_exists($ancestryClass, $remoteRelationsMap)) {
981
                    $remoteField = $remoteRelationsMap[$ancestryClass];
982
                    break;
983
                }
984
            }
985
        }
986
987
        // In case of an indeterminate remote field show an error
988
        if (empty($remoteField)) {
989
            $polymorphic = false;
990
            $message = "No has_one found on class '$remoteClass'";
991
            if ($type == 'has_many') {
992
                // include a hint for has_many that is missing a has_one
993
                $message .= ", the has_many relation from '$class' to '$remoteClass'";
994
                $message .= " requires a has_one on '$remoteClass'";
995
            }
996
            throw new Exception($message);
997
        }
998
999
        // If given an explicit field name ensure the related class specifies this
1000
        if (empty($remoteRelations[$remoteField])) {
1001
            throw new Exception("Missing expected has_one named '$remoteField'
1002
				on class '$remoteClass' referenced by $type named '$component'
1003
				on class {$class}");
1004
        }
1005
1006
        // Inspect resulting found relation
1007
        if ($remoteRelations[$remoteField] === DataObject::class) {
1008
            $polymorphic = true;
1009
            return $remoteField; // Composite polymorphic field does not include 'ID' suffix
1010
        } else {
1011
            $polymorphic = false;
1012
            return $remoteField . 'ID';
1013
        }
1014
    }
1015
1016
    /**
1017
     * Validate the to or from field on a has_many mapping class
1018
     *
1019
     * @param string $parentClass Name of parent class
1020
     * @param string $component Name of many_many component
1021
     * @param string $joinClass Class for the joined table
1022
     * @param array $specification Complete many_many specification
1023
     * @param string $key Name of key to check ('from' or 'to')
1024
     * @return string Class that matches the given relation
1025
     * @throws InvalidArgumentException
1026
     */
1027
    protected function checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, $key)
1028
    {
1029
        // Ensure value for this key exists
1030
        if (empty($specification[$key])) {
1031
            throw new InvalidArgumentException(
1032
                "many_many relation {$parentClass}.{$component} has missing {$key} which "
1033
                . "should be a has_one on class {$joinClass}"
1034
            );
1035
        }
1036
1037
        // Check that the field exists on the given object
1038
        $relation = $specification[$key];
1039
        $relationClass = $this->hasOneComponent($joinClass, $relation);
1040
        if (empty($relationClass)) {
1041
            throw new InvalidArgumentException(
1042
                "many_many through relation {$parentClass}.{$component} {$key} references a field name "
1043
                . "{$joinClass}::{$relation} which is not a has_one"
1044
            );
1045
        }
1046
1047
        // Check for polymorphic
1048
        if ($relationClass === DataObject::class) {
1049
            throw new InvalidArgumentException(
1050
                "many_many through relation {$parentClass}.{$component} {$key} references a polymorphic field "
1051
                . "{$joinClass}::{$relation} which is not supported"
1052
            );
1053
        }
1054
1055
        // Validate the join class isn't also the name of a field or relation on either side
1056
        // of the relation
1057
        $field = $this->fieldSpec($relationClass, $joinClass);
1058
        if ($field) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $field 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...
1059
            throw new InvalidArgumentException(
1060
                "many_many through relation {$parentClass}.{$component} {$key} class {$relationClass} "
1061
                . " cannot have a db field of the same name of the join class {$joinClass}"
1062
            );
1063
        }
1064
1065
        // Validate bad types on parent relation
1066
        if ($key === 'from' && $relationClass !== $parentClass) {
1067
            throw new InvalidArgumentException(
1068
                "many_many through relation {$parentClass}.{$component} {$key} references a field name "
1069
                . "{$joinClass}::{$relation} of type {$relationClass}; {$parentClass} expected"
1070
            );
1071
        }
1072
        return $relationClass;
1073
    }
1074
1075
    /**
1076
     * @param string $parentClass Name of parent class
1077
     * @param string $component Name of many_many component
1078
     * @param array $specification Complete many_many specification
1079
     * @return string Name of join class
1080
     */
1081
    protected function checkManyManyJoinClass($parentClass, $component, $specification)
1082
    {
1083
        if (empty($specification['through'])) {
1084
            throw new InvalidArgumentException(
1085
                "many_many relation {$parentClass}.{$component} has missing through which should be "
1086
                . "a DataObject class name to be used as a join table"
1087
            );
1088
        }
1089
        $joinClass = $specification['through'];
1090
        if (!class_exists($joinClass)) {
1091
            throw new InvalidArgumentException(
1092
                "many_many relation {$parentClass}.{$component} has through class \"{$joinClass}\" which does not exist"
1093
            );
1094
        }
1095
        return $joinClass;
1096
    }
1097
1098
    /**
1099
     * Validate a given class is valid for a relation
1100
     *
1101
     * @param string $class Parent class
1102
     * @param string $component Component name
1103
     * @param string $relationClass Candidate class to check
1104
     * @param string $type Relation type (e.g. has_one)
1105
     */
1106
    protected function checkRelationClass($class, $component, $relationClass, $type)
1107
    {
1108
        if (!is_string($component) || is_numeric($component)) {
1109
            throw new InvalidArgumentException(
1110
                "{$class} has invalid {$type} relation name"
1111
            );
1112
        }
1113
        if (!is_string($relationClass)) {
1114
            throw new InvalidArgumentException(
1115
                "{$type} relation {$class}.{$component} is not a class name"
1116
            );
1117
        }
1118
        if (!class_exists($relationClass)) {
1119
            throw new InvalidArgumentException(
1120
                "{$type} relation {$class}.{$component} references class {$relationClass} which doesn't exist"
1121
            );
1122
        }
1123
        // Support polymorphic has_one
1124
        if ($type === 'has_one') {
1125
            $valid = is_a($relationClass, DataObject::class, true);
1126
        } else {
1127
            $valid = is_subclass_of($relationClass, DataObject::class, true);
1128
        }
1129
        if (!$valid) {
1130
            throw new InvalidArgumentException(
1131
                "{$type} relation {$class}.{$component} references class {$relationClass} "
1132
                . " which is not a subclass of " . DataObject::class
1133
            );
1134
        }
1135
    }
1136
}
1137