Completed
Push — master ( c17796...052b15 )
by Damian
01:29
created

DataObjectSchema::parseManyManyComponent()   B

Complexity

Conditions 3
Paths 3

Size

Total Lines 38
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

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