Passed
Push — 4.1.1 ( 01ed8a )
by Robbie
09:45
created

DataObjectSchema::buildSortDatabaseIndexes()   C

Complexity

Conditions 8
Paths 2

Size

Total Lines 26
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 17
nc 2
nop 1
dl 0
loc 26
rs 5.3846
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
     * @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
     * @return string Returns the table name, or null if there is no table
122
     */
123
    public function tableName($class)
124
    {
125
        $tables = $this->getTableNames();
126
        $class = ClassInfo::class_name($class);
127
        if (isset($tables[$class])) {
128
            return $tables[$class];
129
        }
130
        return null;
131
    }
132
133
    /**
134
     * Returns the root class (the first to extend from DataObject) for the
135
     * passed class.
136
     *
137
     * @param string|object $class
138
     * @return string
139
     * @throws InvalidArgumentException
140
     */
141
    public function baseDataClass($class)
142
    {
143
        $current = $class;
144
        while ($next = get_parent_class($current)) {
145
            if ($next === DataObject::class) {
146
                // Only use ClassInfo::class_name() to format the class if we've not used get_parent_class()
147
                return ($current === $class) ? ClassInfo::class_name($current) : $current;
148
            }
149
            $current = $next;
150
        }
151
        throw new InvalidArgumentException("$class is not a subclass of DataObject");
152
    }
153
154
    /**
155
     * Get the base table
156
     *
157
     * @param string|object $class
158
     * @return string
159
     */
160
    public function baseDataTable($class)
161
    {
162
        return $this->tableName($this->baseDataClass($class));
163
    }
164
165
    /**
166
     * fieldSpec should exclude virtual fields (such as composite fields), and only include fields with a db column.
167
     */
168
    const DB_ONLY = 1;
169
170
    /**
171
     * fieldSpec should only return fields that belong to this table, and not any ancestors
172
     */
173
    const UNINHERITED = 2;
174
175
    /**
176
     * fieldSpec should prefix all field specifications with the class name in RecordClass.Column(spec) format.
177
     */
178
    const INCLUDE_CLASS = 4;
179
180
    /**
181
     * Get all DB field specifications for a class, including ancestors and composite fields.
182
     *
183
     * @param string|DataObject $classOrInstance
184
     * @param int $options Bitmask of options
185
     *  - UNINHERITED Limit to only this table
186
     *  - DB_ONLY Exclude virtual fields (such as composite fields), and only include fields with a db column.
187
     *  - INCLUDE_CLASS Prefix the field specification with the class name in RecordClass.Column(spec) format.
188
     * @return array List of fields, where the key is the field name and the value is the field specification.
189
     */
190
    public function fieldSpecs($classOrInstance, $options = 0)
191
    {
192
        $class = ClassInfo::class_name($classOrInstance);
193
194
        // Validate options
195
        if (!is_int($options)) {
0 ignored issues
show
introduced by
The condition is_int($options) is always true.
Loading history...
196
            throw new InvalidArgumentException("Invalid options " . var_export($options, true));
197
        }
198
        $uninherited = ($options & self::UNINHERITED) === self::UNINHERITED;
199
        $dbOnly = ($options & self::DB_ONLY) === self::DB_ONLY;
200
        $includeClass = ($options & self::INCLUDE_CLASS) === self::INCLUDE_CLASS;
201
202
        // Walk class hierarchy
203
        $db = [];
204
        $classes = $uninherited ? [$class] : ClassInfo::ancestry($class);
205
        foreach ($classes as $tableClass) {
206
            // Skip irrelevant parent classes
207
            if (!is_subclass_of($tableClass, DataObject::class)) {
208
                continue;
209
            }
210
211
            // Find all fields on this class
212
            $fields = $this->databaseFields($tableClass, false);
213
            // Merge with composite fields
214
            if (!$dbOnly) {
215
                $compositeFields = $this->compositeFields($tableClass, false);
216
                $fields = array_merge($fields, $compositeFields);
217
            }
218
219
            // Record specification
220
            foreach ($fields as $name => $specification) {
221
                $prefix = $includeClass ? "{$tableClass}." : "";
222
                $db[$name] = $prefix . $specification;
223
            }
224
        }
225
        return $db;
226
    }
