Passed
Push — master ( 7dcb2c...27a2d0 )
by Daniel
11:00 queued 01:58
created

DataObjectSchema::buildSortDatabaseIndexes()   C

Complexity

Conditions 8
Paths 2

Size

Total Lines 26
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

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

629
                    if ($table && strtolower($table) !== strtolower(self::/** @scrutinizer ignore-call */ tableName($class))) {
Loading history...
630
                        continue;
631
                    }
632
                    if ($this->databaseField($class, $column, false)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->databaseField($class, $column, false) of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
633
                        $indexes[$column] = [
634
                            'type' => 'index',
635
                            'columns' => [$column],
636
                        ];
637
                    }
638
                } catch (InvalidArgumentException $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
639
                }
640
            }
641
        }
642
        return $indexes;
643
    }
644
645
    /**
646
     * Parses a specified column into a sort field and direction
647
     *
648
     * @param string $column String to parse containing the column name
649
     * @return array Resolved table and column.
650
     */
651
    protected function parseSortColumn($column)
652
    {
653
        // Parse column specification, considering possible ansi sql quoting
654
        // Note that table prefix is allowed, but discarded
655
        if (preg_match('/^("?(?<table>[^"\s]+)"?\\.)?"?(?<column>[^"\s]+)"?(\s+(?<direction>((asc)|(desc))(ending)?))?$/i', $column, $match)) {
656
            $table = $match['table'];
657
            $column = $match['column'];
658
        } else {
659
            throw new InvalidArgumentException("Invalid sort() column");
660
        }
661
        return array($table, $column);
662
    }
663
664
    /**
665
     * Returns the table name in the class hierarchy which contains a given
666
     * field column for a {@link DataObject}. If the field does not exist, this
667
     * will return null.
668
     *
669
     * @param string $candidateClass
670
     * @param string $fieldName
671
     * @return string
672
     */
673
    public function tableForField($candidateClass, $fieldName)
674
    {
675
        $class = $this->classForField($candidateClass, $fieldName);
676
        if ($class) {
677
            return $this->tableName($class);
678
        }
679
        return null;
680
    }
681
682
    /**
683
     * Returns the class 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
     * @return string
690
     */
691
    public function classForField($candidateClass, $fieldName)
692
    {
693
        // normalise class name
694
        $candidateClass = ClassInfo::class_name($candidateClass);
695
        if ($candidateClass === DataObject::class) {
696
            return null;
697
        }
698
699
        // Short circuit for fixed fields
700
        $fixed = DataObject::config()->uninherited('fixed_fields');
701
        if (isset($fixed[$fieldName])) {
702
            return $this->baseDataClass($candidateClass);
703
        }
704
705
        // Find regular field
706
        while ($candidateClass && $candidateClass !== DataObject::class) {
707
            $fields = $this->databaseFields($candidateClass, false);
708
            if (isset($fields[$fieldName])) {
709
                return $candidateClass;
710
            }
711
            $candidateClass = get_parent_class($candidateClass);
712
        }
713
        return null;
714
    }
715
716
    /**
717
     * Return information about a specific many_many component. Returns a numeric array.
718
     * The first item in the array will be the class name of the relation.
719
     *
720
     * Standard many_many return type is:
721
     *
722
     * array(
723
     *  <manyManyClass>,        Name of class for relation. E.g. "Categories"
724
     *  <classname>,            The class that relation is defined in e.g. "Product"
725
     *  <candidateName>,        The target class of the relation e.g. "Category"
726
     *  <parentField>,          The field name pointing to <classname>'s table e.g. "ProductID".
727
     *  <childField>,           The field name pointing to <candidatename>'s table e.g. "CategoryID".
728
     *  <joinTableOrRelation>   The join table between the two classes e.g. "Product_Categories".
729
     *                          If the class name is 'ManyManyThroughList' then this is the name of the
730
     *                          has_many relation.
731
     * )
732
     * @param string $class Name of class to get component for
733
     * @param string $component The component name
734
     * @return array|null
735
     */
736
    public function manyManyComponent($class, $component)
