Passed
Push — 4.4 ( 692295...8ee50d )
by Ingo
07:38
created

DataObjectSchema::getFieldMap()   B

Complexity

Conditions 7
Paths 11

Size

Total Lines 26
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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