Completed
Push — 4.0 ( b59aea...80f83b )
by Loz
52s queued 21s
created

DataObjectSchema::getTableNames()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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

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

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

Loading history...
327
        }
328
329
        return $table;
330
    }
331
332
    /**
333
     * Return the complete map of fields to specification on this object, including fixed_fields.
334
     * "ID" will be included on every table.
335
     *
336
     * @param string $class Class name to query from
337
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
338
     * @return array Map of fieldname to specification, similiar to {@link DataObject::$db}.
339
     */
340
    public function databaseFields($class, $aggregated = true)
341
    {
342
        $class = ClassInfo::class_name($class);
343
        if ($class === DataObject::class) {
344
            return [];
345
        }
346
        $this->cacheDatabaseFields($class);
347
        $fields = $this->databaseFields[$class];
348
349
        if (!$aggregated) {
350
            return $fields;
351
        }
352
353
        // Recursively merge
354
        $parentFields = $this->databaseFields(get_parent_class($class));
355
        return array_merge($fields, array_diff_key($parentFields, $fields));
356
    }
357
358
    /**
359
     * Gets a single database field.
360
     *
361
     * @param string $class Class name to query from
362
     * @param string $field Field name
363
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
364
     * @return string|null Field specification, or null if not a field
365
     */
366
    public function databaseField($class, $field, $aggregated = true)
367
    {
368
        $fields = $this->databaseFields($class, $aggregated);
369
        return isset($fields[$field]) ? $fields[$field] : null;
370
    }
371
372
    /**
373
     * @param string $class
374
     * @param bool $aggregated
375
     *
376
     * @return array
377
     */
378
    public function databaseIndexes($class, $aggregated = true)
379
    {
380
        $class = ClassInfo::class_name($class);
381
        if ($class === DataObject::class) {
382
            return [];
383
        }
384
        $this->cacheDatabaseIndexes($class);
385
        $indexes = $this->databaseIndexes[$class];
386
        if (!$aggregated) {
387
            return $indexes;
388
        }
389
        return array_merge($indexes, $this->databaseIndexes(get_parent_class($class)));
390
    }
391
392
    /**
393
     * Check if the given class has a table
394
     *
395
     * @param string $class
396
     * @return bool
397
     */
398
    public function classHasTable($class)
399
    {
400
        if (!is_subclass_of($class, DataObject::class)) {
401
            return false;
402
        }
403
404
        $fields = $this->databaseFields($class, false);
405
        return !empty($fields);
406
    }
407
408
    /**
409
     * Returns a list of all the composite if the given db field on the class is a composite field.
410
     * Will check all applicable ancestor classes and aggregate results.
411
     *
412
     * Can be called directly on an object. E.g. Member::composite_fields(), or Member::composite_fields(null, true)
413
     * to aggregate.
414
     *
415
     * Includes composite has_one (Polymorphic) fields
416
     *
417
     * @param string $class Name of class to check
418
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
419
     * @return array List of composite fields and their class spec
420
     */
421
    public function compositeFields($class, $aggregated = true)
422
    {
423
        $class = ClassInfo::class_name($class);
424
        if ($class === DataObject::class) {
425
            return [];
426
        }
427
        $this->cacheDatabaseFields($class);
428
429
        // Get fields for this class
430
        $compositeFields = $this->compositeFields[$class];
431
        if (!$aggregated) {
432
            return $compositeFields;
433
        }
434
435
        // Recursively merge
436
        $parentFields = $this->compositeFields(get_parent_class($class));
437
        return array_merge($compositeFields, array_diff_key($parentFields, $compositeFields));
438
    }
439
440
    /**
441
     * Get a composite field for a class
442
     *
443
     * @param string $class Class name to query from
444
     * @param string $field Field name
445
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
446
     * @return string|null Field specification, or null if not a field
447
     */
448
    public function compositeField($class, $field, $aggregated = true)
449
    {
450
        $fields = $this->compositeFields($class, $aggregated);
451
        return isset($fields[$field]) ? $fields[$field] : null;
452
    }
453
454
    /**
455
     * Cache all database and composite fields for the given class.
456
     * Will do nothing if already cached
457
     *
458
     * @param string $class Class name to cache
459
     */
460
    protected function cacheDatabaseFields($class)
