DataObjectSchema::cacheTableNames()   A
last analyzed

Complexity

Conditions 5
Paths 5

Size

Total Lines 20
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 12
nc 5
nop 0
dl 0
loc 20
rs 9.5555
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\ORM\FieldType\DBComposite;
15
use SilverStripe\ORM\FieldType\DBField;
16
17
/**
18
 * Provides {@link \SilverStripe\ORM\DataObject} and database schema mapping functionality
19
 */
20
class DataObjectSchema
21
{
22
    use Injectable;
23
    use Configurable;
24
25
    /**
26
     * Default separate for table namespaces. Can be set to any string for
27
     * databases that do not support some characters.
28
     *
29
     * @config
30
     * @var string
31
     */
32
    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...
33
34
    /**
35
     * Cache of database fields
36
     *
37
     * @var array
38
     */
39
    protected $databaseFields = [];
40
41
    /**
42
     * Cache of database indexes
43
     *
44
     * @var array
45
     */
46
    protected $databaseIndexes = [];
47
48
    /**
49
     * Fields that should be indexed, by class name
50
     *
51
     * @var array
52
     */
53
    protected $defaultDatabaseIndexes = [];
54
55
    /**
56
     * Cache of composite database field
57
     *
58
     * @var array
59
     */
60
    protected $compositeFields = [];
61
62
    /**
63
     * Cache of table names
64
     *
65
     * @var array
66
     */
67
    protected $tableNames = [];
68
69
    /**
70
     * Clear cached table names
71
     */
72
    public function reset()
73
    {
74
        $this->tableNames = [];
75
        $this->databaseFields = [];
76
        $this->databaseIndexes = [];
77
        $this->defaultDatabaseIndexes = [];
78
        $this->compositeFields = [];
79
    }
80
81
    /**
82
     * Get all table names
83
     *
84
     * @return array
85
     */
86
    public function getTableNames()
87
    {
88
        $this->cacheTableNames();
89
        return $this->tableNames;
90
    }
91
92
    /**
93
     * Given a DataObject class and a field on that class, determine the appropriate SQL for
94
     * selecting / filtering on in a SQL string. Note that $class must be a valid class, not an
95
     * arbitrary table.
96
     *
97
     * The result will be a standard ANSI-sql quoted string in "Table"."Column" format.
98
     *
99
     * @param string $class Class name (not a table).
100
     * @param string $field Name of field that belongs to this class (or a parent class)
101
     * @param string $tablePrefix Optional prefix for table (alias)
102
     *
103
     * @return string The SQL identifier string for the corresponding column for this field
104
     */
105
    public function sqlColumnForField($class, $field, $tablePrefix = null)
106
    {
107
        $table = $this->tableForField($class, $field);
108
        if (!$table) {
109
            throw new InvalidArgumentException("\"{$field}\" is not a field on class \"{$class}\"");
110
        }
111
        return "\"{$tablePrefix}{$table}\".\"{$field}\"";
112
    }
113
114
    /**
115
     * Get table name for the given class.
116
     *
117
     * Note that this does not confirm a table actually exists (or should exist), but returns
118
     * the name that would be used if this table did exist.
119
     *
120
     * @param string $class
121
     *
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
     *
140
     * @return string
141
     * @throws InvalidArgumentException
142
     */
143
    public function baseDataClass($class)
144
    {
145
        $current = $class;
146
        while ($next = get_parent_class($current)) {
147
            if ($next === DataObject::class) {
148
                // Only use ClassInfo::class_name() to format the class if we've not used get_parent_class()
149
                return ($current === $class) ? ClassInfo::class_name($current) : $current;
150
            }
151
            $current = $next;
152
        }
153
        throw new InvalidArgumentException("$class is not a subclass of DataObject");
154
    }
155
156
    /**
157
     * Get the base table
158
     *
159
     * @param string|object $class
160
     *
161
     * @return string
162
     */
163
    public function baseDataTable($class)
164
    {
165
        return $this->tableName($this->baseDataClass($class));
166
    }
167
168
    /**
169
     * fieldSpec should exclude virtual fields (such as composite fields), and only include fields with a db column.
170
     */
171
    const DB_ONLY = 1;
172
173
    /**
174
     * fieldSpec should only return fields that belong to this table, and not any ancestors
175
     */
176
    const UNINHERITED = 2;
177
178
    /**
179
     * fieldSpec should prefix all field specifications with the class name in RecordClass.Column(spec) format.
180
     */
181
    const INCLUDE_CLASS = 4;
182
183
    /**
184
     * Get all DB field specifications for a class, including ancestors and composite fields.
185
     *
186
     * @param string|DataObject $classOrInstance
187
     * @param int $options Bitmask of options
188
     *  - UNINHERITED Limit to only this table
189
     *  - DB_ONLY Exclude virtual fields (such as composite fields), and only include fields with a db column.
190
     *  - INCLUDE_CLASS Prefix the field specification with the class name in RecordClass.Column(spec) format.
191
     *
192
     * @return array List of fields, where the key is the field name and the value is the field specification.
193
     */
194
    public function fieldSpecs($classOrInstance, $options = 0)
195
    {
196
        $class = ClassInfo::class_name($classOrInstance);
197
198
        // Validate options
199
        if (!is_int($options)) {
0 ignored issues
show
introduced by
The condition is_int($options) is always true.
Loading history...
200
            throw new InvalidArgumentException("Invalid options " . var_export($options, true));
201
        }
202
        $uninherited = ($options & self::UNINHERITED) === self::UNINHERITED;
203
        $dbOnly = ($options & self::DB_ONLY) === self::DB_ONLY;
204
        $includeClass = ($options & self::INCLUDE_CLASS) === self::INCLUDE_CLASS;
205
206
        // Walk class hierarchy
207
        $db = [];
208
        $classes = $uninherited ? [$class] : ClassInfo::ancestry($class);
209
        foreach ($classes as $tableClass) {
210
            // Skip irrelevant parent classes
211
            if (!is_subclass_of($tableClass, DataObject::class)) {
212
                continue;
213
            }
214
215
            // Find all fields on this class
216
            $fields = $this->databaseFields($tableClass, false);
217
            // Merge with composite fields
218
            if (!$dbOnly) {
219
                $compositeFields = $this->compositeFields($tableClass, false);
220
                $fields = array_merge($fields, $compositeFields);
221
            }
222
223
            // Record specification
224
            foreach ($fields as $name => $specification) {
225
                $prefix = $includeClass ? "{$tableClass}." : "";
226
                $db[$name] = $prefix . $specification;
227
            }
228
        }
229
        return $db;
230
    }
231
232
233
    /**
234
     * Get specifications for a single class field
235
     *
236
     * @param string|DataObject $classOrInstance Name or instance of class
237
     * @param string $fieldName Name of field to retrieve
238
     * @param int $options Bitmask of options
239
     *  - UNINHERITED Limit to only this table
240
     *  - DB_ONLY Exclude virtual fields (such as composite fields), and only include fields with a db column.
241
     *  - INCLUDE_CLASS Prefix the field specification with the class name in RecordClass.Column(spec) format.
242
     *
243
     * @return string|null Field will be a string in FieldClass(args) format, or
244
     * RecordClass.FieldClass(args) format if using INCLUDE_CLASS. Will be null if no field is found.
245
     */
246
    public function fieldSpec($classOrInstance, $fieldName, $options = 0)
247
    {
248
        $specs = $this->fieldSpecs($classOrInstance, $options);
249
        return isset($specs[$fieldName]) ? $specs[$fieldName] : null;
250
    }
251
252
    /**
253
     * Find the class for the given table
254
     *
255
     * @param string $table
256
     *
257
     * @return string|null The FQN of the class, or null if not found
258
     */
259
    public function tableClass($table)
260
    {
261
        $tables = $this->getTableNames();
262
        $class = array_search($table, $tables, true);
263
        if ($class) {
264
            return $class;
265
        }
266
267
        // If there is no class for this table, strip table modifiers (e.g. _Live / _Versions)
268
        // from the end and re-attempt a search.
269
        if (preg_match('/^(?<class>.+)(_[^_]+)$/i', $table, $matches)) {
270
            $table = $matches['class'];
271
            $class = array_search($table, $tables, true);
272
            if ($class) {
273
                return $class;
274
            }
275
        }
276
        return null;
277
    }
278
279
    /**
280
     * Cache all table names if necessary
281
     */
282
    protected function cacheTableNames()
283
    {
284
        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...
285
            return;
286
        }
287
        $this->tableNames = [];
288
        foreach (ClassInfo::subclassesFor(DataObject::class) as $class) {
289
            if ($class === DataObject::class) {
290
                continue;
291
            }
292
            $table = $this->buildTableName($class);
293
294
            // Check for conflicts
295
            $conflict = array_search($table, $this->tableNames, true);
296
            if ($conflict) {
297
                throw new LogicException(
298
                    "Multiple classes (\"{$class}\", \"{$conflict}\") map to the same table: \"{$table}\""
299
                );
300
            }
301
            $this->tableNames[$class] = $table;
302
        }
303
    }