737
    {
738
        $classes = ClassInfo::ancestry($class);
739
        foreach ($classes as $parentClass) {
740
            // Check if the component is defined in many_many on this class
741
            $otherManyMany = Config::inst()->get($parentClass, 'many_many', Config::UNINHERITED);
742
            if (isset($otherManyMany[$component])) {
743
                return $this->parseManyManyComponent($parentClass, $component, $otherManyMany[$component]);
744
            }
745
746
            // Check if the component is defined in belongs_many_many on this class
747
            $belongsManyMany = Config::inst()->get($parentClass, 'belongs_many_many', Config::UNINHERITED);
748
            if (!isset($belongsManyMany[$component])) {
749
                continue;
750
            }
751
752
            // Extract class and relation name from dot-notation
753
            $belongs = $this->parseBelongsManyManyComponent(
754
                $parentClass,
755
                $component,
756
                $belongsManyMany[$component]
757
            );
758
759
            // Build inverse relationship from other many_many, and swap parent/child
760
            $otherManyMany = $this->manyManyComponent($belongs['childClass'], $belongs['relationName']);
761
            return [
762
                'relationClass' => $otherManyMany['relationClass'],
763
                'parentClass' => $otherManyMany['childClass'],
764
                'childClass' => $otherManyMany['parentClass'],
765
                'parentField' => $otherManyMany['childField'],
766
                'childField' => $otherManyMany['parentField'],
767
                'join' => $otherManyMany['join'],
768
            ];
769
        }
770
        return null;
771
    }
772
773
774
    /**
775
     * Parse a belongs_many_many component to extract class and relationship name
776
     *
777
     * @param string $parentClass Name of class
778
     * @param string $component Name of relation on class
779
     * @param string $specification specification for this belongs_many_many
780
     * @return array Array with child class and relation name
781
     */
782
    protected function parseBelongsManyManyComponent($parentClass, $component, $specification)
783
    {
784
        $childClass = $specification;
785
        $relationName = null;
786
        if (strpos($specification, '.') !== false) {
787
            list($childClass, $relationName) = explode('.', $specification, 2);
788
        }
789
790
        // Check child class exists
791
        if (!class_exists($childClass)) {
792
            throw new LogicException(
793
                "belongs_many_many relation {$parentClass}.{$component} points to "
794
                . "{$childClass} which does not exist"
795
            );
796
        }
797
798
        // We need to find the inverse component name, if not explicitly given
799
        if (!$relationName) {
800
            $relationName = $this->getManyManyInverseRelationship($childClass, $parentClass);
801
        }
802
803
        // Check valid relation found
804
        if (!$relationName) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $relationName of type null|string is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
805
            throw new LogicException(
806
                "belongs_many_many relation {$parentClass}.{$component} points to "
807
                . "{$specification} without matching many_many"
808
            );
809
        }
810
811
        // Return relatios
812
        return [
813
            'childClass' => $childClass,
814
            'relationName' => $relationName
815
        ];
816
    }
817
818
    /**
819
     * Return the many-to-many extra fields specification for a specific component.
820
     *
821
     * @param string $class
822
     * @param string $component
823
     * @return array|null
824
     */
825
    public function manyManyExtraFieldsForComponent($class, $component)
826
    {
827
        // Get directly declared many_many_extraFields
828
        $extraFields = Config::inst()->get($class, 'many_many_extraFields');
829
        if (isset($extraFields[$component])) {
830
            return $extraFields[$component];
831
        }
832
833
        // If not belongs_many_many then there are no components
834
        while ($class && ($class !== DataObject::class)) {
835
            $belongsManyMany = Config::inst()->get($class, 'belongs_many_many', Config::UNINHERITED);
836
            if (isset($belongsManyMany[$component])) {
837
                // Reverse relationship and find extrafields from child class
838
                $belongs = $this->parseBelongsManyManyComponent(
839
                    $class,
840
                    $component,
841
                    $belongsManyMany[$component]
842
                );
843
                return $this->manyManyExtraFieldsForComponent($belongs['childClass'], $belongs['relationName']);
844
            }
845
            $class = get_parent_class($class);
846
        }
847
        return null;
848
    }
849
850
    /**
851
     * Return data for a specific has_many component.
852
     *
853
     * @param string $class Parent class
854
     * @param string $component
855
     * @param bool $classOnly If this is TRUE, than any has_many relationships in the form
856
     * "ClassName.Field" will have the field data stripped off. It defaults to TRUE.
857
     * @return string|null
858
     */
859
    public function hasManyComponent($class, $component, $classOnly = true)
