Passed
Push — fix-1683 ( 00f5cf )
by Sam
08:14
created

DataObjectSchema::classesForField()   A

Complexity

Conditions 6
Paths 8

Size

Total Lines 25
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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

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

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

Loading history...
334
        }
335
336
        return $table;
337
    }
338
339
    /**
340
     * Return the complete map of fields to specification on this object, including fixed_fields.
341
     * "ID" will be included on every table.
342
     *
343
     * @param string $class Class name to query from
344
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
345
     *
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
     *
373
     * @return string|null Field specification, or null if not a field
374
     */
375
    public function databaseField($class, $field, $aggregated = true)
376
    {
377
        $fields = $this->databaseFields($class, $aggregated);
378
        return isset($fields[$field]) ? $fields[$field] : null;
379
    }
380
381
    /**
382
     * @param string $class
383
     * @param bool $aggregated
384
     *
385
     * @return array
386
     */
387
    public function databaseIndexes($class, $aggregated = true)
388
    {
389
        $class = ClassInfo::class_name($class);
390
        if ($class === DataObject::class) {
391
            return [];
392
        }
393
        $this->cacheDatabaseIndexes($class);
394
        $indexes = $this->databaseIndexes[$class];
395
        if (!$aggregated) {
396
            return $indexes;
397
        }
398
        return array_merge($indexes, $this->databaseIndexes(get_parent_class($class)));
399
    }
400
401
    /**
402
     * Check if the given class has a table
403
     *
404
     * @param string $class
405
     *
406
     * @return bool
407
     */
408
    public function classHasTable($class)
409
    {
410
        if (!is_subclass_of($class, DataObject::class)) {
411
            return false;
412
        }
413
414
        $fields = $this->databaseFields($class, false);
415
        return !empty($fields);
416
    }
417
418
    /**
419
     * Returns a list of all the composite if the given db field on the class is a composite field.
420
     * Will check all applicable ancestor classes and aggregate results.
421
     *
422
     * Can be called directly on an object. E.g. Member::composite_fields(), or Member::composite_fields(null, true)
423
     * to aggregate.
424
     *
425
     * Includes composite has_one (Polymorphic) fields
426
     *
427
     * @param string $class Name of class to check
428
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
429
     *
430
     * @return array List of composite fields and their class spec
431
     */
432
    public function compositeFields($class, $aggregated = true)
433
    {
434
        $class = ClassInfo::class_name($class);
435
        if ($class === DataObject::class) {
436
            return [];
437
        }
438
        $this->cacheDatabaseFields($class);
439
440
        // Get fields for this class
441
        $compositeFields = $this->compositeFields[$class];
442
        if (!$aggregated) {
443
            return $compositeFields;
444
        }
445
446
        // Recursively merge
447
        $parentFields = $this->compositeFields(get_parent_class($class));
448
        return array_merge($compositeFields, array_diff_key($parentFields, $compositeFields));
449
    }
450
451
    /**
452
     * Get a composite field for a class
453
     *
454
     * @param string $class Class name to query from
455
     * @param string $field Field name
456
     * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table
457
     *
458
     * @return string|null Field specification, or null if not a field
459
     */
460
    public function compositeField($class, $field, $aggregated = true)
461
    {
462
        $fields = $this->compositeFields($class, $aggregated);
463
        return isset($fields[$field]) ? $fields[$field] : null;
464
    }
465
466
    /**
467
     * Cache all database and composite fields for the given class.
468
     * Will do nothing if already cached
469
     *
470
     * @param string $class Class name to cache
471
     */
472
    protected function cacheDatabaseFields($class)