304
305
    /**
306
     * Generate table name for a class.
307
     *
308
     * Note: some DB schema have a hard limit on table name length. This is not enforced by this method.
309
     * See dev/build errors for details in case of table name violation.
310
     *
311
     * @param string $class
312
     *
313
     * @return string
314
     */
315
    protected function buildTableName($class)
316
    {
317
        $table = Config::inst()->get($class, 'table_name', Config::UNINHERITED);
318
319
        // Generate default table name
320
        if (!$table) {
321
            $separator = DataObjectSchema::config()->uninherited('table_namespace_separator');
322
            $parts = explode('\\', trim($class, '\\'));
323
            $vendor = array_slice($parts, 0, 1)[0];
324
            $base = array_slice($parts, -1, 1)[0];
325
            if ($vendor && $base && $vendor !== $base) {
326
                $table = "{$vendor}{$separator}{$base}";
327
            } elseif ($base) {
328
                $table = $base;
329
            } else {
330
                throw new InvalidArgumentException("Unable to build a table name for class '$class'");
331
            }
332
        }
333
334
        return $table;
335
    }
336
337
    /**
338
     * @param $class
339
     * @return array
340
     */
341
    public function getLegacyTableNames($class)
342
    {
343
        $separator = DataObjectSchema::config()->uninherited('table_namespace_separator');
344
        $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...
345
346
        return $names;
347
    }