461
    {
462
        // Skip if already cached
463
        if (isset($this->databaseFields[$class]) && isset($this->compositeFields[$class])) {
464
            return;
465
        }
466
        $compositeFields = array();
467
        $dbFields = array();
468
469
        // Ensure fixed fields appear at the start
470
        $fixedFields = DataObject::config()->uninherited('fixed_fields');
471
        if (get_parent_class($class) === DataObject::class) {
472
            // Merge fixed with ClassName spec and custom db fields
473
            $dbFields = $fixedFields;
474
        } else {
475
            $dbFields['ID'] = $fixedFields['ID'];
476
        }
477
478
        // Check each DB value as either a field or composite field
479
        $db = Config::inst()->get($class, 'db', Config::UNINHERITED) ?: array();
480
        foreach ($db as $fieldName => $fieldSpec) {
481
            $fieldClass = strtok($fieldSpec, '(');
482
            if (singleton($fieldClass) instanceof DBComposite) {
483
                $compositeFields[$fieldName] = $fieldSpec;
484
            } else {
485
                $dbFields[$fieldName] = $fieldSpec;
486
            }
487
        }
488
489
        // Add in all has_ones
490
        $hasOne = Config::inst()->get($class, 'has_one', Config::UNINHERITED) ?: array();
491
        foreach ($hasOne as $fieldName => $hasOneClass) {
492
            if ($hasOneClass === DataObject::class) {
493
                $compositeFields[$fieldName] = 'PolymorphicForeignKey';
494
            } else {
495
                $dbFields["{$fieldName}ID"] = 'ForeignKey';
496
            }
497
        }
498
499
        // Merge composite fields into DB
500
        foreach ($compositeFields as $fieldName => $fieldSpec) {
501
            $fieldObj = Injector::inst()->create($fieldSpec, $fieldName);
502
            $fieldObj->setTable($class);
503
            $nestedFields = $fieldObj->compositeDatabaseFields();
504
            foreach ($nestedFields as $nestedName => $nestedSpec) {
505
                $dbFields["{$fieldName}{$nestedName}"] = $nestedSpec;
506
            }
507
        }
508
509
        // Prevent field-less tables with only 'ID'
510
        if (count($dbFields) < 2) {
511
            $dbFields = [];
512
        }
513
514
        // Return cached results
515
        $this->databaseFields[$class] = $dbFields;
516
        $this->compositeFields[$class] = $compositeFields;
517
    }
518
519
    /**
520
     * Cache all indexes for the given class. Will do nothing if already cached.
521
     *
522
     * @param $class
523
     */
524
    protected function cacheDatabaseIndexes($class)
525
    {
526
        if (!array_key_exists($class, $this->databaseIndexes)) {
527
            $this->databaseIndexes[$class] = array_merge(
528
                $this->cacheDefaultDatabaseIndexes($class),
529
                $this->buildCustomDatabaseIndexes($class)
530
            );
531
        }
532
    }
533
534
    /**
535
     * Get "default" database indexable field types
536
     *
537
     * @param  string $class
538
     * @return array
539
     */
540
    protected function cacheDefaultDatabaseIndexes($class)
541
    {
542
        if (array_key_exists($class, $this->defaultDatabaseIndexes)) {
543
            return $this->defaultDatabaseIndexes[$class];
544
        }
545
        $this->defaultDatabaseIndexes[$class] = [];
546
547
        $fieldSpecs = $this->fieldSpecs($class, self::UNINHERITED);
548
        foreach ($fieldSpecs as $field => $spec) {
549
            /** @var DBField $fieldObj */
550
            $fieldObj = Injector::inst()->create($spec, $field);
551
            if ($indexSpecs = $fieldObj->getIndexSpecs()) {
552
                $this->defaultDatabaseIndexes[$class][$field] = $indexSpecs;
553
            }
554
        }
555
        return $this->defaultDatabaseIndexes[$class];
556
    }
557
558
    /**
559
     * Look for custom indexes declared on the class
560
     *
561
     * @param  string $class
562
     * @return array
563
     * @throws InvalidArgumentException If an index already exists on the class
564
     * @throws InvalidArgumentException If a custom index format is not valid
565
     */
566
    protected function buildCustomDatabaseIndexes($class)
