Completed
Push — master ( fe927f...32a670 )
by Robbie
21:23 queued 12:16
created

DataObjectSchema::parseSortColumn()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

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

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

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

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

1301
                if (in_array($type, /** @scrutinizer ignore-type */ $fieldNames)) {
Loading history...
1302
                    $table = $schema->tableForField($class, $field);
1303
                    if (!isset($mapping[$class])) {
1304
                        $mapping[$class] = [];
1305
                    }
1306
                    if (!isset($mapping[$class][$table])) {
1307
                        $mapping[$class][$table] = [];
1308
                    }
1309
                    if (!in_array($field, $mapping[$class][$table])) {
1310
                        $mapping[$class][$table][] = $field;
1311
                    }
1312
                }
1313
            }
1314
        }
1315
1316
        return $mapping;
1317
    }
1318
}
1319