473
    {
474
        // Skip if already cached
475
        if (isset($this->databaseFields[$class]) && isset($this->compositeFields[$class])) {
476
            return;
477
        }
478
        $compositeFields = array();
479
        $dbFields = array();
480
481
        // Ensure fixed fields appear at the start
482
        $fixedFields = DataObject::config()->uninherited('fixed_fields');
483
        if (get_parent_class($class) === DataObject::class) {
484
            // Merge fixed with ClassName spec and custom db fields
485
            $dbFields = $fixedFields;
486
        } else {
487
            $dbFields['ID'] = $fixedFields['ID'];
488
        }
489
490
        // Check each DB value as either a field or composite field
491
        $db = Config::inst()->get($class, 'db', Config::UNINHERITED) ?: array();
492
        foreach ($db as $fieldName => $fieldSpec) {
493
            $fieldClass = strtok($fieldSpec, '(');
494
            if (singleton($fieldClass) instanceof DBComposite) {
495
                $compositeFields[$fieldName] = $fieldSpec;
496
            } else {
497
                $dbFields[$fieldName] = $fieldSpec;
498
            }
499
        }
500
501
        // Add in all has_ones
502
        $hasOne = Config::inst()->get($class, 'has_one', Config::UNINHERITED) ?: array();
503
        foreach ($hasOne as $fieldName => $hasOneClass) {
504
            if ($hasOneClass === DataObject::class) {
505
                $compositeFields[$fieldName] = 'PolymorphicForeignKey';
506
            } else {
507
                $dbFields["{$fieldName}ID"] = 'ForeignKey';
508
            }
509
        }
510
511
        // Merge composite fields into DB
512
        foreach ($compositeFields as $fieldName => $fieldSpec) {
513
            $fieldObj = Injector::inst()->create($fieldSpec, $fieldName);
514
            $fieldObj->setTable($class);
515
            $nestedFields = $fieldObj->compositeDatabaseFields();
516
            foreach ($nestedFields as $nestedName => $nestedSpec) {
517
                $dbFields["{$fieldName}{$nestedName}"] = $nestedSpec;
518
            }
519
        }
520
521
        // Prevent field-less tables with only 'ID'
522
        if (count($dbFields) < 2) {
523
            $dbFields = [];
524
        }
525
526
        // Return cached results
527
        $this->databaseFields[$class] = $dbFields;
528
        $this->compositeFields[$class] = $compositeFields;
529
    }
530
531
    /**
532
     * Cache all indexes for the given class. Will do nothing if already cached.
533
     *
534
     * @param $class
535
     */
536
    protected function cacheDatabaseIndexes($class)
537
    {
538
        if (!array_key_exists($class, $this->databaseIndexes)) {
539
            $this->databaseIndexes[$class] = array_merge(
540
                $this->buildSortDatabaseIndexes($class),
541
                $this->cacheDefaultDatabaseIndexes($class),
542
                $this->buildCustomDatabaseIndexes($class)
543
            );
544
        }
545
    }
546
547
    /**
548
     * Get "default" database indexable field types
549
     *
550
     * @param  string $class
551
     *
552
     * @return array
553
     */
554
    protected function cacheDefaultDatabaseIndexes($class)
555
    {
556
        if (array_key_exists($class, $this->defaultDatabaseIndexes)) {
557
            return $this->defaultDatabaseIndexes[$class];
558
        }
559
        $this->defaultDatabaseIndexes[$class] = [];
560
561
        $fieldSpecs = $this->fieldSpecs($class, self::UNINHERITED);
562
        foreach ($fieldSpecs as $field => $spec) {
563
            /** @var DBField $fieldObj */
564
            $fieldObj = Injector::inst()->create($spec, $field);
565
            if ($indexSpecs = $fieldObj->getIndexSpecs()) {
566
                $this->defaultDatabaseIndexes[$class][$field] = $indexSpecs;
567
            }
568
        }
569
        return $this->defaultDatabaseIndexes[$class];
570
    }
571
572
    /**
573
     * Look for custom indexes declared on the class
574
     *
575
     * @param  string $class
576
     *
577
     * @return array
578
     * @throws InvalidArgumentException If an index already exists on the class
579
     * @throws InvalidArgumentException If a custom index format is not valid
580
     */
581
    protected function buildCustomDatabaseIndexes($class)