567
    {
568
        $indexes = [];
569
        $classIndexes = Config::inst()->get($class, 'indexes', Config::UNINHERITED) ?: [];
570
        foreach ($classIndexes as $indexName => $indexSpec) {
571
            if (array_key_exists($indexName, $indexes)) {
572
                throw new InvalidArgumentException(sprintf(
573
                    'Index named "%s" already exists on class %s',
574
                    $indexName,
575
                    $class
576
                ));
577
            }
578
            if (is_array($indexSpec)) {
579
                if (!ArrayLib::is_associative($indexSpec)) {
580
                    $indexSpec = [
581
                        'columns' => $indexSpec,
582
                    ];
583
                }
584
                if (!isset($indexSpec['type'])) {
585
                    $indexSpec['type'] = 'index';
586
                }
587
                if (!isset($indexSpec['columns'])) {
588
                    $indexSpec['columns'] = [$indexName];
589
                } elseif (!is_array($indexSpec['columns'])) {
590
                    throw new InvalidArgumentException(sprintf(
591
                        'Index %s on %s is not valid. columns should be an array %s given',
592
                        var_export($indexName, true),
593
                        var_export($class, true),
594
                        var_export($indexSpec['columns'], true)
595
                    ));
596
                }
597
            } else {
598
                $indexSpec = [
599
                    'type' => 'index',
600
                    'columns' => [$indexName],
601
                ];
602
            }
603
            $indexes[$indexName] = $indexSpec;
604
        }
605
        return $indexes;
606
    }
607
608
    /**
609
     * Returns the table name in the class hierarchy which contains a given
610
     * field column for a {@link DataObject}. If the field does not exist, this
611
     * will return null.
612
     *
613
     * @param string $candidateClass
614
     * @param string $fieldName
615
     * @return string
616
     */
617
    public function tableForField($candidateClass, $fieldName)
618
    {
619
        $class = $this->classForField($candidateClass, $fieldName);
620
        if ($class) {
621
            return $this->tableName($class);
622
        }
623
        return null;
624
    }
625
626
    /**
627
     * Returns the class name in the class hierarchy which contains a given
628
     * field column for a {@link DataObject}. If the field does not exist, this
629
     * will return null.
630
     *
631
     * @param string $candidateClass
632
     * @param string $fieldName
633
     * @return string
634
     */
635
    public function classForField($candidateClass, $fieldName)
636
    {
637
        // normalise class name
638
        $candidateClass = ClassInfo::class_name($candidateClass);
639
        if ($candidateClass === DataObject::class) {
640
            return null;
641
        }
642
643
        // Short circuit for fixed fields
644
        $fixed = DataObject::config()->uninherited('fixed_fields');
645
        if (isset($fixed[$fieldName])) {
646
            return $this->baseDataClass($candidateClass);
647
        }
648
649
        // Find regular field
650
        while ($candidateClass && $candidateClass !== DataObject::class) {
651
            $fields = $this->databaseFields($candidateClass, false);
652
            if (isset($fields[$fieldName])) {
653
                return $candidateClass;
654
            }
655
            $candidateClass = get_parent_class($candidateClass);
656
        }
657
        return null;
658
    }
659
660
    /**
661
     * Return information about a specific many_many component. Returns a numeric array.
662
     * The first item in the array will be the class name of the relation.
663
     *
664
     * Standard many_many return type is:
665
     *
666
     * array(
667
     *  <manyManyClass>,        Name of class for relation. E.g. "Categories"
668
     *  <classname>,            The class that relation is defined in e.g. "Product"
669
     *  <candidateName>,        The target class of the relation e.g. "Category"
670
     *  <parentField>,          The field name pointing to <classname>'s table e.g. "ProductID".
671
     *  <childField>,           The field name pointing to <candidatename>'s table e.g. "CategoryID".
672
     *  <joinTableOrRelation>   The join table between the two classes e.g. "Product_Categories".
673
     *                          If the class name is 'ManyManyThroughList' then this is the name of the
674
     *                          has_many relation.
675
     * )
676
     * @param string $class Name of class to get component for
677
     * @param string $component The component name
678
     * @return array|null
679
     */
680
    public function manyManyComponent($class, $component)