860
    {
861
        $hasMany = (array)Config::inst()->get($class, 'has_many');
862
        if (!isset($hasMany[$component])) {
863
            return null;
864
        }
865
866
        // Remove has_one specifier if given
867
        $hasMany = $hasMany[$component];
868
        $hasManyClass = strtok($hasMany, '.');
869
870
        // Validate
871
        $this->checkRelationClass($class, $component, $hasManyClass, 'has_many');
872
        return $classOnly ? $hasManyClass : $hasMany;
873
    }
874
875
    /**
876
     * Return data for a specific has_one component.
877
     *
878
     * @param string $class
879
     * @param string $component
880
     * @return string|null
881
     */
882
    public function hasOneComponent($class, $component)
883
    {
884
        $hasOnes = Config::forClass($class)->get('has_one');
885
        if (!isset($hasOnes[$component])) {
886
            return null;
887
        }
888
889
        // Validate
890
        $relationClass = $hasOnes[$component];
891
        $this->checkRelationClass($class, $component, $relationClass, 'has_one');
892
        return $relationClass;
893
    }
894
895
    /**
896
     * Return data for a specific belongs_to component.
897
     *
898
     * @param string $class
899
     * @param string $component
900
     * @param bool $classOnly If this is TRUE, than any has_many relationships in the
901
     * form "ClassName.Field" will have the field data stripped off. It defaults to TRUE.
902
     * @return string|null
903
     */
904
    public function belongsToComponent($class, $component, $classOnly = true)
905
    {
906
        $belongsTo = (array)Config::forClass($class)->get('belongs_to');
907
        if (!isset($belongsTo[$component])) {
908
            return null;
909
        }
910
911
        // Remove has_one specifier if given
912
        $belongsTo = $belongsTo[$component];
913
        $belongsToClass = strtok($belongsTo, '.');
914
915
        // Validate
916
        $this->checkRelationClass($class, $component, $belongsToClass, 'belongs_to');
917
        return $classOnly ? $belongsToClass : $belongsTo;
918
    }
919
920
    /**
921
     *
922
     * @param string $parentClass Parent class name
923
     * @param string $component ManyMany name
924
     * @param string|array $specification Declaration of many_many relation type
925
     * @return array
926
     */
927
    protected function parseManyManyComponent($parentClass, $component, $specification)
928
    {
929
        // Check if this is many_many_through
930
        if (is_array($specification)) {
931
            // Validate join, parent and child classes
932
            $joinClass = $this->checkManyManyJoinClass($parentClass, $component, $specification);
933
            $parentClass = $this->checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, 'from');
934
            $joinChildClass = $this->checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, 'to');
935
            return [
936
                'relationClass' => ManyManyThroughList::class,
937
                'parentClass' => $parentClass,
938
                'childClass' => $joinChildClass,
939
                'parentField' => $specification['from'] . 'ID',
940
                'childField' => $specification['to'] . 'ID',
941
                'join' => $joinClass,
942
            ];
943
        }
944
945
        // Validate $specification class is valid
946
        $this->checkRelationClass($parentClass, $component, $specification, 'many_many');
947
948
        // automatic scaffolded many_many table
949
        $classTable = $this->tableName($parentClass);
950
        $parentField = "{$classTable}ID";
951
        if ($parentClass === $specification) {
952
            $childField = "ChildID";
953
        } else {
954
            $candidateTable = $this->tableName($specification);
955
            $childField = "{$candidateTable}ID";
956
        }
957
        $joinTable = "{$classTable}_{$component}";
958
        return [
959
            'relationClass' => ManyManyList::class,
960
            'parentClass' => $parentClass,
961
            'childClass' => $specification,
962
            'parentField' => $parentField,
963
            'childField' => $childField,
964
            'join' => $joinTable,
965
        ];
966
    }
967
968
    /**
969
     * Find a many_many on the child class that points back to this many_many
970
     *
971
     * @param string $childClass
972
     * @param string $parentClass
973
     * @return string|null
974
     */
975
    protected function getManyManyInverseRelationship($childClass, $parentClass)
976
    {
977
        $otherManyMany = Config::inst()->get($childClass, 'many_many', Config::UNINHERITED);
978
        if (!$otherManyMany) {
979
            return null;
980
        }
981
        foreach ($otherManyMany as $inverseComponentName => $nextClass) {
982
            if ($nextClass === $parentClass) {
983
                return $inverseComponentName;
984
            }
985
        }
986
        return null;
987
    }