582
    {
583
        $indexes = [];
584
        $classIndexes = Config::inst()->get($class, 'indexes', Config::UNINHERITED) ?: [];
585
        foreach ($classIndexes as $indexName => $indexSpec) {
586
            if (array_key_exists($indexName, $indexes)) {
587
                throw new InvalidArgumentException(sprintf(
588
                    'Index named "%s" already exists on class %s',
589
                    $indexName,
590
                    $class
591
                ));
592
            }
593
            if (is_array($indexSpec)) {
594
                if (!ArrayLib::is_associative($indexSpec)) {
595
                    $indexSpec = [
596
                        'columns' => $indexSpec,
597
                    ];
598
                }
599
                if (!isset($indexSpec['type'])) {
600
                    $indexSpec['type'] = 'index';
601
                }
602
                if (!isset($indexSpec['columns'])) {
603
                    $indexSpec['columns'] = [$indexName];
604
                } elseif (!is_array($indexSpec['columns'])) {
605
                    throw new InvalidArgumentException(sprintf(
606
                        'Index %s on %s is not valid. columns should be an array %s given',
607
                        var_export($indexName, true),
608
                        var_export($class, true),
609
                        var_export($indexSpec['columns'], true)
610
                    ));
611
                }
612
            } else {
613
                $indexSpec = [
614
                    'type' => 'index',
615
                    'columns' => [$indexName],
616
                ];
617
            }
618
            $indexes[$indexName] = $indexSpec;
619
        }
620
        return $indexes;
621
    }
622
623
    protected function buildSortDatabaseIndexes($class)
624
    {
625
        $sort = Config::inst()->get($class, 'default_sort', Config::UNINHERITED);
626
        $indexes = [];
627
628
        if ($sort && is_string($sort)) {
629
            $sort = preg_split('/,(?![^()]*+\\))/', $sort);
630
            foreach ($sort as $value) {
631
                try {
632
                    list ($table, $column) = $this->parseSortColumn(trim($value));
633
                    $table = trim($table, '"');
634
                    $column = trim($column, '"');
635
                    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

635
                    if ($table && strtolower($table) !== strtolower(self::/** @scrutinizer ignore-call */ tableName($class))) {
Loading history...
636
                        continue;
637
                    }
638
                    if ($this->databaseField($class, $column, false)) {
639
                        $indexes[$column] = [
640
                            'type' => 'index',
641
                            'columns' => [$column],
642
                        ];
643
                    }
644
                } catch (InvalidArgumentException $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
645
                }
646
            }
647
        }
648
        return $indexes;
649
    }
650
651
    /**
652
     * Parses a specified column into a sort field and direction
653
     *
654
     * @param string $column String to parse containing the column name
655
     *
656
     * @return array Resolved table and column.
657
     */
658
    protected function parseSortColumn($column)
659
    {
660
        // Parse column specification, considering possible ansi sql quoting
661
        // Note that table prefix is allowed, but discarded
662
        if (preg_match('/^("?(?<table>[^"\s]+)"?\\.)?"?(?<column>[^"\s]+)"?(\s+(?<direction>((asc)|(desc))(ending)?))?$/i', $column, $match)) {
663
            $table = $match['table'];
664
            $column = $match['column'];
665
        } else {
666
            throw new InvalidArgumentException("Invalid sort() column");
667
        }
668
        return array($table, $column);
669
    }
670
671
    /**
672
     * Returns the table name in the class ancestry which contains a given
673
     * field column for a {@link DataObject}. If the field does not exist, this
674
     * will return null.
675
     *
676
     * @param string $candidateClass
677
     * @param string $fieldName
678
     *
679
     * @return string
680
     */
681
    public function tableForField($candidateClass, $fieldName)
682
    {
683
        $class = $this->classForField($candidateClass, $fieldName);
684
        if ($class) {
685
            return $this->tableName($class);
686
        }
687
        return null;
688
    }
689
690
    /**
691
     * Returns the table name(s) in the class ancestry and descendants which contain a given
692
     * field column for a {@link DataObject}. If the field does not exist, this will return null.
693
     *
694
     * @param string $candidateClass
695
     * @param string $fieldName
696
     *
697
     * @return string
698
     */
699
    public function tablesForField($candidateClass, $fieldName)
700
    {
701
        $classes = $this->classesForField($candidateClass, $fieldName);
702
        if ($classes) {
703
            return array_map(
0 ignored issues
show
Bug Best Practice introduced by
The expression return array_map(functio... /* ... */ }, $classes) returns the type array which is incompatible with the documented return type string.
Loading history...
704
                function ($class) {
705
                    return $this->tableName($class);
706
                },
707
                $classes
0 ignored issues
show
Bug introduced by
$classes of type string is incompatible with the type array expected by parameter $arr1 of array_map(). ( Ignorable by Annotation )

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

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