681
    {
682
        $classes = ClassInfo::ancestry($class);
683
        foreach ($classes as $parentClass) {
684
            // Check if the component is defined in many_many on this class
685
            $otherManyMany = Config::inst()->get($parentClass, 'many_many', Config::UNINHERITED);
686
            if (isset($otherManyMany[$component])) {
687
                return $this->parseManyManyComponent($parentClass, $component, $otherManyMany[$component]);
688
            }
689
690
            // Check if the component is defined in belongs_many_many on this class
691
            $belongsManyMany = Config::inst()->get($parentClass, 'belongs_many_many', Config::UNINHERITED);
692
            if (!isset($belongsManyMany[$component])) {
693
                continue;
694
            }
695
696
            // Extract class and relation name from dot-notation
697
            $belongs = $this->parseBelongsManyManyComponent(
698
                $parentClass,
699
                $component,
700
                $belongsManyMany[$component]
701
            );
702
703
            // Build inverse relationship from other many_many, and swap parent/child
704
            $otherManyMany = $this->manyManyComponent($belongs['childClass'], $belongs['relationName']);
705
            return [
706
                'relationClass' => $otherManyMany['relationClass'],
707
                'parentClass' => $otherManyMany['childClass'],
708
                'childClass' => $otherManyMany['parentClass'],
709
                'parentField' => $otherManyMany['childField'],
710
                'childField' => $otherManyMany['parentField'],
711
                'join' => $otherManyMany['join'],
712
            ];
713
        }
714
        return null;
715
    }
716
717
718
719
    /**
720
     * Parse a belongs_many_many component to extract class and relationship name
721
     *
722
     * @param string $parentClass Name of class
723
     * @param string $component Name of relation on class
724
     * @param string $specification specification for this belongs_many_many
725
     * @return array Array with child class and relation name
726
     */
727
    protected function parseBelongsManyManyComponent($parentClass, $component, $specification)
728
    {
729
        $childClass = $specification;
730
        $relationName = null;
731
        if (strpos($specification, '.') !== false) {
732
            list($childClass, $relationName) = explode('.', $specification, 2);
733
        }
734
735
        // Check child class exists
736
        if (!class_exists($childClass)) {
737
            throw new LogicException(
738
                "belongs_many_many relation {$parentClass}.{$component} points to "
739
                . "{$childClass} which does not exist"
740
            );
741
        }
742
743
        // We need to find the inverse component name, if not explicitly given
744
        if (!$relationName) {
745
            $relationName = $this->getManyManyInverseRelationship($childClass, $parentClass);
746
        }
747
748
        // Check valid relation found
749
        if (!$relationName) {
750
            throw new LogicException(
751
                "belongs_many_many relation {$parentClass}.{$component} points to "
752
                . "{$specification} without matching many_many"
753
            );
754
        }
755
756
        // Return relatios
757
        return [
758
            'childClass' => $childClass,
759
            'relationName' => $relationName
760
        ];
761
    }
762
763
    /**
764
     * Return the many-to-many extra fields specification for a specific component.
765
     *
766
     * @param string $class
767
     * @param string $component
768
     * @return array|null
769
     */
770
    public function manyManyExtraFieldsForComponent($class, $component)
771
    {
772
        // Get directly declared many_many_extraFields
773
        $extraFields = Config::inst()->get($class, 'many_many_extraFields');
774
        if (isset($extraFields[$component])) {
775
            return $extraFields[$component];
776
        }
777
778
        // If not belongs_many_many then there are no components
779
        while ($class && ($class !== DataObject::class)) {
780
            $belongsManyMany = Config::inst()->get($class, 'belongs_many_many', Config::UNINHERITED);
781
            if (isset($belongsManyMany[$component])) {
782
                // Reverse relationship and find extrafields from child class
783
                $belongs = $this->parseBelongsManyManyComponent(
784
                    $class,
785
                    $component,
786
                    $belongsManyMany[$component]
787
                );
788
                return $this->manyManyExtraFieldsForComponent($belongs['childClass'], $belongs['relationName']);
789
            }
790
            $class = get_parent_class($class);
791
        }
792
        return null;
793
    }
794
795
    /**
796
     * Return data for a specific has_many component.
797
     *
798
     * @param string $class Parent class
799
     * @param string $component
800
     * @param bool $classOnly If this is TRUE, than any has_many relationships in the form
801
     * "ClassName.Field" will have the field data stripped off. It defaults to TRUE.
802
     * @return string|null
803
     */
804
    public function hasManyComponent($class, $component, $classOnly = true)