988
989
    /**
990
     * Tries to find the database key on another object that is used to store a
991
     * relationship to this class. If no join field can be found it defaults to 'ParentID'.
992
     *
993
     * If the remote field is polymorphic then $polymorphic is set to true, and the return value
994
     * is in the form 'Relation' instead of 'RelationID', referencing the composite DBField.
995
     *
996
     * @param string $class
997
     * @param string $component Name of the relation on the current object pointing to the
998
     * remote object.
999
     * @param string $type the join type - either 'has_many' or 'belongs_to'
1000
     * @param boolean $polymorphic Flag set to true if the remote join field is polymorphic.
1001
     * @return string
1002
     * @throws Exception
1003
     */
1004
    public function getRemoteJoinField($class, $component, $type = 'has_many', &$polymorphic = false)
1005
    {
1006
        // Extract relation from current object
1007
        if ($type === 'has_many') {
1008
            $remoteClass = $this->hasManyComponent($class, $component, false);
1009
        } else {
1010
            $remoteClass = $this->belongsToComponent($class, $component, false);
1011
        }
1012
1013
        if (empty($remoteClass)) {
1014
            throw new Exception("Unknown $type component '$component' on class '$class'");
1015
        }
1016
        if (!ClassInfo::exists(strtok($remoteClass, '.'))) {
1017
            throw new Exception(
1018
                "Class '$remoteClass' not found, but used in $type component '$component' on class '$class'"
1019
            );
1020
        }
1021
1022
        // If presented with an explicit field name (using dot notation) then extract field name
1023
        $remoteField = null;
1024
        if (strpos($remoteClass, '.') !== false) {
1025
            list($remoteClass, $remoteField) = explode('.', $remoteClass);
1026
        }
1027
1028
        // Reference remote has_one to check against
1029
        $remoteRelations = Config::inst()->get($remoteClass, 'has_one');
1030
1031
        // Without an explicit field name, attempt to match the first remote field
1032
        // with the same type as the current class
1033
        if (empty($remoteField)) {
0 ignored issues
show
introduced by
The condition empty($remoteField) can never be true.
Loading history...
1034
            // look for remote has_one joins on this class or any parent classes
1035
            $remoteRelationsMap = array_flip($remoteRelations);
1036
            foreach (array_reverse(ClassInfo::ancestry($class)) as $ancestryClass) {
1037
                if (array_key_exists($ancestryClass, $remoteRelationsMap)) {
1038
                    $remoteField = $remoteRelationsMap[$ancestryClass];
1039
                    break;
1040
                }
1041
            }
1042
        }
1043
1044
        // In case of an indeterminate remote field show an error
1045
        if (empty($remoteField)) {
0 ignored issues
show
introduced by
The condition empty($remoteField) can never be true.
Loading history...
1046
            $polymorphic = false;
1047
            $message = "No has_one found on class '$remoteClass'";
1048
            if ($type == 'has_many') {
1049
                // include a hint for has_many that is missing a has_one
1050
                $message .= ", the has_many relation from '$class' to '$remoteClass'";
1051
                $message .= " requires a has_one on '$remoteClass'";
1052
            }
1053
            throw new Exception($message);
1054
        }
1055
1056
        // If given an explicit field name ensure the related class specifies this
1057
        if (empty($remoteRelations[$remoteField])) {
1058
            throw new Exception("Missing expected has_one named '$remoteField'
1059
				on class '$remoteClass' referenced by $type named '$component'
1060
				on class {$class}");
1061
        }
1062
1063
        // Inspect resulting found relation
1064
        if ($remoteRelations[$remoteField] === DataObject::class) {
1065
            $polymorphic = true;
1066
            return $remoteField; // Composite polymorphic field does not include 'ID' suffix
1067
        } else {
1068
            $polymorphic = false;
1069
            return $remoteField . 'ID';
1070
        }
1071
    }
1072
1073
    /**
1074
     * Validate the to or from field on a has_many mapping class
1075
     *
1076
     * @param string $parentClass Name of parent class
1077
     * @param string $component Name of many_many component
1078
     * @param string $joinClass Class for the joined table
1079
     * @param array $specification Complete many_many specification
1080
     * @param string $key Name of key to check ('from' or 'to')
1081
     * @return string Class that matches the given relation
1082
     * @throws InvalidArgumentException
1083
     */
1084
    protected function checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, $key)