348
349
    /**
350
     * Return the complete map of fields to specification on this object, including fixed_fields.
351
     * "ID" will be included on every table.
352
     *
353
     * @param string $class Class name to query from
354
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
355
     *
356
     * @return array Map of fieldname to specification, similiar to {@link DataObject::$db}.
357
     */
358
    public function databaseFields($class, $aggregated = true)
359
    {
360
        $class = ClassInfo::class_name($class);
361
        if ($class === DataObject::class) {
362
            return [];
363
        }
364
        $this->cacheDatabaseFields($class);
365
        $fields = $this->databaseFields[$class];
366
367
        if (!$aggregated) {
368
            return $fields;
369
        }
370
371
        // Recursively merge
372
        $parentFields = $this->databaseFields(get_parent_class($class));
373
        return array_merge($fields, array_diff_key($parentFields, $fields));
374
    }
375
376
    /**
377
     * Gets a single database field.
378
     *
379
     * @param string $class Class name to query from
380
     * @param string $field Field name
381
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
382
     *
383
     * @return string|null Field specification, or null if not a field
384
     */
385
    public function databaseField($class, $field, $aggregated = true)
386
    {
387
        $fields = $this->databaseFields($class, $aggregated);
388
        return isset($fields[$field]) ? $fields[$field] : null;
389
    }
390
391
    /**
392
     * @param string $class
393
     * @param bool $aggregated
394
     *
395
     * @return array
396
     */
397
    public function databaseIndexes($class, $aggregated = true)
398
    {
399
        $class = ClassInfo::class_name($class);
400
        if ($class === DataObject::class) {
401
            return [];
402
        }
403
        $this->cacheDatabaseIndexes($class);
404
        $indexes = $this->databaseIndexes[$class];
405
        if (!$aggregated) {
406
            return $indexes;
407
        }
408
        return array_merge($indexes, $this->databaseIndexes(get_parent_class($class)));
409
    }
410
411
    /**
412
     * Check if the given class has a table
413
     *
414
     * @param string $class
415
     *
416
     * @return bool
417
     */
418
    public function classHasTable($class)
419
    {
420
        if (!is_subclass_of($class, DataObject::class)) {
421
            return false;
422
        }
423
424
        $fields = $this->databaseFields($class, false);
425
        return !empty($fields);
426
    }