805
    {
806
        $hasMany = (array)Config::inst()->get($class, 'has_many');
807
        if (!isset($hasMany[$component])) {
808
            return null;
809
        }
810
811
        // Remove has_one specifier if given
812
        $hasMany = $hasMany[$component];
813
        $hasManyClass = strtok($hasMany, '.');
814
815
        // Validate
816
        $this->checkRelationClass($class, $component, $hasManyClass, 'has_many');
817
        return $classOnly ? $hasManyClass : $hasMany;
818
    }
819
820
    /**
821
     * Return data for a specific has_one component.
822
     *
823
     * @param string $class
824
     * @param string $component
825
     * @return string|null
826
     */
827
    public function hasOneComponent($class, $component)
828
    {
829
        $hasOnes = Config::forClass($class)->get('has_one');
830
        if (!isset($hasOnes[$component])) {
831
            return null;
832
        }
833
834
        // Validate
835
        $relationClass = $hasOnes[$component];
836
        $this->checkRelationClass($class, $component, $relationClass, 'has_one');
837
        return $relationClass;
838
    }
839
840
    /**
841
     * Return data for a specific belongs_to component.
842
     *
843
     * @param string $class
844
     * @param string $component
845
     * @param bool $classOnly If this is TRUE, than any has_many relationships in the
846
     * form "ClassName.Field" will have the field data stripped off. It defaults to TRUE.
847
     * @return string|null
848
     */
849
    public function belongsToComponent($class, $component, $classOnly = true)
850
    {
851
        $belongsTo = (array)Config::forClass($class)->get('belongs_to');
852
        if (!isset($belongsTo[$component])) {
853
            return null;
854
        }
855
856
        // Remove has_one specifier if given
857
        $belongsTo = $belongsTo[$component];
858
        $belongsToClass = strtok($belongsTo, '.');
859
860
        // Validate
861
        $this->checkRelationClass($class, $component, $belongsToClass, 'belongs_to');
862
        return $classOnly ? $belongsToClass : $belongsTo;
863
    }
864
865
    /**
866
     *
867
     * @param string $parentClass Parent class name
868
     * @param string $component ManyMany name
869
     * @param string|array $specification Declaration of many_many relation type
870
     * @return array
871
     */
872
    protected function parseManyManyComponent($parentClass, $component, $specification)
873
    {
874
        // Check if this is many_many_through
875
        if (is_array($specification)) {
876
            // Validate join, parent and child classes
877
            $joinClass = $this->checkManyManyJoinClass($parentClass, $component, $specification);
878
            $parentClass = $this->checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, 'from');
879
            $joinChildClass = $this->checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, 'to');
880
            return [
881
                'relationClass' => ManyManyThroughList::class,
882
                'parentClass' => $parentClass,
883
                'childClass' => $joinChildClass,
884
                'parentField' => $specification['from'] . 'ID',
885
                'childField' => $specification['to'] . 'ID',
886
                'join' => $joinClass,
887
            ];
888
        }
889
890
        // Validate $specification class is valid
891
        $this->checkRelationClass($parentClass, $component, $specification, 'many_many');
892
893
        // automatic scaffolded many_many table
894
        $classTable = $this->tableName($parentClass);
895
        $parentField = "{$classTable}ID";
896
        if ($parentClass === $specification) {
897
            $childField = "ChildID";
898
        } else {
899
            $candidateTable = $this->tableName($specification);
900
            $childField = "{$candidateTable}ID";
901
        }
902
        $joinTable = "{$classTable}_{$component}";
903
        return [
904
            'relationClass' => ManyManyList::class,
905
            'parentClass' => $parentClass,
906
            'childClass' => $specification,
907
            'parentField' => $parentField,
908
            'childField' => $childField,
909
            'join' => $joinTable,
910
        ];
911
    }
912
913
    /**
914
     * Find a many_many on the child class that points back to this many_many
915
     *
916
     * @param string $childClass
917
     * @param string $parentClass
918
     * @return string|null
919
     */
920
    protected function getManyManyInverseRelationship($childClass, $parentClass)
921
    {
922
        $otherManyMany = Config::inst()->get($childClass, 'many_many', Config::UNINHERITED);
923
        if (!$otherManyMany) {
924
            return null;
925
        }
926
        foreach ($otherManyMany as $inverseComponentName => $nextClass) {
927
            if ($nextClass === $parentClass) {
928
                return $inverseComponentName;
929
            }
930
        }
931
        return null;
932
    }