1085
    {
1086
        // Ensure value for this key exists
1087
        if (empty($specification[$key])) {
1088
            throw new InvalidArgumentException(
1089
                "many_many relation {$parentClass}.{$component} has missing {$key} which "
1090
                . "should be a has_one on class {$joinClass}"
1091
            );
1092
        }
1093
1094
        // Check that the field exists on the given object
1095
        $relation = $specification[$key];
1096
        $relationClass = $this->hasOneComponent($joinClass, $relation);
1097
        if (empty($relationClass)) {
1098
            throw new InvalidArgumentException(
1099
                "many_many through relation {$parentClass}.{$component} {$key} references a field name "
1100
                . "{$joinClass}::{$relation} which is not a has_one"
1101
            );
1102
        }
1103
1104
        // Check for polymorphic
1105
        if ($relationClass === DataObject::class) {
1106
            throw new InvalidArgumentException(
1107
                "many_many through relation {$parentClass}.{$component} {$key} references a polymorphic field "
1108
                . "{$joinClass}::{$relation} which is not supported"
1109
            );
1110
        }
1111
1112
        // Validate the join class isn't also the name of a field or relation on either side
1113
        // of the relation
1114
        $field = $this->fieldSpec($relationClass, $joinClass);
1115
        if ($field) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $field of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
1116
            throw new InvalidArgumentException(
1117
                "many_many through relation {$parentClass}.{$component} {$key} class {$relationClass} "
1118
                . " cannot have a db field of the same name of the join class {$joinClass}"
1119
            );
1120
        }
1121
1122
        // Validate bad types on parent relation
1123
        if ($key === 'from' && $relationClass !== $parentClass) {
1124
            throw new InvalidArgumentException(
1125
                "many_many through relation {$parentClass}.{$component} {$key} references a field name "
1126
                . "{$joinClass}::{$relation} of type {$relationClass}; {$parentClass} expected"
1127
            );
1128
        }
1129
        return $relationClass;
1130
    }
1131
1132
    /**
1133
     * @param string $parentClass Name of parent class
1134
     * @param string $component Name of many_many component
1135
     * @param array $specification Complete many_many specification
1136
     * @return string Name of join class
1137
     */
1138
    protected function checkManyManyJoinClass($parentClass, $component, $specification)
1139
    {
1140
        if (empty($specification['through'])) {
1141
            throw new InvalidArgumentException(
1142
                "many_many relation {$parentClass}.{$component} has missing through which should be "
1143
                . "a DataObject class name to be used as a join table"
1144
            );
1145
        }
1146
        $joinClass = $specification['through'];
1147
        if (!class_exists($joinClass)) {
1148
            throw new InvalidArgumentException(
1149
                "many_many relation {$parentClass}.{$component} has through class \"{$joinClass}\" which does not exist"
1150
            );
1151
        }
1152
        return $joinClass;
1153
    }
1154
1155
    /**
1156
     * Validate a given class is valid for a relation
1157
     *
1158
     * @param string $class Parent class
1159
     * @param string $component Component name
1160
     * @param string $relationClass Candidate class to check
1161
     * @param string $type Relation type (e.g. has_one)
1162
     */
1163
    protected function checkRelationClass($class, $component, $relationClass, $type)
1164
    {
1165
        if (!is_string($component) || is_numeric($component)) {
1166
            throw new InvalidArgumentException(
1167
                "{$class} has invalid {$type} relation name"
1168
            );
1169
        }
1170
        if (!is_string($relationClass)) {
1171
            throw new InvalidArgumentException(
1172
                "{$type} relation {$class}.{$component} is not a class name"
1173
            );
1174
        }
1175
        if (!class_exists($relationClass)) {
1176
            throw new InvalidArgumentException(
1177
                "{$type} relation {$class}.{$component} references class {$relationClass} which doesn't exist"
1178
            );
1179
        }
1180
        // Support polymorphic has_one
1181
        if ($type === 'has_one') {
1182
            $valid = is_a($relationClass, DataObject::class, true);
1183
        } else {
1184
            $valid = is_subclass_of($relationClass, DataObject::class, true);
1185
        }
1186
        if (!$valid) {
1187
            throw new InvalidArgumentException(
1188
                "{$type} relation {$class}.{$component} references class {$relationClass} "
1189
                . " which is not a subclass of " . DataObject::class
1190
            );
1191
        }
1192
    }
1193
}
1194