427
428
    /**
429
     * Returns a list of all the composite if the given db field on the class is a composite field.
430
     * Will check all applicable ancestor classes and aggregate results.
431
     *
432
     * Can be called directly on an object. E.g. Member::composite_fields(), or Member::composite_fields(null, true)
433
     * to aggregate.
434
     *
435
     * Includes composite has_one (Polymorphic) fields
436
     *
437
     * @param string $class Name of class to check
438
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
439
     *
440
     * @return array List of composite fields and their class spec
441
     */
442
    public function compositeFields($class, $aggregated = true)
443
    {
444
        $class = ClassInfo::class_name($class);
445
        if ($class === DataObject::class) {
446
            return [];
447
        }
448
        $this->cacheDatabaseFields($class);
449
450
        // Get fields for this class
451
        $compositeFields = $this->compositeFields[$class];
452
        if (!$aggregated) {
453
            return $compositeFields;
454
        }
455
456
        // Recursively merge
457
        $parentFields = $this->compositeFields(get_parent_class($class));
458
        return array_merge($compositeFields, array_diff_key($parentFields, $compositeFields));
459
    }
460
461
    /**
462
     * Get a composite field for a class
463
     *
464
     * @param string $class Class name to query from
465
     * @param string $field Field name
466
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
467
     *
468
     * @return string|null Field specification, or null if not a field
469
     */
470
    public function compositeField($class, $field, $aggregated = true)
471
    {
472
        $fields = $this->compositeFields($class, $aggregated);
473
        return isset($fields[$field]) ? $fields[$field] : null;
474
    }
475
476
    /**
477
     * Cache all database and composite fields for the given class.
478
     * Will do nothing if already cached
479
     *
480
     * @param string $class Class name to cache
481
     */
482
    protected function cacheDatabaseFields($class)
483
    {
484
        // Skip if already cached
485
        if (isset($this->databaseFields[$class]) && isset($this->compositeFields[$class])) {
486
            return;
487
        }
488
        $compositeFields = array();
489
        $dbFields = array();
490
491
        // Ensure fixed fields appear at the start
492
        $fixedFields = DataObject::config()->uninherited('fixed_fields');
493
        if (get_parent_class($class) === DataObject::class) {
494
            // Merge fixed with ClassName spec and custom db fields
495
            $dbFields = $fixedFields;
496
        } else {
497
            $dbFields['ID'] = $fixedFields['ID'];
498
        }
499
500
        // Check each DB value as either a field or composite field
501
        $db = Config::inst()->get($class, 'db', Config::UNINHERITED) ?: array();
502
        foreach ($db as $fieldName => $fieldSpec) {
503
            $fieldClass = strtok($fieldSpec, '(');
504
            if (singleton($fieldClass) instanceof DBComposite) {
505
                $compositeFields[$fieldName] = $fieldSpec;
506
            } else {
507
                $dbFields[$fieldName] = $fieldSpec;
508
            }
509
        }
510
511
        // Add in all has_ones
512
        $hasOne = Config::inst()->get($class, 'has_one', Config::UNINHERITED) ?: array();
513
        foreach ($hasOne as $fieldName => $hasOneClass) {
514
            if ($hasOneClass === DataObject::class) {
515
                $compositeFields[$fieldName] = 'PolymorphicForeignKey';
516
            } else {
517
                $dbFields["{$fieldName}ID"] = 'ForeignKey';
518
            }
519
        }
520
521
        // Merge composite fields into DB
522
        foreach ($compositeFields as $fieldName => $fieldSpec) {
523
            $fieldObj = Injector::inst()->create($fieldSpec, $fieldName);
524
            $fieldObj->setTable($class);
525
            $nestedFields = $fieldObj->compositeDatabaseFields();
526
            foreach ($nestedFields as $nestedName => $nestedSpec) {
527
                $dbFields["{$fieldName}{$nestedName}"] = $nestedSpec;
528
            }
529
        }
530
531
        // Prevent field-less tables with only 'ID'
532
        if (count($dbFields) < 2) {
533
            $dbFields = [];
534
        }
535
536
        // Return cached results
537
        $this->databaseFields[$class] = $dbFields;
538
        $this->compositeFields[$class] = $compositeFields;
539
    }