933
934
    /**
935
     * Tries to find the database key on another object that is used to store a
936
     * relationship to this class. If no join field can be found it defaults to 'ParentID'.
937
     *
938
     * If the remote field is polymorphic then $polymorphic is set to true, and the return value
939
     * is in the form 'Relation' instead of 'RelationID', referencing the composite DBField.
940
     *
941
     * @param string $class
942
     * @param string $component Name of the relation on the current object pointing to the
943
     * remote object.
944
     * @param string $type the join type - either 'has_many' or 'belongs_to'
945
     * @param boolean $polymorphic Flag set to true if the remote join field is polymorphic.
946
     * @return string
947
     * @throws Exception
948
     */
949
    public function getRemoteJoinField($class, $component, $type = 'has_many', &$polymorphic = false)
950
    {
951
        // Extract relation from current object
952
        if ($type === 'has_many') {
953
            $remoteClass = $this->hasManyComponent($class, $component, false);
954
        } else {
955
            $remoteClass = $this->belongsToComponent($class, $component, false);
956
        }
957
958
        if (empty($remoteClass)) {
959
            throw new Exception("Unknown $type component '$component' on class '$class'");
960
        }
961
        if (!ClassInfo::exists(strtok($remoteClass, '.'))) {
962
            throw new Exception(
963
                "Class '$remoteClass' not found, but used in $type component '$component' on class '$class'"
964
            );
965
        }
966
967
        // If presented with an explicit field name (using dot notation) then extract field name
968
        $remoteField = null;
969
        if (strpos($remoteClass, '.') !== false) {
970
            list($remoteClass, $remoteField) = explode('.', $remoteClass);
971
        }
972
973
        // Reference remote has_one to check against
974
        $remoteRelations = Config::inst()->get($remoteClass, 'has_one');
975
976
        // Without an explicit field name, attempt to match the first remote field
977
        // with the same type as the current class
978
        if (empty($remoteField)) {
979
            // look for remote has_one joins on this class or any parent classes
980
            $remoteRelationsMap = array_flip($remoteRelations);
981
            foreach (array_reverse(ClassInfo::ancestry($class)) as $ancestryClass) {
982
                if (array_key_exists($ancestryClass, $remoteRelationsMap)) {
983
                    $remoteField = $remoteRelationsMap[$ancestryClass];
984
                    break;
985
                }
986
            }
987
        }
988
989
        // In case of an indeterminate remote field show an error
990
        if (empty($remoteField)) {
991
            $polymorphic = false;
992
            $message = "No has_one found on class '$remoteClass'";
993
            if ($type == 'has_many') {
994
                // include a hint for has_many that is missing a has_one
995
                $message .= ", the has_many relation from '$class' to '$remoteClass'";
996
                $message .= " requires a has_one on '$remoteClass'";
997
            }
998
            throw new Exception($message);
999
        }
1000
1001
        // If given an explicit field name ensure the related class specifies this
1002
        if (empty($remoteRelations[$remoteField])) {
1003
            throw new Exception("Missing expected has_one named '$remoteField'
1004
				on class '$remoteClass' referenced by $type named '$component'
1005
				on class {$class}");
1006
        }
1007
1008
        // Inspect resulting found relation
1009
        if ($remoteRelations[$remoteField] === DataObject::class) {
1010
            $polymorphic = true;
1011
            return $remoteField; // Composite polymorphic field does not include 'ID' suffix
1012
        } else {
1013
            $polymorphic = false;
1014
            return $remoteField . 'ID';
1015
        }
1016
    }
1017
1018
    /**
1019
     * Validate the to or from field on a has_many mapping class
1020
     *
1021
     * @param string $parentClass Name of parent class
1022
     * @param string $component Name of many_many component
1023
     * @param string $joinClass Class for the joined table
1024
     * @param array $specification Complete many_many specification
1025
     * @param string $key Name of key to check ('from' or 'to')
1026
     * @return string Class that matches the given relation
1027
     * @throws InvalidArgumentException
1028
     */
1029
    protected function checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, $key)
