Test Setup Failed
Push — master ( 210134...c17796 )
by Damian
03:18
created

src/ORM/DataObjectSchema.php (1 issue)

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