540
541
    /**
542
     * Cache all indexes for the given class. Will do nothing if already cached.
543
     *
544
     * @param $class
545
     */
546
    protected function cacheDatabaseIndexes($class)
547
    {
548
        if (!array_key_exists($class, $this->databaseIndexes)) {
549
            $this->databaseIndexes[$class] = array_merge(
550
                $this->buildSortDatabaseIndexes($class),
551
                $this->cacheDefaultDatabaseIndexes($class),
552
                $this->buildCustomDatabaseIndexes($class)
553
            );
554
        }
555
    }
556
557
    /**
558
     * Get "default" database indexable field types
559
     *
560
     * @param  string $class
561
     *
562
     * @return array
563
     */
564
    protected function cacheDefaultDatabaseIndexes($class)
565
    {
566
        if (array_key_exists($class, $this->defaultDatabaseIndexes)) {
567
            return $this->defaultDatabaseIndexes[$class];
568
        }
569
        $this->defaultDatabaseIndexes[$class] = [];
570
571
        $fieldSpecs = $this->fieldSpecs($class, self::UNINHERITED);
572
        foreach ($fieldSpecs as $field => $spec) {
573
            /** @var DBField $fieldObj */
574
            $fieldObj = Injector::inst()->create($spec, $field);
575
            if ($indexSpecs = $fieldObj->getIndexSpecs()) {
576
                $this->defaultDatabaseIndexes[$class][$field] = $indexSpecs;
577
            }
578
        }
579
        return $this->defaultDatabaseIndexes[$class];
580
    }
581
582
    /**
583
     * Look for custom indexes declared on the class
584
     *
585
     * @param  string $class
586
     *
587
     * @return array
588
     * @throws InvalidArgumentException If an index already exists on the class
589
     * @throws InvalidArgumentException If a custom index format is not valid
590
     */
591
    protected function buildCustomDatabaseIndexes($class)
592
    {
593
        $indexes = [];
594
        $classIndexes = Config::inst()->get($class, 'indexes', Config::UNINHERITED) ?: [];
595
        foreach ($classIndexes as $indexName => $indexSpec) {
596
            if (array_key_exists($indexName, $indexes)) {
597
                throw new InvalidArgumentException(sprintf(
598
                    'Index named "%s" already exists on class %s',
599
                    $indexName,
600
                    $class
601
                ));
602
            }
603
            if (is_array($indexSpec)) {
604
                if (!ArrayLib::is_associative($indexSpec)) {
605
                    $indexSpec = [
606
                        'columns' => $indexSpec,
607
                    ];
608
                }
609
                if (!isset($indexSpec['type'])) {
610
                    $indexSpec['type'] = 'index';
611
                }
612
                if (!isset($indexSpec['columns'])) {
613
                    $indexSpec['columns'] = [$indexName];
614
                } elseif (!is_array($indexSpec['columns'])) {
615
                    throw new InvalidArgumentException(sprintf(
616
                        'Index %s on %s is not valid. columns should be an array %s given',
617
                        var_export($indexName, true),
618
                        var_export($class, true),
619
                        var_export($indexSpec['columns'], true)
620
                    ));
621
                }
622
            } else {
623
                $indexSpec = [
624
                    'type' => 'index',
625
                    'columns' => [$indexName],
626
                ];
627
            }
628
            $indexes[$indexName] = $indexSpec;
629
        }
630
        return $indexes;
631
    }
632
633
    protected function buildSortDatabaseIndexes($class)
634
    {
635
        $sort = Config::inst()->get($class, 'default_sort', Config::UNINHERITED);
636
        $indexes = [];
637
638
        if ($sort && is_string($sort)) {
639
            $sort = preg_split('/,(?![^()]*+\\))/', $sort);
640
            foreach ($sort as $value) {
641
                try {
642
                    list ($table, $column) = $this->parseSortColumn(trim($value));
643
                    $table = trim($table, '"');
644
                    $column = trim($column, '"');
645
                    if ($table && strtolower($table) !== strtolower(self::tableName($class))) {
0 ignored issues
show
Bug Best Practice introduced by
The method SilverStripe\ORM\DataObjectSchema::tableName() is not static, but was called statically. ( Ignorable by Annotation )

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

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