1030
    {
1031
        // Ensure value for this key exists
1032
        if (empty($specification[$key])) {
1033
            throw new InvalidArgumentException(
1034
                "many_many relation {$parentClass}.{$component} has missing {$key} which "
1035
                . "should be a has_one on class {$joinClass}"
1036
            );
1037
        }
1038
1039
        // Check that the field exists on the given object
1040
        $relation = $specification[$key];
1041
        $relationClass = $this->hasOneComponent($joinClass, $relation);
1042
        if (empty($relationClass)) {
1043
            throw new InvalidArgumentException(
1044
                "many_many through relation {$parentClass}.{$component} {$key} references a field name "
1045
                . "{$joinClass}::{$relation} which is not a has_one"
1046
            );
1047
        }
1048
1049
        // Check for polymorphic
1050
        if ($relationClass === DataObject::class) {
1051
            throw new InvalidArgumentException(
1052
                "many_many through relation {$parentClass}.{$component} {$key} references a polymorphic field "
1053
                . "{$joinClass}::{$relation} which is not supported"
1054
            );
1055
        }
1056
1057
        // Validate the join class isn't also the name of a field or relation on either side
1058
        // of the relation
1059
        $field = $this->fieldSpec($relationClass, $joinClass);
1060
        if ($field) {
1061
            throw new InvalidArgumentException(
1062
                "many_many through relation {$parentClass}.{$component} {$key} class {$relationClass} "
1063
                . " cannot have a db field of the same name of the join class {$joinClass}"
1064
            );
1065
        }
1066
1067
        // Validate bad types on parent relation
1068
        if ($key === 'from' && $relationClass !== $parentClass) {
1069
            throw new InvalidArgumentException(
1070
                "many_many through relation {$parentClass}.{$component} {$key} references a field name "
1071
                . "{$joinClass}::{$relation} of type {$relationClass}; {$parentClass} expected"
1072
            );
1073
        }
1074
        return $relationClass;
1075
    }
1076
1077
    /**
1078
     * @param string $parentClass Name of parent class
1079
     * @param string $component Name of many_many component
1080
     * @param array $specification Complete many_many specification
1081
     * @return string Name of join class
1082
     */
1083
    protected function checkManyManyJoinClass($parentClass, $component, $specification)
1084
    {
1085
        if (empty($specification['through'])) {
1086
            throw new InvalidArgumentException(
1087
                "many_many relation {$parentClass}.{$component} has missing through which should be "
1088
                . "a DataObject class name to be used as a join table"
1089
            );
1090
        }
1091
        $joinClass = $specification['through'];
1092
        if (!class_exists($joinClass)) {
1093
            throw new InvalidArgumentException(
1094
                "many_many relation {$parentClass}.{$component} has through class \"{$joinClass}\" which does not exist"
1095
            );
1096
        }
1097
        return $joinClass;
1098
    }
1099
1100
    /**
1101
     * Validate a given class is valid for a relation
1102
     *
1103
     * @param string $class Parent class
1104
     * @param string $component Component name
1105
     * @param string $relationClass Candidate class to check
1106
     * @param string $type Relation type (e.g. has_one)
1107
     */
1108
    protected function checkRelationClass($class, $component, $relationClass, $type)
1109
    {
1110
        if (!is_string($component) || is_numeric($component)) {
0 ignored issues
show
introduced by
The condition is_string($component) is always true.
Loading history...
1111
            throw new InvalidArgumentException(
1112
                "{$class} has invalid {$type} relation name"
1113
            );
1114
        }
1115
        if (!is_string($relationClass)) {
0 ignored issues
show
introduced by
The condition is_string($relationClass) is always true.
Loading history...
1116
            throw new InvalidArgumentException(
1117
                "{$type} relation {$class}.{$component} is not a class name"
1118
            );
1119
        }
1120
        if (!class_exists($relationClass)) {
1121
            throw new InvalidArgumentException(
1122
                "{$type} relation {$class}.{$component} references class {$relationClass} which doesn't exist"
1123
            );
1124
        }
1125
        // Support polymorphic has_one
1126
        if ($type === 'has_one') {
1127
            $valid = is_a($relationClass, DataObject::class, true);
1128
        } else {
1129
            $valid = is_subclass_of($relationClass, DataObject::class, true);
1130
        }
1131
        if (!$valid) {
1132
            throw new InvalidArgumentException(
1133
                "{$type} relation {$class}.{$component} references class {$relationClass} "
1134
                . " which is not a subclass of " . DataObject::class
1135
            );
1136
        }
1137
    }
1138
}
1139