227
228
229
    /**
230
     * Get specifications for a single class field
231
     *
232
     * @param string|DataObject $classOrInstance Name or instance of class
233
     * @param string $fieldName Name of field to retrieve
234
     * @param int $options Bitmask of options
235
     *  - UNINHERITED Limit to only this table
236
     *  - DB_ONLY Exclude virtual fields (such as composite fields), and only include fields with a db column.
237
     *  - INCLUDE_CLASS Prefix the field specification with the class name in RecordClass.Column(spec) format.
238
     * @return string|null Field will be a string in FieldClass(args) format, or
239
     * RecordClass.FieldClass(args) format if using INCLUDE_CLASS. Will be null if no field is found.
240
     */
241
    public function fieldSpec($classOrInstance, $fieldName, $options = 0)
242
    {
243
        $specs = $this->fieldSpecs($classOrInstance, $options);
244
        return isset($specs[$fieldName]) ? $specs[$fieldName] : null;
245
    }
246
247
    /**
248
     * Find the class for the given table
249
     *
250
     * @param string $table
251
     * @return string|null The FQN of the class, or null if not found
252
     */
253
    public function tableClass($table)
254
    {
255
        $tables = $this->getTableNames();
256
        $class = array_search($table, $tables, true);
257
        if ($class) {
258
            return $class;
259
        }
260
261
        // If there is no class for this table, strip table modifiers (e.g. _Live / _Versions)
262
        // from the end and re-attempt a search.
263
        if (preg_match('/^(?<class>.+)(_[^_]+)$/i', $table, $matches)) {
264
            $table = $matches['class'];
265
            $class = array_search($table, $tables, true);
266
            if ($class) {
267
                return $class;
268
            }
269
        }
270
        return null;
271
    }
272
273
    /**
274
     * Cache all table names if necessary
275
     */
276
    protected function cacheTableNames()
277
    {
278
        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...
279
            return;
280
        }
281
        $this->tableNames = [];
282
        foreach (ClassInfo::subclassesFor(DataObject::class) as $class) {
283
            if ($class === DataObject::class) {
284
                continue;
285
            }
286
            $table = $this->buildTableName($class);
287
288
            // Check for conflicts
289
            $conflict = array_search($table, $this->tableNames, true);
290
            if ($conflict) {
291
                throw new LogicException(
292
                    "Multiple classes (\"{$class}\", \"{$conflict}\") map to the same table: \"{$table}\""
293
                );
294
            }
295
            $this->tableNames[$class] = $table;
296
        }
297
    }
298
299
    /**
300
     * Generate table name for a class.
301
     *
302
     * Note: some DB schema have a hard limit on table name length. This is not enforced by this method.
303
     * See dev/build errors for details in case of table name violation.
304
     *
305
     * @param string $class
306
     * @return string
307
     */
308
    protected function buildTableName($class)
309
    {
310
        $table = Config::inst()->get($class, 'table_name', Config::UNINHERITED);
311
312
        // Generate default table name
313
        if ($table) {
314
            return $table;
315
        }
316
317
        if (strpos($class, '\\') === false) {
318
            return $class;
319
        }
320
321
        $separator = DataObjectSchema::config()->uninherited('table_namespace_separator');
0 ignored issues
show
Coding Style introduced by
As per coding style, self should be used for accessing local static members.

This check looks for accesses to local static members using the fully qualified name instead of self::.

<?php

class Certificate {
    const TRIPLEDES_CBC = 'ASDFGHJKL';

    private $key;

    public function __construct()
    {
        $this->key = Certificate::TRIPLEDES_CBC;
    }
}

While this is perfectly valid, the fully qualified name of Certificate::TRIPLEDES_CBC could just as well be replaced by self::TRIPLEDES_CBC. Referencing local members with self:: assured the access will still work when the class is renamed, makes it perfectly clear that the member is in fact local and will usually be shorter.

Loading history...
322
        $table = str_replace('\\', $separator, trim($class, '\\'));
323
324
        if (!ClassInfo::classImplements($class, TestOnly::class) && $this->classHasTable($class)) {
325
            DBSchemaManager::showTableNameWarning($table, $class);
0 ignored issues
show
Deprecated Code introduced by
The function SilverStripe\ORM\Connect...:showTableNameWarning() has been deprecated: 4.0..5.0 ( Ignorable by Annotation )

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

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

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