Passed
Pull Request — 4 (#10382)
by Guy
07:28
created

DataObject::primarySearchFieldName()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 3
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\ORM;
4
5
use BadMethodCallException;
6
use Exception;
7
use InvalidArgumentException;
8
use LogicException;
9
use SilverStripe\Core\ClassInfo;
10
use SilverStripe\Core\Config\Config;
11
use SilverStripe\Core\Injector\Injector;
12
use SilverStripe\Core\Resettable;
13
use SilverStripe\Dev\Debug;
14
use SilverStripe\Dev\Deprecation;
15
use SilverStripe\Forms\FieldList;
16
use SilverStripe\Forms\FormField;
17
use SilverStripe\Forms\FormScaffolder;
18
use SilverStripe\Forms\CompositeValidator;
19
use SilverStripe\Forms\HiddenField;
20
use SilverStripe\i18n\i18n;
21
use SilverStripe\i18n\i18nEntityProvider;
22
use SilverStripe\ORM\Connect\MySQLSchemaManager;
23
use SilverStripe\ORM\FieldType\DBComposite;
24
use SilverStripe\ORM\FieldType\DBDatetime;
25
use SilverStripe\ORM\FieldType\DBEnum;
26
use SilverStripe\ORM\FieldType\DBField;
27
use SilverStripe\ORM\Filters\SearchFilter;
28
use SilverStripe\ORM\Queries\SQLDelete;
29
use SilverStripe\ORM\Search\SearchContext;
30
use SilverStripe\ORM\RelatedData\RelatedDataService;
31
use SilverStripe\ORM\UniqueKey\UniqueKeyInterface;
32
use SilverStripe\ORM\UniqueKey\UniqueKeyService;
33
use SilverStripe\Security\Member;
34
use SilverStripe\Security\Permission;
35
use SilverStripe\Security\Security;
36
use SilverStripe\View\SSViewer;
37
use SilverStripe\View\ViewableData;
38
use stdClass;
39
40
/**
41
 * A single database record & abstract class for the data-access-model.
42
 *
43
 * <h2>Extensions</h2>
44
 *
45
 * See {@link Extension} and {@link DataExtension}.
46
 *
47
 * <h2>Permission Control</h2>
48
 *
49
 * Object-level access control by {@link Permission}. Permission codes are arbitrary
50
 * strings which can be selected on a group-by-group basis.
51
 *
52
 * <code>
53
 * class Article extends DataObject implements PermissionProvider {
54
 *  static $api_access = true;
55
 *
56
 *  function canView($member = false) {
57
 *    return Permission::check('ARTICLE_VIEW');
58
 *  }
59
 *  function canEdit($member = false) {
60
 *    return Permission::check('ARTICLE_EDIT');
61
 *  }
62
 *  function canDelete() {
63
 *    return Permission::check('ARTICLE_DELETE');
64
 *  }
65
 *  function canCreate() {
66
 *    return Permission::check('ARTICLE_CREATE');
67
 *  }
68
 *  function providePermissions() {
69
 *    return array(
70
 *      'ARTICLE_VIEW' => 'Read an article object',
71
 *      'ARTICLE_EDIT' => 'Edit an article object',
72
 *      'ARTICLE_DELETE' => 'Delete an article object',
73
 *      'ARTICLE_CREATE' => 'Create an article object',
74
 *    );
75
 *  }
76
 * }
77
 * </code>
78
 *
79
 * Object-level access control by {@link Group} membership:
80
 * <code>
81
 * class Article extends DataObject {
82
 *   static $api_access = true;
83
 *
84
 *   function canView($member = false) {
85
 *     if (!$member) $member = Security::getCurrentUser();
86
 *     return $member->inGroup('Subscribers');
87
 *   }
88
 *   function canEdit($member = false) {
89
 *     if (!$member) $member = Security::getCurrentUser();
90
 *     return $member->inGroup('Editors');
91
 *   }
92
 *
93
 *   // ...
94
 * }
95
 * </code>
96
 *
97
 * If any public method on this class is prefixed with an underscore,
98
 * the results are cached in memory through {@link cachedCall()}.
99
 *
100
 *
101
 * @todo Add instance specific removeExtension() which undos loadExtraStatics()
102
 *  and defineMethods()
103
 *
104
 * @property int $ID ID of the DataObject, 0 if the DataObject doesn't exist in database.
105
 * @property int $OldID ID of object, if deleted
106
 * @property string $Title
107
 * @property string $ClassName Class name of the DataObject
108
 * @property string $LastEdited Date and time of DataObject's last modification.
109
 * @property string $Created Date and time of DataObject creation.
110
 * @property string $ObsoleteClassName If ClassName no longer exists this will be set to the legacy value
111
 */
112
class DataObject extends ViewableData implements DataObjectInterface, i18nEntityProvider, Resettable
113
{
114
115
    /**
116
     * Human-readable singular name.
117
     * @var string
118
     * @config
119
     */
120
    private static $singular_name = null;
121
122
    /**
123
     * Human-readable plural name
124
     * @var string
125
     * @config
126
     */
127
    private static $plural_name = null;
128
129
    /**
130
     * Allow API access to this object?
131
     * @todo Define the options that can be set here
132
     * @config
133
     */
134
    private static $api_access = false;
135
136
    /**
137
     * Allows specification of a default value for the ClassName field.
138
     * Configure this value only in subclasses of DataObject.
139
     *
140
     * @config
141
     * @var string
142
     */
143
    private static $default_classname = null;
144
145
    /**
146
     * @deprecated 4.0.0:5.0.0
147
     * @var bool
148
     */
149
    public $destroyed = false;
150
151
    /**
152
     * Data stored in this objects database record. An array indexed by fieldname.
153
     *
154
     * Use {@link toMap()} if you want an array representation
155
     * of this object, as the $record array might contain lazy loaded field aliases.
156
     *
157
     * @var array
158
     */
159
    protected $record;
160
161
    /**
162
     * If selected through a many_many through relation, this is the instance of the through record
163
     *
164
     * @var DataObject
165
     */
166
    protected $joinRecord;
167
168
    /**
169
     * Represents a field that hasn't changed (before === after, thus before == after)
170
     */
171
    const CHANGE_NONE = 0;
172
173
    /**
174
     * Represents a field that has changed type, although not the loosely defined value.
175
     * (before !== after && before == after)
176
     * E.g. change 1 to true or "true" to true, but not true to 0.
177
     * Value changes are by nature also considered strict changes.
178
     */
179
    const CHANGE_STRICT = 1;
180
181
    /**
182
     * Represents a field that has changed the loosely defined value
183
     * (before != after, thus, before !== after))
184
     * E.g. change false to true, but not false to 0
185
     */
186
    const CHANGE_VALUE = 2;
187
188
    /**
189
     * Value for 2nd argument to constructor, indicating that a new record is being created
190
     * Setters will be called on fields passed, and defaults will be populated
191
     */
192
    const CREATE_OBJECT = 0;
193
194
    /**
195
     * Value for 2nd argument to constructor, indicating that a record is a singleton representing the whole type,
196
     * e.g. to call requireTable() in dev/build
197
     * Defaults will not be populated and data passed will be ignored
198
     */
199
    const CREATE_SINGLETON = 1;
200
201
    /**
202
     * Value for 2nd argument to constructor, indicating that a record is being hydrated from the database
203
     * Setter methods are not called, and population via private static $defaults will not occur.
204
     */
205
    const CREATE_HYDRATED = 2;
206
207
    /**
208
     * Value for 2nd argument to constructor, indicating that a record is being hydrated from memory. This can be used
209
     * to initialised a record that doesn't yet have an ID. Setter methods are not called, and population via private
210
     * static $defaults will not occur.
211
     */
212
    const CREATE_MEMORY_HYDRATED = 3;
213
214
    /**
215
     * An array indexed by fieldname, true if the field has been changed.
216
     * Use {@link getChangedFields()} and {@link isChanged()} to inspect
217
     * the changed state.
218
     *
219
     * @var array
220
     */
221
    private $changed = [];
222
223
    /**
224
     * A flag to indicate that a "strict" change of the entire record been forced
225
     * Use {@link getChangedFields()} and {@link isChanged()} to inspect
226
     * the changed state.
227
     *
228
     * @var boolean
229
     */
230
    private $changeForced = false;
231
232
    /**
233
     * The database record (in the same format as $record), before
234
     * any changes.
235
     * @var array
236
     */
237
    protected $original = [];
238
239
    /**
240
     * Used by onBeforeDelete() to ensure child classes call parent::onBeforeDelete()
241
     * @var boolean
242
     */
243
    protected $brokenOnDelete = false;
244
245
    /**
246
     * Used by onBeforeWrite() to ensure child classes call parent::onBeforeWrite()
247
     * @var boolean
248
     */
249
    protected $brokenOnWrite = false;
250
251
    /**
252
     * Should dataobjects be validated before they are written?
253
     *
254
     * Caution: Validation can contain safeguards against invalid/malicious data,
255
     * and check permission levels (e.g. on {@link Group}). Therefore it is recommended
256
     * to only disable validation for very specific use cases.
257
     *
258
     * @config
259
     * @var boolean
260
     */
261
    private static $validation_enabled = true;
262
263
    /**
264
     * Static caches used by relevant functions.
265
     *
266
     * @var array
267
     */
268
    protected static $_cache_get_one;
269
270
    /**
271
     * Cache of field labels
272
     *
273
     * @var array
274
     */
275
    protected static $_cache_field_labels = [];
276
277
    /**
278
     * Base fields which are not defined in static $db
279
     *
280
     * @config
281
     * @var array
282
     */
283
    private static $fixed_fields = [
284
        'ID' => 'PrimaryKey',
285
        'ClassName' => 'DBClassName',
286
        'LastEdited' => 'DBDatetime',
287
        'Created' => 'DBDatetime',
288
    ];
289
290
    /**
291
     * Override table name for this class. If ignored will default to FQN of class.
292
     * This option is not inheritable, and must be set on each class.
293
     * If left blank naming will default to the legacy (3.x) behaviour.
294
     *
295
     * @var string
296
     */
297
    private static $table_name = null;
298
299
    /**
300
     * Non-static relationship cache, indexed by component name.
301
     *
302
     * @var DataObject[]
303
     */
304
    protected $components = [];
305
306
    /**
307
     * Non-static cache of has_many and many_many relations that can't be written until this object is saved.
308
     *
309
     * @var UnsavedRelationList[]
310
     */
311
    protected $unsavedRelations;
312
313
    /**
314
     * List of relations that should be cascade deleted, similar to `owns`
315
     * Note: This will trigger delete on many_many objects, not only the mapping table.
316
     * For many_many through you can specify the components you want to delete separately
317
     * (many_many or has_many sub-component)
318
     *
319
     * @config
320
     * @var array
321
     */
322
    private static $cascade_deletes = [];
323
324
    /**
325
     * List of relations that should be cascade duplicate.
326
     * many_many duplications are shallow only.
327
     *
328
     * Note: If duplicating a many_many through you should refer to the
329
     * has_many intermediary relation instead, otherwise extra fields
330
     * will be omitted from the duplicated relation.
331
     *
332
     * @var array
333
     */
334
    private static $cascade_duplicates = [];
335
336
    /**
337
     * Get schema object
338
     *
339
     * @return DataObjectSchema
340
     */
341
    public static function getSchema()
342
    {
343
        return Injector::inst()->get(DataObjectSchema::class);
344
    }
345
346
    /**
347
     * Construct a new DataObject.
348
     *
349
     * @param array $record Initial record content, or rehydrated record content, depending on $creationType
350
     * @param int|boolean $creationType Set to DataObject::CREATE_OBJECT, DataObject::CREATE_HYDRATED,
351
     *   DataObject::CREATE_MEMORY_HYDRATED or DataObject::CREATE_SINGLETON. Used by Silverstripe internals and best
352
     *   left as the default by regular users.
353
     * @param array $queryParams List of DataQuery params necessary to lazy load, or load related objects.
354
     */
355
    public function __construct($record = [], $creationType = self::CREATE_OBJECT, $queryParams = [])
356
    {
357
        parent::__construct();
358
359
        // Legacy $record default
360
        if ($record === null) {
0 ignored issues
show
introduced by
The condition $record === null is always false.
Loading history...
361
            $record = [];
362
        }
363
364
        // Legacy $isSingleton boolean
365
        if (!is_int($creationType)) {
366
            if (!is_bool($creationType)) {
0 ignored issues
show
introduced by
The condition is_bool($creationType) is always true.
Loading history...
367
                user_error('Creation type is neither boolean (old isSingleton arg) nor integer (new arg), please review your code', E_USER_WARNING);
368
            }
369
            $creationType = $creationType ? self::CREATE_SINGLETON : self::CREATE_OBJECT;
370
        }
371
372
        // Set query params on the DataObject to tell the lazy loading mechanism the context the object creation context
373
        $this->setSourceQueryParams($queryParams);
374
375
        // Set $this->record to $record, but ignore NULLs
376
        $this->record = [];
377
378
        switch ($creationType) {
379
            // Hydrate a record
380
            case self::CREATE_HYDRATED:
381
            case self::CREATE_MEMORY_HYDRATED:
382
                $this->hydrate($record, $creationType === self::CREATE_HYDRATED);
383
                break;
384
385
            // Create a new object, using the constructor argument as the initial content
386
            case self::CREATE_OBJECT:
387
                if ($record instanceof stdClass) {
0 ignored issues
show
introduced by
$record is never a sub-type of stdClass.
Loading history...
388
                    $record = (array)$record;
389
                }
390
391
                if (!is_array($record)) {
0 ignored issues
show
introduced by
The condition is_array($record) is always true.
Loading history...
392
                    if (is_object($record)) {
393
                        $passed = "an object of type '" . get_class($record) . "'";
394
                    } else {
395
                        $passed = "The value '$record'";
396
                    }
397
398
                    user_error(
399
                        "DataObject::__construct passed $passed.  It's supposed to be passed an array,"
400
                        . " taken straight from the database.  Perhaps you should use DataList::create()->First(); instead?",
401
                        E_USER_WARNING
402
                    );
403
                    $record = [];
404
                }
405
406
                // Default columns
407
                $this->record['ID'] = empty($record['ID']) ? 0 : $record['ID'];
408
                $this->record['ClassName'] = static::class;
409
                $this->record['RecordClassName'] = static::class;
410
                unset($record['ID']);
411
                $this->original = $this->record;
412
413
                $this->populateDefaults();
414
415
                // prevent populateDefaults() and setField() from marking overwritten defaults as changed
416
                $this->changed = [];
417
                $this->changeForced = false;
418
419
                // Set the data passed in the constructor, allowing for defaults and calling setters
420
                // This will mark fields as changed
421
                if ($record) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $record 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...
422
                    $this->update($record);
423
                }
424
                break;
425
426
            case self::CREATE_SINGLETON:
427
                // No setting happens for a singleton
428
                $this->record['ID'] = 0;
429
                $this->record['ClassName'] = static::class;
430
                $this->record['RecordClassName'] = static::class;
431
                $this->original = $this->record;
432
                $this->changed = [];
433
                $this->changeForced = false;
434
                break;
435
436
            default:
437
                throw new \LogicException('Bad creationType ' . $this->creationType);
0 ignored issues
show
Bug Best Practice introduced by
The property creationType does not exist on SilverStripe\ORM\DataObject. Since you implemented __get, consider adding a @property annotation.
Loading history...
438
        }
439
    }
440
441
    /**
442
     * Constructor hydration logic for CREATE_HYDRATED and CREATE_MEMORY_HYDRATED.
443
     * @param array $record
444
     * @param bool $mustHaveID If true, an exception will be thrown if $record doesn't have an ID.
445
     */
446
    private function hydrate(array $record, bool $mustHaveID)
447
    {
448
        if ($mustHaveID && empty($record['ID'])) {
449
            // CREATE_HYDRATED requires an ID to be included in the record
450
            throw new \InvalidArgumentException(
451
                "Hydrated records must be passed a record array including an ID."
452
            );
453
        } elseif (empty($record['ID'])) {
454
            // CREATE_MEMORY_HYDRATED implicitly set the record ID to 0 if not provided
455
            $record['ID'] = 0;
456
        }
457
458
        $this->record = $record;
459
460
        // Identify fields that should be lazy loaded, but only on existing records
461
        // Get all field specs scoped to class for later lazy loading
462
        $fields = static::getSchema()->fieldSpecs(
463
            static::class,
464
            DataObjectSchema::INCLUDE_CLASS | DataObjectSchema::DB_ONLY
465
        );
466
467
        foreach ($fields as $field => $fieldSpec) {
468
            $fieldClass = strtok($fieldSpec ?? '', ".");
469
            if (!array_key_exists($field, $record ?? [])) {
470
                $this->record[$field . '_Lazy'] = $fieldClass;
471
            }
472
        }
473
474
        // Extension point to hydrate additional fields into this object during construction.
475
        // Return an array of field names => raw values from your augmentHydrateFields extension method.
476
        $extendedAdditionalFields = $this->extend('augmentHydrateFields');
477
        foreach ($extendedAdditionalFields as $additionalFields) {
478
            foreach ($additionalFields as $field => $value) {
479
                $this->record[$field] = $value;
480
481
                // If a corresponding lazy-load field exists, remove it as the value has been provided
482
                $lazyName = $field . '_Lazy';
483
                if (array_key_exists($lazyName, $this->record ?? [])) {
484
                    unset($this->record[$lazyName]);
485
                }
486
            }
487
        }
488
489
        $this->original = $this->record;
490
        $this->changed = [];
491
        $this->changeForced = false;
492
    }
493
494
    /**
495
     * Destroy all of this objects dependent objects and local caches.
496
     * You'll need to call this to get the memory of an object that has components or extensions freed.
497
     */
498
    public function destroy()
499
    {
500
        $this->flushCache(false);
501
    }
502
503
    /**
504
     * Create a duplicate of this node. Can duplicate many_many relations
505
     *
506
     * @param bool $doWrite Perform a write() operation before returning the object.
507
     * If this is true, it will create the duplicate in the database.
508
     * @param array|null|false $relations List of relations to duplicate.
509
     * Will default to `cascade_duplicates` if null.
510
     * Set to 'false' to force none.
511
     * Set to specific array of names to duplicate to override these.
512
     * Note: If using versioned, this will additionally failover to `owns` config.
513
     * @return static A duplicate of this node. The exact type will be the type of this node.
514
     */
515
    public function duplicate($doWrite = true, $relations = null)
516
    {
517
        // Handle legacy behaviour
518
        if (is_string($relations) || $relations === true) {
0 ignored issues
show
introduced by
The condition $relations === true is always false.
Loading history...
519
            if ($relations === true) {
520
                $relations = 'many_many';
521
            }
522
            Deprecation::notice('5.0', 'Use cascade_duplicates config instead of providing a string to duplicate()');
523
            $relations = array_keys($this->config()->get($relations) ?? []) ?: [];
524
        }
525
526
        // Get duplicates
527
        if ($relations === null) {
528
            $relations = $this->config()->get('cascade_duplicates');
529
            // Remove any duplicate entries before duplicating them
530
            if (is_array($relations)) {
531
                $relations = array_unique($relations ?? []);
532
            }
533
        }
534
535
        // Create unsaved raw duplicate
536
        $map = $this->toMap();
537
        unset($map['Created']);
538
        /** @var static $clone */
539
        $clone = Injector::inst()->create(static::class, $map, false, $this->getSourceQueryParams());
540
        $clone->ID = 0;
541
542
        // Note: Extensions such as versioned may update $relations here
543
        $clone->invokeWithExtensions('onBeforeDuplicate', $this, $doWrite, $relations);
544
        if ($relations) {
545
            $this->duplicateRelations($this, $clone, $relations);
546
        }
547
        if ($doWrite) {
548
            $clone->write();
549
        }
550
        $clone->invokeWithExtensions('onAfterDuplicate', $this, $doWrite, $relations);
551
552
        return $clone;
553
    }
554
555
    /**
556
     * Copies the given relations from this object to the destination
557
     *
558
     * @param DataObject $sourceObject the source object to duplicate from
559
     * @param DataObject $destinationObject the destination object to populate with the duplicated relations
560
     * @param array $relations List of relations
561
     */
562
    protected function duplicateRelations($sourceObject, $destinationObject, $relations)
563
    {
564
        // Get list of duplicable relation types
565
        $manyMany = $sourceObject->manyMany();
566
        $hasMany = $sourceObject->hasMany();
567
        $hasOne = $sourceObject->hasOne();
568
        $belongsTo = $sourceObject->belongsTo();
569
570
        // Duplicate each relation based on type
571
        foreach ($relations as $relation) {
572
            switch (true) {
573
                case array_key_exists($relation, $manyMany): {
574
                    $this->duplicateManyManyRelation($sourceObject, $destinationObject, $relation);
575
                    break;
576
                }
577
                case array_key_exists($relation, $hasMany): {
0 ignored issues
show
Bug introduced by
It seems like $hasMany can also be of type false and string; however, parameter $array of array_key_exists() does only seem to accept ArrayObject|array, maybe add an additional type check? ( Ignorable by Annotation )

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

577
                case array_key_exists($relation, /** @scrutinizer ignore-type */ $hasMany): {
Loading history...
578
                    $this->duplicateHasManyRelation($sourceObject, $destinationObject, $relation);
579
                    break;
580
                }
581
                case array_key_exists($relation, $hasOne): {
582
                    $this->duplicateHasOneRelation($sourceObject, $destinationObject, $relation);
583
                    break;
584
                }
585
                case array_key_exists($relation, $belongsTo): {
0 ignored issues
show
Bug introduced by
It seems like $belongsTo can also be of type string; however, parameter $array of array_key_exists() does only seem to accept ArrayObject|array, maybe add an additional type check? ( Ignorable by Annotation )

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

585
                case array_key_exists($relation, /** @scrutinizer ignore-type */ $belongsTo): {
Loading history...
586
                    $this->duplicateBelongsToRelation($sourceObject, $destinationObject, $relation);
587
                    break;
588
                }
589
                default: {
590
                    $sourceType = get_class($sourceObject);
591
                    throw new InvalidArgumentException(
592
                        "Cannot duplicate unknown relation {$relation} on parent type {$sourceType}"
593
                    );
594
                }
595
            }
596
        }
597
    }
598
599
    /**
600
     * Copies the many_many and belongs_many_many relations from one object to another instance of the name of object.
601
     *
602
     * @deprecated 4.1.0:5.0.0 Use duplicateRelations() instead
603
     * @param DataObject $sourceObject the source object to duplicate from
604
     * @param DataObject $destinationObject the destination object to populate with the duplicated relations
605
     * @param bool|string $filter
606
     */
607
    protected function duplicateManyManyRelations($sourceObject, $destinationObject, $filter)
608
    {
609
        Deprecation::notice('5.0', 'Use duplicateRelations() instead');
610
611
        // Get list of relations to duplicate
612
        if ($filter === 'many_many' || $filter === 'belongs_many_many') {
613
            $relations = $sourceObject->config()->get($filter);
614
        } elseif ($filter === true) {
615
            $relations = $sourceObject->manyMany();
616
        } else {
617
            throw new InvalidArgumentException("Invalid many_many duplication filter");
618
        }
619
        foreach ($relations as $manyManyName => $type) {
620
            $this->duplicateManyManyRelation($sourceObject, $destinationObject, $manyManyName);
621
        }
622
    }
623
624
    /**
625
     * Duplicates a single many_many relation from one object to another.
626
     *
627
     * @param DataObject $sourceObject
628
     * @param DataObject $destinationObject
629
     * @param string $relation
630
     */
631
    protected function duplicateManyManyRelation($sourceObject, $destinationObject, $relation)
632
    {
633
        // Copy all components from source to destination
634
        $source = $sourceObject->getManyManyComponents($relation);
635
        $dest = $destinationObject->getManyManyComponents($relation);
636
637
        if ($source instanceof ManyManyList) {
638
            $extraFieldNames = $source->getExtraFields();
639
        } else {
640
            $extraFieldNames = [];
641
        }
642
643
        foreach ($source as $item) {
644
            // Merge extra fields
645
            $extraFields = [];
646
            foreach ($extraFieldNames as $fieldName => $fieldType) {
647
                $extraFields[$fieldName] = $item->getField($fieldName);
648
            }
649
            $dest->add($item, $extraFields);
650
        }
651
    }
652
653
    /**
654
     * Duplicates a single many_many relation from one object to another.
655
     *
656
     * @param DataObject $sourceObject
657
     * @param DataObject $destinationObject
658
     * @param string $relation
659
     */
660
    protected function duplicateHasManyRelation($sourceObject, $destinationObject, $relation)
661
    {
662
        // Copy all components from source to destination
663
        $source = $sourceObject->getComponents($relation);
664
        $dest = $destinationObject->getComponents($relation);
665
666
        /** @var DataObject $item */
667
        foreach ($source as $item) {
668
            // Don't write on duplicate; Wait until ParentID is available later.
669
            // writeRelations() will eventually write these records when converting
670
            // from UnsavedRelationList
671
            $clonedItem = $item->duplicate(false);
672
            $dest->add($clonedItem);
673
        }
674
    }
675
676
    /**
677
     * Duplicates a single has_one relation from one object to another.
678
     * Note: Child object will be force written.
679
     *
680
     * @param DataObject $sourceObject
681
     * @param DataObject $destinationObject
682
     * @param string $relation
683
     */
684
    protected function duplicateHasOneRelation($sourceObject, $destinationObject, $relation)
685
    {
686
        // Check if original object exists
687
        $item = $sourceObject->getComponent($relation);
688
        if (!$item->isInDB()) {
689
            return;
690
        }
691
692
        $clonedItem = $item->duplicate(false);
693
        $destinationObject->setComponent($relation, $clonedItem);
694
    }
695
696
    /**
697
     * Duplicates a single belongs_to relation from one object to another.
698
     * Note: This will force a write on both parent / child objects.
699
     *
700
     * @param DataObject $sourceObject
701
     * @param DataObject $destinationObject
702
     * @param string $relation
703
     */
704
    protected function duplicateBelongsToRelation($sourceObject, $destinationObject, $relation)
705
    {
706
        // Check if original object exists
707
        $item = $sourceObject->getComponent($relation);
708
        if (!$item->isInDB()) {
709
            return;
710
        }
711
712
        $clonedItem = $item->duplicate(false);
713
        $destinationObject->setComponent($relation, $clonedItem);
714
        // After $clonedItem is assigned the appropriate FieldID / FieldClass, force write
715
        // @todo Write this component in onAfterWrite instead, assigning the FieldID then
716
        // https://github.com/silverstripe/silverstripe-framework/issues/7818
717
        $clonedItem->write();
718
    }
719
720
    /**
721
     * Return obsolete class name, if this is no longer a valid class
722
     *
723
     * @return string
724
     */
725
    public function getObsoleteClassName()
726
    {
727
        $className = $this->getField("ClassName");
728
        if (!ClassInfo::exists($className)) {
729
            return $className;
730
        }
731
        return null;
732
    }
733
734
    /**
735
     * Gets name of this class
736
     *
737
     * @return string
738
     */
739
    public function getClassName()
740
    {
741
        $className = $this->getField("ClassName");
742
        if (!ClassInfo::exists($className)) {
743
            return static::class;
744
        }
745
        return $className;
746
    }
747
748
    /**
749
     * Set the ClassName attribute. {@link $class} is also updated.
750
     * Warning: This will produce an inconsistent record, as the object
751
     * instance will not automatically switch to the new subclass.
752
     * Please use {@link newClassInstance()} for this purpose,
753
     * or destroy and reinstanciate the record.
754
     *
755
     * @param string $className The new ClassName attribute (a subclass of {@link DataObject})
756
     * @return $this
757
     */
758
    public function setClassName($className)
759
    {
760
        $className = trim($className ?? '');
761
        if (!$className || !is_subclass_of($className, self::class)) {
762
            return $this;
763
        }
764
765
        $this->setField("ClassName", $className);
766
        $this->setField('RecordClassName', $className);
767
        return $this;
768
    }
769
770
    /**
771
     * Create a new instance of a different class from this object's record.
772
     * This is useful when dynamically changing the type of an instance. Specifically,
773
     * it ensures that the instance of the class is a match for the className of the
774
     * record. Don't set the {@link DataObject->class} or {@link DataObject->ClassName}
775
     * property manually before calling this method, as it will confuse change detection.
776
     *
777
     * If the new class is different to the original class, defaults are populated again
778
     * because this will only occur automatically on instantiation of a DataObject if
779
     * there is no record, or the record has no ID. In this case, we do have an ID but
780
     * we still need to repopulate the defaults.
781
     *
782
     * @param string $newClassName The name of the new class
783
     *
784
     * @return DataObject The new instance of the new class, The exact type will be of the class name provided.
785
     */
786
    public function newClassInstance($newClassName)
787
    {
788
        if (!is_subclass_of($newClassName, self::class)) {
789
            throw new InvalidArgumentException("$newClassName is not a valid subclass of DataObject");
790
        }
791
792
        $originalClass = $this->ClassName;
793
794
        /** @var DataObject $newInstance */
795
        $newInstance = Injector::inst()->create($newClassName, $this->record, self::CREATE_MEMORY_HYDRATED);
796
797
        // Modify ClassName
798
        if ($newClassName != $originalClass) {
799
            $newInstance->setClassName($newClassName);
800
            $newInstance->populateDefaults();
801
            $newInstance->forceChange();
802
        }
803
804
        return $newInstance;
805
    }
806
807
    /**
808
     * Adds methods from the extensions.
809
     * Called by Object::__construct() once per class.
810
     */
811
    public function defineMethods()
812
    {
813
        parent::defineMethods();
814
815
        if (static::class === self::class) {
0 ignored issues
show
introduced by
The condition static::class === self::class is always true.
Loading history...
816
            return;
817
        }
818
819
        // Set up accessors for joined items
820
        if ($manyMany = $this->manyMany()) {
821
            foreach ($manyMany as $relationship => $class) {
822
                $this->addWrapperMethod($relationship, 'getManyManyComponents');
823
            }
824
        }
825
        if ($hasMany = $this->hasMany()) {
826
            foreach ($hasMany as $relationship => $class) {
827
                $this->addWrapperMethod($relationship, 'getComponents');
828
            }
829
        }
830
        if ($hasOne = $this->hasOne()) {
831
            foreach ($hasOne as $relationship => $class) {
832
                $this->addWrapperMethod($relationship, 'getComponent');
833
            }
834
        }
835
        if ($belongsTo = $this->belongsTo()) {
836
            foreach (array_keys($belongsTo ?? []) as $relationship) {
837
                $this->addWrapperMethod($relationship, 'getComponent');
838
            }
839
        }
840
    }
841
842
    /**
843
     * Returns true if this object "exists", i.e., has a sensible value.
844
     * The default behaviour for a DataObject is to return true if
845
     * the object exists in the database, you can override this in subclasses.
846
     *
847
     * @return boolean true if this object exists
848
     */
849
    public function exists()
850
    {
851
        return (isset($this->record['ID']) && $this->record['ID'] > 0);
852
    }
853
854
    /**
855
     * Returns TRUE if all values (other than "ID") are
856
     * considered empty (by weak boolean comparison).
857
     *
858
     * @return boolean
859
     */
860
    public function isEmpty()
861
    {
862
        $fixed = DataObject::config()->uninherited('fixed_fields');
863
        foreach ($this->toMap() as $field => $value) {
864
            // only look at custom fields
865
            if (isset($fixed[$field])) {
866
                continue;
867
            }
868
869
            $dbObject = $this->dbObject($field);
870
            if (!$dbObject) {
871
                continue;
872
            }
873
            if ($dbObject->exists()) {
874
                return false;
875
            }
876
        }
877
        return true;
878
    }
879
880
    /**
881
     * Pluralise this item given a specific count.
882
     *
883
     * E.g. "0 Pages", "1 File", "3 Images"
884
     *
885
     * @param string $count
886
     * @return string
887
     */
888
    public function i18n_pluralise($count)
889
    {
890
        $default = 'one ' . $this->i18n_singular_name() . '|{count} ' . $this->i18n_plural_name();
891
        return i18n::_t(
892
            static::class . '.PLURALS',
893
            $default,
894
            ['count' => $count]
895
        );
896
    }
897
898
    /**
899
     * Get the user friendly singular name of this DataObject.
900
     * If the name is not defined (by redefining $singular_name in the subclass),
901
     * this returns the class name.
902
     *
903
     * @return string User friendly singular name of this DataObject
904
     */
905
    public function singular_name()
906
    {
907
        $name = $this->config()->get('singular_name');
908
        if ($name) {
909
            return $name;
910
        }
911
        return ucwords(trim(strtolower(preg_replace(
912
            '/_?([A-Z])/',
913
            ' $1',
914
            ClassInfo::shortName($this) ?? ''
915
        ) ?? '')));
916
    }
917
918
    /**
919
     * Get the translated user friendly singular name of this DataObject
920
     * same as singular_name() but runs it through the translating function
921
     *
922
     * Translating string is in the form:
923
     *     $this->class.SINGULARNAME
924
     * Example:
925
     *     Page.SINGULARNAME
926
     *
927
     * @return string User friendly translated singular name of this DataObject
928
     */
929
    public function i18n_singular_name()
930
    {
931
        return _t(static::class . '.SINGULARNAME', $this->singular_name());
932
    }
933
934
    /**
935
     * Get the user friendly plural name of this DataObject
936
     * If the name is not defined (by renaming $plural_name in the subclass),
937
     * this returns a pluralised version of the class name.
938
     *
939
     * @return string User friendly plural name of this DataObject
940
     */
941
    public function plural_name()
942
    {
943
        if ($name = $this->config()->get('plural_name')) {
944
            return $name;
945
        }
946
        $name = $this->singular_name();
947
        //if the penultimate character is not a vowel, replace "y" with "ies"
948
        if (preg_match('/[^aeiou]y$/i', $name ?? '')) {
949
            $name = substr($name ?? '', 0, -1) . 'ie';
950
        }
951
        return ucfirst($name . 's');
952
    }
953
954
    /**
955
     * Get the translated user friendly plural name of this DataObject
956
     * Same as plural_name but runs it through the translation function
957
     * Translation string is in the form:
958
     *      $this->class.PLURALNAME
959
     * Example:
960
     *      Page.PLURALNAME
961
     *
962
     * @return string User friendly translated plural name of this DataObject
963
     */
964
    public function i18n_plural_name()
965
    {
966
        return _t(static::class . '.PLURALNAME', $this->plural_name());
967
    }
968
969
    /**
970
     * Standard implementation of a title/label for a specific
971
     * record. Tries to find properties 'Title' or 'Name',
972
     * and falls back to the 'ID'. Useful to provide
973
     * user-friendly identification of a record, e.g. in errormessages
974
     * or UI-selections.
975
     *
976
     * Overload this method to have a more specialized implementation,
977
     * e.g. for an Address record this could be:
978
     * <code>
979
     * function getTitle() {
980
     *   return "{$this->StreetNumber} {$this->StreetName} {$this->City}";
981
     * }
982
     * </code>
983
     *
984
     * @return string
985
     */
986
    public function getTitle()
987
    {
988
        $schema = static::getSchema();
989
        if ($schema->fieldSpec($this, 'Title')) {
990
            return $this->getField('Title');
991
        }
992
        if ($schema->fieldSpec($this, 'Name')) {
993
            return $this->getField('Name');
994
        }
995
996
        return "#{$this->ID}";
997
    }
998
999
    /**
1000
     * Returns the associated database record - in this case, the object itself.
1001
     * This is included so that you can call $dataOrController->data() and get a DataObject all the time.
1002
     *
1003
     * @return DataObject Associated database record
1004
     */
1005
    public function data()
1006
    {
1007
        return $this;
1008
    }
1009
1010
    /**
1011
     * Convert this object to a map.
1012
     * Note that it has the following quirks:
1013
     *  - custom getters, including those that adjust the result of database fields, won't be executed
1014
     *  - NULL values won't be returned.
1015
     *
1016
     * @return array The data as a map.
1017
     */
1018
    public function toMap()
1019
    {
1020
        $this->loadLazyFields();
1021
        return array_filter($this->record ?? [], function ($val) {
1022
            return $val !== null;
1023
        });
1024
    }
1025
1026
    /**
1027
     * Return all currently fetched database fields.
1028
     *
1029
     * This function is similar to toMap() but doesn't trigger the lazy-loading of all unfetched fields.
1030
     * Obviously, this makes it a lot faster.
1031
     *
1032
     * @return array The data as a map.
1033
     */
1034
    public function getQueriedDatabaseFields()
1035
    {
1036
        return $this->record;
1037
    }
1038
1039
    /**
1040
     * Update a number of fields on this object, given a map of the desired changes.
1041
     *
1042
     * The field names can be simple names, or you can use a dot syntax to access $has_one relations.
1043
     * For example, array("Author.FirstName" => "Jim") will set $this->Author()->FirstName to "Jim".
1044
     *
1045
     * Doesn't write the main object, but if you use the dot syntax, it will write()
1046
     * the related objects that it alters.
1047
     *
1048
     * When using this method with user supplied data, it's very important to
1049
     * whitelist the allowed keys.
1050
     *
1051
     * @param array $data A map of field name to data values to update.
1052
     * @return DataObject $this
1053
     */
1054
    public function update($data)
1055
    {
1056
        foreach ($data as $key => $value) {
1057
            // Implement dot syntax for updates
1058
            if (strpos($key ?? '', '.') !== false) {
1059
                $relations = explode('.', $key ?? '');
1060
                $fieldName = array_pop($relations);
1061
                /** @var static $relObj */
1062
                $relObj = $this;
1063
                $relation = null;
1064
                foreach ($relations as $i => $relation) {
1065
                    // no support for has_many or many_many relationships,
1066
                    // as the updater wouldn't know which object to write to (or create)
1067
                    if ($relObj->$relation() instanceof DataObject) {
1068
                        $parentObj = $relObj;
1069
                        $relObj = $relObj->$relation();
1070
                        // If the intermediate relationship objects haven't been created, then write them
1071
                        if ($i < sizeof($relations ?? []) - 1 && !$relObj->ID || (!$relObj->ID && $parentObj !== $this)) {
0 ignored issues
show
introduced by
Consider adding parentheses for clarity. Current Interpretation: ($i < sizeof($relations ...&& $parentObj !== $this, Probably Intended Meaning: $i < sizeof($relations ?...& $parentObj !== $this)
Loading history...
1072
                            $relObj->write();
1073
                            $relatedFieldName = $relation . "ID";
1074
                            $parentObj->$relatedFieldName = $relObj->ID;
1075
                            $parentObj->write();
1076
                        }
1077
                    } else {
1078
                        user_error(
1079
                            "DataObject::update(): Can't traverse relationship '$relation'," .
1080
                            "it has to be a has_one relationship or return a single DataObject",
1081
                            E_USER_NOTICE
1082
                        );
1083
                        // unset relation object so we don't write properties to the wrong object
1084
                        $relObj = null;
1085
                        break;
1086
                    }
1087
                }
1088
1089
                if ($relObj) {
1090
                    $relObj->$fieldName = $value;
1091
                    $relObj->write();
1092
                    $relatedFieldName = $relation . "ID";
1093
                    $this->$relatedFieldName = $relObj->ID;
1094
                    $relObj->flushCache();
1095
                } else {
1096
                    $class = static::class;
1097
                    user_error("Couldn't follow dot syntax '{$key}' on '{$class}' object", E_USER_WARNING);
1098
                }
1099
            } else {
1100
                $this->$key = $value;
1101
            }
1102
        }
1103
        return $this;
1104
    }
1105
1106
    /**
1107
     * Pass changes as a map, and try to
1108
     * get automatic casting for these fields.
1109
     * Doesn't write to the database. To write the data,
1110
     * use the write() method.
1111
     *
1112
     * @param array $data A map of field name to data values to update.
1113
     * @return DataObject $this
1114
     */
1115
    public function castedUpdate($data)
1116
    {
1117
        foreach ($data as $k => $v) {
1118
            $this->setCastedField($k, $v);
1119
        }
1120
        return $this;
1121
    }
1122
1123
    /**
1124
     * Merges data and relations from another object of same class,
1125
     * without conflict resolution. Allows to specify which
1126
     * dataset takes priority in case its not empty.
1127
     * has_one-relations are just transferred with priority 'right'.
1128
     * has_many and many_many-relations are added regardless of priority.
1129
     *
1130
     * Caution: has_many/many_many relations are moved rather than duplicated,
1131
     * meaning they are not connected to the merged object any longer.
1132
     * Caution: Just saves updated has_many/many_many relations to the database,
1133
     * doesn't write the updated object itself (just writes the object-properties).
1134
     * Caution: Does not delete the merged object.
1135
     * Caution: Does now overwrite Created date on the original object.
1136
     *
1137
     * @param DataObject $rightObj
1138
     * @param string $priority left|right Determines who wins in case of a conflict (optional)
1139
     * @param bool $includeRelations Merge any existing relations (optional)
1140
     * @param bool $overwriteWithEmpty Overwrite existing left values with empty right values.
1141
     *                            Only applicable with $priority='right'. (optional)
1142
     * @return Boolean
1143
     */
1144
    public function merge($rightObj, $priority = 'right', $includeRelations = true, $overwriteWithEmpty = false)
1145
    {
1146
        $leftObj = $this;
1147
1148
        if ($leftObj->ClassName != $rightObj->ClassName) {
1149
            // we can't merge similar subclasses because they might have additional relations
1150
            user_error("DataObject->merge(): Invalid object class '{$rightObj->ClassName}'
1151
			(expected '{$leftObj->ClassName}').", E_USER_WARNING);
1152
            return false;
1153
        }
1154
1155
        if (!$rightObj->ID) {
1156
            user_error("DataObject->merge(): Please write your merged-in object to the database before merging,
1157
				to make sure all relations are transferred properly.').", E_USER_WARNING);
1158
            return false;
1159
        }
1160
1161
        // makes sure we don't merge data like ID or ClassName
1162
        $rightData = DataObject::getSchema()->fieldSpecs(get_class($rightObj));
1163
        foreach ($rightData as $key => $rightSpec) {
1164
            // Don't merge ID
1165
            if ($key === 'ID') {
1166
                continue;
1167
            }
1168
1169
            // Only merge relations if allowed
1170
            if ($rightSpec === 'ForeignKey' && !$includeRelations) {
1171
                continue;
1172
            }
1173
1174
            // don't merge conflicting values if priority is 'left'
1175
            if ($priority == 'left' && $leftObj->{$key} !== $rightObj->{$key}) {
1176
                continue;
1177
            }
1178
1179
            // don't overwrite existing left values with empty right values (if $overwriteWithEmpty is set)
1180
            if ($priority == 'right' && !$overwriteWithEmpty && empty($rightObj->{$key})) {
1181
                continue;
1182
            }
1183
1184
            // TODO remove redundant merge of has_one fields
1185
            $leftObj->{$key} = $rightObj->{$key};
1186
        }
1187
1188
        // merge relations
1189
        if ($includeRelations) {
1190
            if ($manyMany = $this->manyMany()) {
1191
                foreach ($manyMany as $relationship => $class) {
1192
                    /** @var DataObject $leftComponents */
1193
                    $leftComponents = $leftObj->getManyManyComponents($relationship);
1194
                    $rightComponents = $rightObj->getManyManyComponents($relationship);
1195
                    if ($rightComponents && $rightComponents->exists()) {
1196
                        $leftComponents->addMany($rightComponents->column('ID'));
0 ignored issues
show
Bug introduced by
The method addMany() does not exist on SilverStripe\ORM\DataObject. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

1196
                        $leftComponents->/** @scrutinizer ignore-call */ 
1197
                                         addMany($rightComponents->column('ID'));
Loading history...
1197
                    }
1198
                    $leftComponents->write();
1199
                }
1200
            }
1201
1202
            if ($hasMany = $this->hasMany()) {
1203
                foreach ($hasMany as $relationship => $class) {
1204
                    $leftComponents = $leftObj->getComponents($relationship);
1205
                    $rightComponents = $rightObj->getComponents($relationship);
1206
                    if ($rightComponents && $rightComponents->exists()) {
1207
                        $leftComponents->addMany($rightComponents->column('ID'));
1208
                    }
1209
                    $leftComponents->write();
0 ignored issues
show
Bug introduced by
The method write() does not exist on SilverStripe\ORM\UnsavedRelationList. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

1209
                    $leftComponents->/** @scrutinizer ignore-call */ 
1210
                                     write();
Loading history...
Bug introduced by
The method write() does not exist on SilverStripe\ORM\HasManyList. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

1209
                    $leftComponents->/** @scrutinizer ignore-call */ 
1210
                                     write();
Loading history...
1210
                }
1211
            }
1212
        }
1213
1214
        return true;
1215
    }
1216
1217
    /**
1218
     * Forces the record to think that all its data has changed.
1219
     * Doesn't write to the database. Force-change preserved until
1220
     * next write. Existing CHANGE_VALUE or CHANGE_STRICT values
1221
     * are preserved.
1222
     *
1223
     * @return $this
1224
     */
1225
    public function forceChange()
1226
    {
1227
        // Ensure lazy fields loaded
1228
        $this->loadLazyFields();
1229
1230
        // Populate the null values in record so that they actually get written
1231
        foreach (array_keys(static::getSchema()->fieldSpecs(static::class) ?? []) as $fieldName) {
1232
            if (!isset($this->record[$fieldName])) {
1233
                $this->record[$fieldName] = null;
1234
            }
1235
        }
1236
1237
        $this->changeForced = true;
1238
1239
        return $this;
1240
    }
1241
1242
    /**
1243
     * Validate the current object.
1244
     *
1245
     * By default, there is no validation - objects are always valid!  However, you can overload this method in your
1246
     * DataObject sub-classes to specify custom validation, or use the hook through DataExtension.
1247
     *
1248
     * Invalid objects won't be able to be written - a warning will be thrown and no write will occur.  onBeforeWrite()
1249
     * and onAfterWrite() won't get called either.
1250
     *
1251
     * It is expected that you call validate() in your own application to test that an object is valid before
1252
     * attempting a write, and respond appropriately if it isn't.
1253
     *
1254
     * @see {@link ValidationResult}
1255
     * @return ValidationResult
1256
     */
1257
    public function validate()
1258
    {
1259
        $result = ValidationResult::create();
1260
        $this->extend('validate', $result);
1261
        return $result;
1262
    }
1263
1264
    /**
1265
     * Public accessor for {@see DataObject::validate()}
1266
     *
1267
     * @return ValidationResult
1268
     */
1269
    public function doValidate()
1270
    {
1271
        Deprecation::notice('5.0', 'Use validate');
1272
        return $this->validate();
1273
    }
1274
1275
    /**
1276
     * Event handler called before writing to the database.
1277
     * You can overload this to clean up or otherwise process data before writing it to the
1278
     * database.  Don't forget to call parent::onBeforeWrite(), though!
1279
     *
1280
     * This called after {@link $this->validate()}, so you can be sure that your data is valid.
1281
     *
1282
     * @uses DataExtension::onBeforeWrite()
1283
     */
1284
    protected function onBeforeWrite()
1285
    {
1286
        $this->brokenOnWrite = false;
1287
1288
        $dummy = null;
1289
        $this->extend('onBeforeWrite', $dummy);
1290
    }
1291
1292
    /**
1293
     * Event handler called after writing to the database.
1294
     * You can overload this to act upon changes made to the data after it is written.
1295
     * $this->changed will have a record
1296
     * database.  Don't forget to call parent::onAfterWrite(), though!
1297
     *
1298
     * @uses DataExtension::onAfterWrite()
1299
     */
1300
    protected function onAfterWrite()
1301
    {
1302
        $dummy = null;
1303
        $this->extend('onAfterWrite', $dummy);
1304
    }
1305
1306
    /**
1307
     * Find all objects that will be cascade deleted if this object is deleted
1308
     *
1309
     * Notes:
1310
     *   - If this object is versioned, objects will only be searched in the same stage as the given record.
1311
     *   - This will only be useful prior to deletion, as post-deletion this record will no longer exist.
1312
     *
1313
     * @param bool $recursive True if recursive
1314
     * @param ArrayList $list Optional list to add items to
1315
     * @return ArrayList list of objects
1316
     */
1317
    public function findCascadeDeletes($recursive = true, $list = null)
1318
    {
1319
        // Find objects in these relationships
1320
        return $this->findRelatedObjects('cascade_deletes', $recursive, $list);
1321
    }
1322
1323
    /**
1324
     * Event handler called before deleting from the database.
1325
     * You can overload this to clean up or otherwise process data before delete this
1326
     * record.  Don't forget to call parent::onBeforeDelete(), though!
1327
     *
1328
     * @uses DataExtension::onBeforeDelete()
1329
     */
1330
    protected function onBeforeDelete()
1331
    {
1332
        $this->brokenOnDelete = false;
1333
1334
        $dummy = null;
1335
        $this->extend('onBeforeDelete', $dummy);
1336
1337
        // Cascade deletes
1338
        $deletes = $this->findCascadeDeletes(false);
1339
        foreach ($deletes as $delete) {
1340
            $delete->delete();
1341
        }
1342
    }
1343
1344
    protected function onAfterDelete()
1345
    {
1346
        $this->extend('onAfterDelete');
1347
    }
1348
1349
    /**
1350
     * Load the default values in from the self::$defaults array.
1351
     * Will traverse the defaults of the current class and all its parent classes.
1352
     * Called by the constructor when creating new records.
1353
     *
1354
     * @uses DataExtension::populateDefaults()
1355
     * @return DataObject $this
1356
     */
1357
    public function populateDefaults()
1358
    {
1359
        $classes = array_reverse(ClassInfo::ancestry($this) ?? []);
1360
1361
        foreach ($classes as $class) {
1362
            $defaults = Config::inst()->get($class, 'defaults', Config::UNINHERITED);
1363
1364
            if ($defaults && !is_array($defaults)) {
1365
                user_error(
1366
                    "Bad '" . static::class . "' defaults given: " . var_export($defaults, true),
1367
                    E_USER_WARNING
1368
                );
1369
                $defaults = null;
1370
            }
1371
1372
            if ($defaults) {
1373
                foreach ($defaults as $fieldName => $fieldValue) {
1374
                    // SRM 2007-03-06: Stricter check
1375
                    if (!isset($this->$fieldName) || $this->$fieldName === null) {
1376
                        $this->$fieldName = $fieldValue;
1377
                    }
1378
                    // Set many-many defaults with an array of ids
1379
                    if (is_array($fieldValue) && $this->getSchema()->manyManyComponent(static::class, $fieldName)) {
1380
                        /** @var ManyManyList $manyManyJoin */
1381
                        $manyManyJoin = $this->$fieldName();
1382
                        $manyManyJoin->setByIDList($fieldValue);
1383
                    }
1384
                }
1385
            }
1386
            if ($class == self::class) {
1387
                break;
1388
            }
1389
        }
1390
1391
        $this->extend('populateDefaults');
1392
        return $this;
1393
    }
1394
1395
    /**
1396
     * Determine validation of this object prior to write
1397
     *
1398
     * @return ValidationException Exception generated by this write, or null if valid
1399
     */
1400
    protected function validateWrite()
1401
    {
1402
        if ($this->ObsoleteClassName) {
1403
            return new ValidationException(
1404
                "Object is of class '{$this->ObsoleteClassName}' which doesn't exist - " .
1405
                "you need to change the ClassName before you can write it"
1406
            );
1407
        }
1408
1409
        // Note: Validation can only be disabled at the global level, not per-model
1410
        if (DataObject::config()->uninherited('validation_enabled')) {
1411
            $result = $this->validate();
1412
            if (!$result->isValid()) {
1413
                return new ValidationException($result);
1414
            }
1415
        }
1416
        return null;
1417
    }
1418
1419
    /**
1420
     * Prepare an object prior to write
1421
     *
1422
     * @throws ValidationException
1423
     */
1424
    protected function preWrite()
1425
    {
1426
        // Validate this object
1427
        if ($writeException = $this->validateWrite()) {
1428
            // Used by DODs to clean up after themselves, eg, Versioned
1429
            $this->invokeWithExtensions('onAfterSkippedWrite');
1430
            throw $writeException;
1431
        }
1432
1433
        // Check onBeforeWrite
1434
        $this->brokenOnWrite = true;
1435
        $this->onBeforeWrite();
1436
        if ($this->brokenOnWrite) {
1437
            throw new LogicException(
1438
                static::class . " has a broken onBeforeWrite() function."
1439
                . " Make sure that you call parent::onBeforeWrite()."
1440
            );
1441
        }
1442
    }
1443
1444
    /**
1445
     * Detects and updates all changes made to this object
1446
     *
1447
     * @param bool $forceChanges If set to true, force all fields to be treated as changed
1448
     * @return bool True if any changes are detected
1449
     */
1450
    protected function updateChanges($forceChanges = false)
1451
    {
1452
        if ($forceChanges) {
1453
            // Force changes, but only for loaded fields
1454
            foreach ($this->record as $field => $value) {
1455
                $this->changed[$field] = static::CHANGE_VALUE;
1456
            }
1457
            return true;
1458
        }
1459
        return $this->isChanged();
1460
    }
1461
1462
    /**
1463
     * Writes a subset of changes for a specific table to the given manipulation
1464
     *
1465
     * @param string $baseTable Base table
1466
     * @param string $now Timestamp to use for the current time
1467
     * @param bool $isNewRecord Whether this should be treated as a new record write
1468
     * @param array $manipulation Manipulation to write to
1469
     * @param string $class Class of table to manipulate
1470
     */
1471
    protected function prepareManipulationTable($baseTable, $now, $isNewRecord, &$manipulation, $class)
1472
    {
1473
        $schema = $this->getSchema();
1474
        $table = $schema->tableName($class);
1475
        $manipulation[$table] = [];
1476
1477
        $changed = $this->getChangedFields();
1478
1479
        // Extract records for this table
1480
        foreach ($this->record as $fieldName => $fieldValue) {
1481
            // we're not attempting to reset the BaseTable->ID
1482
            // Ignore unchanged fields or attempts to reset the BaseTable->ID
1483
            if (empty($changed[$fieldName]) || ($table === $baseTable && $fieldName === 'ID')) {
1484
                continue;
1485
            }
1486
1487
            // Ensure this field pertains to this table
1488
            $specification = $schema->fieldSpec(
1489
                $class,
1490
                $fieldName,
1491
                DataObjectSchema::DB_ONLY | DataObjectSchema::UNINHERITED
1492
            );
1493
            if (!$specification) {
1494
                continue;
1495
            }
1496
1497
            // if database column doesn't correlate to a DBField instance...
1498
            $fieldObj = $this->dbObject($fieldName);
1499
            if (!$fieldObj) {
1500
                $fieldObj = DBField::create_field('Varchar', $fieldValue, $fieldName);
1501
            }
1502
1503
            // Write to manipulation
1504
            $fieldObj->writeToManipulation($manipulation[$table]);
1505
        }
1506
1507
        // Ensure update of Created and LastEdited columns
1508
        if ($baseTable === $table) {
1509
            $manipulation[$table]['fields']['LastEdited'] = $now;
1510
            if ($isNewRecord) {
1511
                $manipulation[$table]['fields']['Created'] = empty($this->record['Created'])
1512
                    ? $now
1513
                    : $this->record['Created'];
1514
                $manipulation[$table]['fields']['ClassName'] = static::class;
1515
            }
1516
        }
1517
1518
        // Inserts done one the base table are performed in another step, so the manipulation should instead
1519
        // attempt an update, as though it were a normal update.
1520
        $manipulation[$table]['command'] = $isNewRecord ? 'insert' : 'update';
1521
        $manipulation[$table]['class'] = $class;
1522
        if ($this->isInDB()) {
1523
            $manipulation[$table]['id'] = $this->record['ID'];
1524
        }
1525
    }
1526
1527
    /**
1528
     * Ensures that a blank base record exists with the basic fixed fields for this dataobject
1529
     *
1530
     * Does nothing if an ID is already assigned for this record
1531
     *
1532
     * @param string $baseTable Base table
1533
     * @param string $now Timestamp to use for the current time
1534
     */
1535
    protected function writeBaseRecord($baseTable, $now)
1536
    {
1537
        // Generate new ID if not specified
1538
        if ($this->isInDB()) {
1539
            return;
1540
        }
1541
1542
        // Perform an insert on the base table
1543
        $manipulation = [];
1544
        $this->prepareManipulationTable($baseTable, $now, true, $manipulation, $this->baseClass());
1545
        DB::manipulate($manipulation);
1546
1547
        $this->changed['ID'] = self::CHANGE_VALUE;
1548
        $this->record['ID'] = DB::get_generated_id($baseTable);
1549
    }
1550
1551
    /**
1552
     * Generate and write the database manipulation for all changed fields
1553
     *
1554
     * @param string $baseTable Base table
1555
     * @param string $now Timestamp to use for the current time
1556
     * @param bool $isNewRecord If this is a new record
1557
     * @throws InvalidArgumentException
1558
     */
1559
    protected function writeManipulation($baseTable, $now, $isNewRecord)
1560
    {
1561
        // Generate database manipulations for each class
1562
        $manipulation = [];
1563
        foreach (ClassInfo::ancestry(static::class, true) as $class) {
1564
            $this->prepareManipulationTable($baseTable, $now, $isNewRecord, $manipulation, $class);
1565
        }
1566
1567
        // Allow extensions to extend this manipulation
1568
        $this->extend('augmentWrite', $manipulation);
1569
1570
        // New records have their insert into the base data table done first, so that they can pass the
1571
        // generated ID on to the rest of the manipulation
1572
        if ($isNewRecord) {
1573
            $manipulation[$baseTable]['command'] = 'update';
1574
        }
1575
1576
        // Make sure none of our field assignment are arrays
1577
        foreach ($manipulation as $tableManipulation) {
1578
            if (!isset($tableManipulation['fields'])) {
1579
                continue;
1580
            }
1581
            foreach ($tableManipulation['fields'] as $fieldName => $fieldValue) {
1582
                if (is_array($fieldValue)) {
1583
                    $dbObject = $this->dbObject($fieldName);
1584
                    // If the field allows non-scalar values we'll let it do dynamic assignments
1585
                    if ($dbObject && $dbObject->scalarValueOnly()) {
1586
                        throw new InvalidArgumentException(
1587
                            'DataObject::writeManipulation: parameterised field assignments are disallowed'
1588
                        );
1589
                    }
1590
                }
1591
            }
1592
        }
1593
1594
        // Perform the manipulation
1595
        DB::manipulate($manipulation);
1596
    }
1597
1598
    /**
1599
     * Writes all changes to this object to the database.
1600
     *  - It will insert a record whenever ID isn't set, otherwise update.
1601
     *  - All relevant tables will be updated.
1602
     *  - $this->onBeforeWrite() gets called beforehand.
1603
     *  - Extensions such as Versioned will amend the database-write to ensure that a version is saved.
1604
     *
1605
     * @uses DataExtension::augmentWrite()
1606
     *
1607
     * @param boolean       $showDebug Show debugging information
1608
     * @param boolean       $forceInsert Run INSERT command rather than UPDATE, even if record already exists
1609
     * @param boolean       $forceWrite Write to database even if there are no changes
1610
     * @param boolean|array $writeComponents Call write() on all associated component instances which were previously
1611
     *                      retrieved through {@link getComponent()}, {@link getComponents()} or
1612
     *                      {@link getManyManyComponents()}. Default to `false`. The parameter can also be provided in
1613
     *                      the form of an array: `['recursive' => true, skip => ['Page'=>[1,2,3]]`. This avoid infinite
1614
     *                      loops when one DataObject are components of each other.
1615
     * @return int The ID of the record
1616
     * @throws ValidationException Exception that can be caught and handled by the calling function
1617
     */
1618
    public function write($showDebug = false, $forceInsert = false, $forceWrite = false, $writeComponents = false)
1619
    {
1620
        $now = DBDatetime::now()->Rfc2822();
1621
1622
        // Execute pre-write tasks
1623
        $this->preWrite();
1624
1625
        // Check if we are doing an update or an insert
1626
        $isNewRecord = !$this->isInDB() || $forceInsert;
1627
1628
        // Check changes exist, abort if there are none
1629
        $hasChanges = $this->updateChanges($isNewRecord);
1630
        if ($hasChanges || $forceWrite || $isNewRecord) {
1631
            // Ensure Created and LastEdited are populated
1632
            if (!isset($this->record['Created'])) {
1633
                $this->record['Created'] = $now;
1634
            }
1635
            $this->record['LastEdited'] = $now;
1636
1637
            // New records have their insert into the base data table done first, so that they can pass the
1638
            // generated primary key on to the rest of the manipulation
1639
            $baseTable = $this->baseTable();
1640
            $this->writeBaseRecord($baseTable, $now);
1641
1642
            // Write the DB manipulation for all changed fields
1643
            $this->writeManipulation($baseTable, $now, $isNewRecord);
1644
1645
            // If there's any relations that couldn't be saved before, save them now (we have an ID here)
1646
            $this->writeRelations();
1647
            $this->onAfterWrite();
1648
1649
            // Reset isChanged data
1650
            // DBComposites properly bound to the parent record will also have their isChanged value reset
1651
            $this->changed = [];
1652
            $this->changeForced = false;
1653
            $this->original = $this->record;
1654
        } else {
1655
            if ($showDebug) {
1656
                Debug::message("no changes for DataObject");
1657
            }
1658
1659
            // Used by DODs to clean up after themselves, eg, Versioned
1660
            $this->invokeWithExtensions('onAfterSkippedWrite');
1661
        }
1662
1663
        // Write relations as necessary
1664
        if ($writeComponents) {
1665
            $recursive = true;
1666
            $skip = [];
1667
            if (is_array($writeComponents)) {
1668
                $recursive = isset($writeComponents['recursive']) && $writeComponents['recursive'];
1669
                $skip = isset($writeComponents['skip']) && is_array($writeComponents['skip'])
1670
                    ? $writeComponents['skip']
1671
                    : [];
1672
            }
1673
            $this->writeComponents($recursive, $skip);
1674
        }
1675
1676
        // Clears the cache for this object so get_one returns the correct object.
1677
        $this->flushCache();
1678
1679
        return $this->record['ID'];
1680
    }
1681
1682
    /**
1683
     * Writes cached relation lists to the database, if possible
1684
     */
1685
    public function writeRelations()
1686
    {
1687
        if (!$this->isInDB()) {
1688
            return;
1689
        }
1690
1691
        // If there's any relations that couldn't be saved before, save them now (we have an ID here)
1692
        if ($this->unsavedRelations) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->unsavedRelations of type SilverStripe\ORM\UnsavedRelationList[] 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...
1693
            foreach ($this->unsavedRelations as $name => $list) {
1694
                $list->changeToList($this->$name());
1695
            }
1696
            $this->unsavedRelations = [];
1697
        }
1698
    }
1699
1700
    /**
1701
     * Write the cached components to the database. Cached components could refer to two different instances of the
1702
     * same record.
1703
     *
1704
     * @param bool $recursive Recursively write components
1705
     * @param array $skip List of DataObject references to skip
1706
     * @return DataObject $this
1707
     */
1708
    public function writeComponents($recursive = false, $skip = [])
1709
    {
1710
        // Make sure we add our current object to the skip list
1711
        $this->skipWriteComponents($recursive, $this, $skip);
1712
1713
        // All our write calls have the same arguments ... just need make sure the skip list is pass by reference
1714
        $args = [
1715
            false, false, false,
1716
            $recursive ? ["recursive" => $recursive, "skip" => &$skip] : false
1717
        ];
1718
1719
        foreach ($this->components as $component) {
1720
            if (!$this->skipWriteComponents($recursive, $component, $skip)) {
1721
                $component->write(...$args);
0 ignored issues
show
Bug introduced by
It seems like $args can also be of type array<string,mixed|true>; however, parameter $showDebug of SilverStripe\ORM\DataObject::write() does only seem to accept boolean, maybe add an additional type check? ( Ignorable by Annotation )

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

1721
                $component->write(/** @scrutinizer ignore-type */ ...$args);
Loading history...
1722
            }
1723
        }
1724
1725
        if ($join = $this->getJoin()) {
1726
            if (!$this->skipWriteComponents($recursive, $join, $skip)) {
1727
                $join->write(...$args);
1728
            }
1729
        }
1730
1731
        return $this;
1732
    }
1733
1734
    /**
1735
     * Check if target is in the skip list and add it if it isn't.
1736
     * @param bool $recursive
1737
     * @param DataObject $target
1738
     * @param array $skip
1739
     * @return bool Whether the target is already in the list
1740
     */
1741
    private function skipWriteComponents($recursive, DataObject $target, array &$skip)
1742
    {
1743
        // skip writing component if it doesn't exist
1744
        if (!$target->exists()) {
1745
            return true;
1746
        }
1747
1748
        // We only care about the skip list if our call is meant to be recursive
1749
        if (!$recursive) {
1750
            return false;
1751
        }
1752
1753
        // Get our Skip array keys
1754
        $classname = get_class($target);
1755
        $id = $target->ID;
1756
1757
        // Check if the target is in the skip list
1758
        if (isset($skip[$classname])) {
1759
            if (in_array($id, $skip[$classname] ?? [])) {
1760
                // Skip the object
1761
                return true;
1762
            }
1763
        } else {
1764
            // This is the first object of this class
1765
            $skip[$classname] = [];
1766
        }
1767
1768
        // Add the target to our skip list
1769
        $skip[$classname][] = $id;
1770
1771
        return false;
1772
    }
1773
1774
    /**
1775
     * Delete this data object.
1776
     * $this->onBeforeDelete() gets called.
1777
     * Note that in Versioned objects, both Stage and Live will be deleted.
1778
     * @uses DataExtension::augmentSQL()
1779
     */
1780
    public function delete()
1781
    {
1782
        $this->brokenOnDelete = true;
1783
        $this->onBeforeDelete();
1784
        if ($this->brokenOnDelete) {
1785
            throw new LogicException(
1786
                static::class . " has a broken onBeforeDelete() function."
1787
                . " Make sure that you call parent::onBeforeDelete()."
1788
            );
1789
        }
1790
1791
        // Deleting a record without an ID shouldn't do anything
1792
        if (!$this->ID) {
1793
            throw new LogicException("DataObject::delete() called on a DataObject without an ID");
1794
        }
1795
1796
        // TODO: This is quite ugly.  To improve:
1797
        //  - move the details of the delete code in the DataQuery system
1798
        //  - update the code to just delete the base table, and rely on cascading deletes in the DB to do the rest
1799
        //    obviously, that means getting requireTable() to configure cascading deletes ;-)
1800
        $srcQuery = DataList::create(static::class)
1801
            ->filter('ID', $this->ID)
1802
            ->dataQuery()
1803
            ->query();
1804
        $queriedTables = $srcQuery->queriedTables();
1805
        $this->extend('updateDeleteTables', $queriedTables, $srcQuery);
1806
        foreach ($queriedTables as $table) {
1807
            $delete = SQLDelete::create("\"$table\"", ['"ID"' => $this->ID]);
1808
            $this->extend('updateDeleteTable', $delete, $table, $queriedTables, $srcQuery);
1809
            $delete->execute();
1810
        }
1811
        // Remove this item out of any caches
1812
        $this->flushCache();
1813
1814
        $this->onAfterDelete();
1815
1816
        $this->OldID = $this->ID;
1817
        $this->ID = 0;
1818
    }
1819
1820
    /**
1821
     * Delete the record with the given ID.
1822
     *
1823
     * @param string $className The class name of the record to be deleted
1824
     * @param int $id ID of record to be deleted
1825
     */
1826
    public static function delete_by_id($className, $id)
1827
    {
1828
        $obj = DataObject::get_by_id($className, $id);
1829
        if ($obj) {
1830
            $obj->delete();
1831
        } else {
1832
            user_error("$className object #$id wasn't found when calling DataObject::delete_by_id", E_USER_WARNING);
1833
        }
1834
    }
1835
1836
    /**
1837
     * Get the class ancestry, including the current class name.
1838
     * The ancestry will be returned as an array of class names, where the 0th element
1839
     * will be the class that inherits directly from DataObject, and the last element
1840
     * will be the current class.
1841
     *
1842
     * @return array Class ancestry
1843
     */
1844
    public function getClassAncestry()
1845
    {
1846
        return ClassInfo::ancestry(static::class);
1847
    }
1848
1849
    /**
1850
     * Return a unary component object from a one to one relationship, as a DataObject.
1851
     * If no component is available, an 'empty component' will be returned for
1852
     * non-polymorphic relations, or for polymorphic relations with a class set.
1853
     *
1854
     * @param string $componentName Name of the component
1855
     * @return DataObject The component object. It's exact type will be that of the component.
1856
     * @throws Exception
1857
     */
1858
    public function getComponent($componentName)
1859
    {
1860
        if (isset($this->components[$componentName])) {
1861
            return $this->components[$componentName];
1862
        }
1863
1864
        // The join object can be returned as a component, named for its alias
1865
        if (isset($this->record[$componentName]) && $this->record[$componentName] === $this->joinRecord) {
1866
            return $this->record[$componentName];
1867
        }
1868
1869
        $schema = static::getSchema();
1870
        if ($class = $schema->hasOneComponent(static::class, $componentName)) {
1871
            $joinField = $componentName . 'ID';
1872
            $joinID = $this->getField($joinField);
1873
1874
            // Extract class name for polymorphic relations
1875
            if ($class === self::class) {
1876
                $class = $this->getField($componentName . 'Class');
1877
                if (empty($class)) {
1878
                    return null;
1879
                }
1880
            }
1881
1882
            if ($joinID) {
1883
                // Ensure that the selected object originates from the same stage, subsite, etc
1884
                $component = DataObject::get($class)
1885
                    ->filter('ID', $joinID)
1886
                    ->setDataQueryParam($this->getInheritableQueryParams())
1887
                    ->first();
1888
            }
1889
1890
            if (empty($component)) {
1891
                $component = Injector::inst()->create($class);
1892
            }
1893
        } elseif ($class = $schema->belongsToComponent(static::class, $componentName)) {
1894
            $joinField = $schema->getRemoteJoinField(static::class, $componentName, 'belongs_to', $polymorphic);
1895
            $joinID = $this->ID;
1896
1897
            if ($joinID) {
1898
                // Prepare filter for appropriate join type
1899
                if ($polymorphic) {
1900
                    $filter = [
1901
                        "{$joinField}ID" => $joinID,
1902
                        "{$joinField}Class" => static::class,
1903
                    ];
1904
                } else {
1905
                    $filter = [
1906
                        $joinField => $joinID
1907
                    ];
1908
                }
1909
1910
                // Ensure that the selected object originates from the same stage, subsite, etc
1911
                $component = DataObject::get($class)
1912
                    ->filter($filter)
1913
                    ->setDataQueryParam($this->getInheritableQueryParams())
1914
                    ->first();
1915
            }
1916
1917
            if (empty($component)) {
1918
                $component = Injector::inst()->create($class);
1919
                if ($polymorphic) {
1920
                    $component->{$joinField . 'ID'} = $this->ID;
1921
                    $component->{$joinField . 'Class'} = static::class;
1922
                } else {
1923
                    $component->$joinField = $this->ID;
1924
                }
1925
            }
1926
        } else {
1927
            throw new InvalidArgumentException(
1928
                "DataObject->getComponent(): Could not find component '$componentName'."
1929
            );
1930
        }
1931
1932
        $this->components[$componentName] = $component;
1933
        return $component;
1934
    }
1935
1936
    /**
1937
     * Assign an item to the given component
1938
     *
1939
     * @param string $componentName
1940
     * @param DataObject|null $item
1941
     * @return $this
1942
     */
1943
    public function setComponent($componentName, $item)
1944
    {
1945
        // Validate component
1946
        $schema = static::getSchema();
1947
        if ($class = $schema->hasOneComponent(static::class, $componentName)) {
1948
            // Force item to be written if not by this point
1949
            // @todo This could be lazy-written in a beforeWrite hook, but force write here for simplicity
1950
            // https://github.com/silverstripe/silverstripe-framework/issues/7818
1951
            if ($item && !$item->isInDB()) {
1952
                $item->write();
1953
            }
1954
1955
            // Update local ID
1956
            $joinField = $componentName . 'ID';
1957
            $this->setField($joinField, $item ? $item->ID : null);
1958
            // Update Class (Polymorphic has_one)
1959
            // Extract class name for polymorphic relations
1960
            if ($class === self::class) {
1961
                $this->setField($componentName . 'Class', $item ? get_class($item) : null);
1962
            }
1963
        } elseif ($class = $schema->belongsToComponent(static::class, $componentName)) {
0 ignored issues
show
Unused Code introduced by
The assignment to $class is dead and can be removed.
Loading history...
1964
            if ($item) {
1965
                // For belongs_to, add to has_one on other component
1966
                $joinField = $schema->getRemoteJoinField(static::class, $componentName, 'belongs_to', $polymorphic);
1967
                if (!$polymorphic) {
1968
                    $joinField = substr($joinField ?? '', 0, -2);
1969
                }
1970
                $item->setComponent($joinField, $this);
1971
            }
1972
        } else {
1973
            throw new InvalidArgumentException(
1974
                "DataObject->setComponent(): Could not find component '$componentName'."
1975
            );
1976
        }
1977
1978
        $this->components[$componentName] = $item;
1979
        return $this;
1980
    }
1981
1982
    /**
1983
     * Returns a one-to-many relation as a HasManyList
1984
     *
1985
     * @param string $componentName Name of the component
1986
     * @param int|array $id Optional ID(s) for parent of this relation, if not the current record
1987
     * @return HasManyList|UnsavedRelationList The components of the one-to-many relationship.
1988
     */
1989
    public function getComponents($componentName, $id = null)
1990
    {
1991
        if (!isset($id)) {
1992
            $id = $this->ID;
1993
        }
1994
        $result = null;
1995
1996
        $schema = $this->getSchema();
1997
        $componentClass = $schema->hasManyComponent(static::class, $componentName);
1998
        if (!$componentClass) {
1999
            throw new InvalidArgumentException(sprintf(
2000
                "DataObject::getComponents(): Unknown 1-to-many component '%s' on class '%s'",
2001
                $componentName,
2002
                static::class
2003
            ));
2004
        }
2005
2006
        // If we haven't been written yet, we can't save these relations, so use a list that handles this case
2007
        if (!$id) {
2008
            if (!isset($this->unsavedRelations[$componentName])) {
2009
                $this->unsavedRelations[$componentName] =
2010
                    new UnsavedRelationList(static::class, $componentName, $componentClass);
2011
            }
2012
            return $this->unsavedRelations[$componentName];
2013
        }
2014
2015
        // Determine type and nature of foreign relation
2016
        $joinField = $schema->getRemoteJoinField(static::class, $componentName, 'has_many', $polymorphic);
2017
        /** @var HasManyList $result */
2018
        if ($polymorphic) {
2019
            $result = PolymorphicHasManyList::create($componentClass, $joinField, static::class);
2020
        } else {
2021
            $result = HasManyList::create($componentClass, $joinField);
2022
        }
2023
2024
        return $result
2025
            ->setDataQueryParam($this->getInheritableQueryParams())
2026
            ->forForeignID($id);
2027
    }
2028
2029
    /**
2030
     * Find the foreign class of a relation on this DataObject, regardless of the relation type.
2031
     *
2032
     * @param string $relationName Relation name.
2033
     * @return string Class name, or null if not found.
2034
     */
2035
    public function getRelationClass($relationName)
2036
    {
2037
        // Parse many_many
2038
        $manyManyComponent = $this->getSchema()->manyManyComponent(static::class, $relationName);
2039
        if ($manyManyComponent) {
2040
            return $manyManyComponent['childClass'];
2041
        }
2042
2043
        // Go through all relationship configuration fields.
2044
        $config = $this->config();
2045
        $candidates = array_merge(
2046
            ($relations = $config->get('has_one')) ? $relations : [],
2047
            ($relations = $config->get('has_many')) ? $relations : [],
2048
            ($relations = $config->get('belongs_to')) ? $relations : []
2049
        );
2050
2051
        if (isset($candidates[$relationName])) {
2052
            $remoteClass = $candidates[$relationName];
2053
2054
            // If dot notation is present, extract just the first part that contains the class.
2055
            if (($fieldPos = strpos($remoteClass ?? '', '.')) !== false) {
2056
                return substr($remoteClass ?? '', 0, $fieldPos);
2057
            }
2058
2059
            // Otherwise just return the class
2060
            return $remoteClass;
2061
        }
2062
2063
        return null;
2064
    }
2065
2066
    /**
2067
     * Given a relation name, determine the relation type
2068
     *
2069
     * @param string $component Name of component
2070
     * @return string has_one, has_many, many_many, belongs_many_many or belongs_to
2071
     */
2072
    public function getRelationType($component)
2073
    {
2074
        $types = ['has_one', 'has_many', 'many_many', 'belongs_many_many', 'belongs_to'];
2075
        $config = $this->config();
2076
        foreach ($types as $type) {
2077
            $relations = $config->get($type);
2078
            if ($relations && isset($relations[$component])) {
2079
                return $type;
2080
            }
2081
        }
2082
        return null;
2083
    }
2084
2085
    /**
2086
     * Given a relation declared on a remote class, generate a substitute component for the opposite
2087
     * side of the relation.
2088
     *
2089
     * Notes on behaviour:
2090
     *  - This can still be used on components that are defined on both sides, but do not need to be.
2091
     *  - All has_ones on remote class will be treated as local has_many, even if they are belongs_to
2092
     *  - Polymorphic relationships do not have two natural endpoints (only on one side)
2093
     *   and thus attempting to infer them will return nothing.
2094
     *  - Cannot be used on unsaved objects.
2095
     *
2096
     * @param string $remoteClass
2097
     * @param string $remoteRelation
2098
     * @return DataList|DataObject The component, either as a list or single object
2099
     * @throws BadMethodCallException
2100
     * @throws InvalidArgumentException
2101
     */
2102
    public function inferReciprocalComponent($remoteClass, $remoteRelation)
2103
    {
2104
        $remote = DataObject::singleton($remoteClass);
2105
        $class = $remote->getRelationClass($remoteRelation);
2106
        $schema = static::getSchema();
2107
2108
        // Validate arguments
2109
        if (!$this->isInDB()) {
2110
            throw new BadMethodCallException(__METHOD__ . " cannot be called on unsaved objects");
2111
        }
2112
        if (empty($class)) {
2113
            throw new InvalidArgumentException(sprintf(
2114
                "%s invoked with invalid relation %s.%s",
2115
                __METHOD__,
2116
                $remoteClass,
2117
                $remoteRelation
2118
            ));
2119
        }
2120
        // If relation is polymorphic, do not infer recriprocal relationship
2121
        if ($class === self::class) {
2122
            return null;
2123
        }
2124
        if (!is_a($this, $class ?? '', true)) {
2125
            throw new InvalidArgumentException(sprintf(
2126
                "Relation %s on %s does not refer to objects of type %s",
2127
                $remoteRelation,
2128
                $remoteClass,
2129
                static::class
2130
            ));
2131
        }
2132
2133
        // Check the relation type to mock
2134
        $relationType = $remote->getRelationType($remoteRelation);
2135
        switch ($relationType) {
2136
            case 'has_one': {
2137
                // Mock has_many
2138
                $joinField = "{$remoteRelation}ID";
2139
                $componentClass = $schema->classForField($remoteClass, $joinField);
2140
                $result = HasManyList::create($componentClass, $joinField);
2141
                return $result
2142
                    ->setDataQueryParam($this->getInheritableQueryParams())
2143
                    ->forForeignID($this->ID);
2144
            }
2145
            case 'belongs_to':
2146
            case 'has_many': {
2147
                // These relations must have a has_one on the other end, so find it
2148
                $joinField = $schema->getRemoteJoinField(
2149
                    $remoteClass,
2150
                    $remoteRelation,
2151
                    $relationType,
2152
                    $polymorphic
2153
                );
2154
                // If relation is polymorphic, do not infer recriprocal relationship automatically
2155
                if ($polymorphic) {
2156
                    return null;
2157
                }
2158
                $joinID = $this->getField($joinField);
2159
                if (empty($joinID)) {
2160
                    return null;
2161
                }
2162
                // Get object by joined ID
2163
                return DataObject::get($remoteClass)
2164
                    ->filter('ID', $joinID)
2165
                    ->setDataQueryParam($this->getInheritableQueryParams())
2166
                    ->first();
2167
            }
2168
            case 'many_many':
2169
            case 'belongs_many_many': {
2170
                // Get components and extra fields from parent
2171
                $manyMany = $remote->getSchema()->manyManyComponent($remoteClass, $remoteRelation);
2172
                $extraFields = $schema->manyManyExtraFieldsForComponent($remoteClass, $remoteRelation) ?: [];
2173
2174
                // Reverse parent and component fields and create an inverse ManyManyList
2175
                /** @var RelationList $result */
2176
                $result = Injector::inst()->create(
2177
                    $manyMany['relationClass'],
2178
                    $manyMany['parentClass'], // Substitute parent class for dataClass
2179
                    $manyMany['join'],
2180
                    $manyMany['parentField'], // Reversed parent / child field
2181
                    $manyMany['childField'], // Reversed parent / child field
2182
                    $extraFields,
2183
                    $manyMany['childClass'], // substitute child class for parentClass
2184
                    $remoteClass // In case ManyManyThroughList needs to use PolymorphicHasManyList internally
2185
                );
2186
                $this->extend('updateManyManyComponents', $result);
2187
2188
                // If this is called on a singleton, then we return an 'orphaned relation' that can have the
2189
                // foreignID set elsewhere.
2190
                return $result
2191
                    ->setDataQueryParam($this->getInheritableQueryParams())
2192
                    ->forForeignID($this->ID);
2193
            }
2194
            default: {
2195
                return null;
2196
            }
2197
        }
2198
    }
2199
2200
    /**
2201
     * Returns a many-to-many component, as a ManyManyList.
2202
     * @param string $componentName Name of the many-many component
2203
     * @param int|array $id Optional ID for parent of this relation, if not the current record
2204
     * @return ManyManyList|UnsavedRelationList The set of components
2205
     */
2206
    public function getManyManyComponents($componentName, $id = null)
2207
    {
2208
        if (!isset($id)) {
2209
            $id = $this->ID;
2210
        }
2211
        $schema = static::getSchema();
2212
        $manyManyComponent = $schema->manyManyComponent(static::class, $componentName);
2213
        if (!$manyManyComponent) {
2214
            throw new InvalidArgumentException(sprintf(
2215
                "DataObject::getComponents(): Unknown many-to-many component '%s' on class '%s'",
2216
                $componentName,
2217
                static::class
2218
            ));
2219
        }
2220
2221
        // If we haven't been written yet, we can't save these relations, so use a list that handles this case
2222
        if (!$id) {
2223
            if (!isset($this->unsavedRelations[$componentName])) {
2224
                $this->unsavedRelations[$componentName] = new UnsavedRelationList(
2225
                    $manyManyComponent['parentClass'],
2226
                    $componentName,
2227
                    $manyManyComponent['childClass']
2228
                );
2229
            }
2230
            return $this->unsavedRelations[$componentName];
2231
        }
2232
2233
        $extraFields = $schema->manyManyExtraFieldsForComponent(static::class, $componentName) ?: [];
2234
        /** @var RelationList $result */
2235
        $result = Injector::inst()->create(
2236
            $manyManyComponent['relationClass'],
2237
            $manyManyComponent['childClass'],
2238
            $manyManyComponent['join'],
2239
            $manyManyComponent['childField'],
2240
            $manyManyComponent['parentField'],
2241
            $extraFields,
2242
            $manyManyComponent['parentClass'],
2243
            static::class // In case ManyManyThroughList needs to use PolymorphicHasManyList internally
2244
        );
2245
2246
        // Store component data in query meta-data
2247
        $result = $result->alterDataQuery(function ($query) use ($extraFields) {
2248
            /** @var DataQuery $query */
2249
            $query->setQueryParam('Component.ExtraFields', $extraFields);
2250
        });
2251
2252
        // If we have a default sort set for our "join" then we should overwrite any default already set.
2253
        $joinSort = Config::inst()->get($manyManyComponent['join'], 'default_sort');
2254
        if (!empty($joinSort)) {
2255
            $result = $result->sort($joinSort);
2256
        }
2257
2258
        $this->extend('updateManyManyComponents', $result);
2259
2260
        // If this is called on a singleton, then we return an 'orphaned relation' that can have the
2261
        // foreignID set elsewhere.
2262
        return $result
2263
            ->setDataQueryParam($this->getInheritableQueryParams())
2264
            ->forForeignID($id);
2265
    }
2266
2267
    /**
2268
     * Return the class of a one-to-one component.  If $component is null, return all of the one-to-one components and
2269
     * their classes. If the selected has_one is a polymorphic field then 'DataObject' will be returned for the type.
2270
     *
2271
     * @return string|array The class of the one-to-one component, or an array of all one-to-one components and
2272
     *                          their classes.
2273
     */
2274
    public function hasOne()
2275
    {
2276
        return (array)$this->config()->get('has_one');
2277
    }
2278
2279
    /**
2280
     * Returns the class of a remote belongs_to relationship. If no component is specified a map of all components and
2281
     * their class name will be returned.
2282
     *
2283
     * @param bool $classOnly If this is TRUE, than any has_many relationships in the form "ClassName.Field" will have
2284
     *        the field data stripped off. It defaults to TRUE.
2285
     * @return string|array
2286
     */
2287
    public function belongsTo($classOnly = true)
2288
    {
2289
        $belongsTo = (array)$this->config()->get('belongs_to');
2290
        if ($belongsTo && $classOnly) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $belongsTo 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...
2291
            return preg_replace('/(.+)?\..+/', '$1', $belongsTo ?? '');
2292
        } else {
2293
            return $belongsTo ? $belongsTo : [];
2294
        }
2295
    }
2296
2297
    /**
2298
     * Gets the class of a one-to-many relationship. If no $component is specified then an array of all the one-to-many
2299
     * relationships and their classes will be returned.
2300
     *
2301
     * @param bool $classOnly If this is TRUE, than any has_many relationships in the form "ClassName.Field" will have
2302
     *        the field data stripped off. It defaults to TRUE.
2303
     * @return string|array|false
2304
     */
2305
    public function hasMany($classOnly = true)
2306
    {
2307
        $hasMany = (array)$this->config()->get('has_many');
2308
        if ($hasMany && $classOnly) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $hasMany 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...
2309
            return preg_replace('/(.+)?\..+/', '$1', $hasMany ?? '');
2310
        } else {
2311
            return $hasMany ? $hasMany : [];
2312
        }
2313
    }
2314
2315
    /**
2316
     * Return the many-to-many extra fields specification.
2317
     *
2318
     * If you don't specify a component name, it returns all
2319
     * extra fields for all components available.
2320
     *
2321
     * @return array|null
2322
     */
2323
    public function manyManyExtraFields()
2324
    {
2325
        return $this->config()->get('many_many_extraFields');
2326
    }
2327
2328
    /**
2329
     * Return information about a many-to-many component.
2330
     * The return value is an array of (parentclass, childclass).  If $component is null, then all many-many
2331
     * components are returned.
2332
     *
2333
     * @see DataObjectSchema::manyManyComponent()
2334
     * @return array|null An array of (parentclass, childclass), or an array of all many-many components
2335
     */
2336
    public function manyMany()
2337
    {
2338
        $config = $this->config();
2339
        $manyManys = (array)$config->get('many_many');
2340
        $belongsManyManys = (array)$config->get('belongs_many_many');
2341
        $items = array_merge($manyManys, $belongsManyManys);
2342
        return $items;
2343
    }
2344
2345
    /**
2346
     * This returns an array (if it exists) describing the database extensions that are required, or false if none
2347
     *
2348
     * This is experimental, and is currently only a Postgres-specific enhancement.
2349
     *
2350
     * @param string $class
2351
     * @return array|false
2352
     */
2353
    public function database_extensions($class)
2354
    {
2355
        $extensions = Config::inst()->get($class, 'database_extensions', Config::UNINHERITED);
2356
        if ($extensions) {
2357
            return $extensions;
2358
        } else {
2359
            return false;
2360
        }
2361
    }
2362
2363
    /**
2364
     * Generates a SearchContext to be used for building and processing
2365
     * a generic search form for properties on this object.
2366
     *
2367
     * @return SearchContext
2368
     */
2369
    public function getDefaultSearchContext()
2370
    {
2371
        return SearchContext::create(
2372
            static::class,
2373
            $this->scaffoldSearchFields(),
2374
            $this->defaultSearchFilters()
2375
        );
2376
    }
2377
2378
    /**
2379
     * Name of the field which is used as a stand-in for searching across all searchable fields.
2380
     *
2381
     * If this is a blank string, global primary search functionality is disabled
2382
     * and the primary search field falls back to using the first field in
2383
     * the searchable fields array.
2384
     */
2385
    public function primarySearchFieldName(): string
2386
    {
2387
        return $this->config()->get('primary_search_field_name');
2388
    }
2389
2390
    /**
2391
     * Determine which properties on the DataObject are
2392
     * searchable, and map them to their default {@link FormField}
2393
     * representations. Used for scaffolding a searchform for {@link ModelAdmin}.
2394
     *
2395
     * Some additional logic is included for switching field labels, based on
2396
     * how generic or specific the field type is.
2397
     *
2398
     * Used by {@link SearchContext}.
2399
     *
2400
     * @param array $_params
2401
     *   'fieldClasses': Associative array of field names as keys and FormField classes as values
2402
     *   'restrictFields': Numeric array of a field name whitelist
2403
     * @return FieldList
2404
     */
2405
    public function scaffoldSearchFields($_params = null)
2406
    {
2407
        $params = array_merge(
2408
            [
2409
                'fieldClasses' => false,
2410
                'restrictFields' => false
2411
            ],
2412
            (array)$_params
2413
        );
2414
        $fields = new FieldList();
2415
2416
        foreach ($this->searchableFields() as $fieldName => $spec) {
2417
            if ($params['restrictFields'] && !in_array($fieldName, $params['restrictFields'] ?? [])) {
2418
                continue;
2419
            }
2420
2421
            // If a custom fieldclass is provided as a string, use it
2422
            $field = null;
2423
            if ($params['fieldClasses'] && isset($params['fieldClasses'][$fieldName])) {
2424
                $fieldClass = $params['fieldClasses'][$fieldName];
2425
                $field = new $fieldClass($fieldName);
2426
            // If we explicitly set a field, then construct that
2427
            } elseif (isset($spec['field'])) {
2428
                // If it's a string, use it as a class name and construct
2429
                if (is_string($spec['field'])) {
2430
                    $fieldClass = $spec['field'];
2431
                    $field = new $fieldClass($fieldName);
2432
2433
                // If it's a FormField object, then just use that object directly.
2434
                } elseif ($spec['field'] instanceof FormField) {
2435
                    $field = $spec['field'];
2436
2437
                // Otherwise we have a bug
2438
                } else {
2439
                    user_error("Bad value for searchable_fields, 'field' value: "
2440
                        . var_export($spec['field'], true), E_USER_WARNING);
2441
                }
2442
2443
            // Otherwise, use the database field's scaffolder
2444
            } elseif ($object = $this->relObject($fieldName)) {
2445
                if (is_object($object) && $object->hasMethod('scaffoldSearchField')) {
2446
                    $field = $object->scaffoldSearchField();
2447
                } else {
2448
                    throw new Exception(sprintf(
2449
                        "SearchField '%s' on '%s' does not return a valid DBField instance.",
2450
                        $fieldName,
2451
                        get_class($this)
2452
                    ));
2453
                }
2454
            }
2455
2456
            // Allow fields to opt out of search
2457
            if (!$field) {
2458
                continue;
2459
            }
2460
2461
            if (strstr($fieldName ?? '', '.')) {
2462
                $field->setName(str_replace('.', '__', $fieldName ?? ''));
2463
            }
2464
            $field->setTitle($spec['title']);
2465
2466
            $fields->push($field);
2467
        }
2468
2469
        // Only include global primary search if there are fields it can search on
2470
        $primarySearch = $this->primarySearchFieldName();
2471
        if ($primarySearch !== '' && $fields->count() > 0) {
2472
            if ($fields->fieldByName($primarySearch) || $fields->dataFieldByName($primarySearch)) {
0 ignored issues
show
Bug introduced by
Are you sure the usage of $fields->fieldByName($primarySearch) targeting SilverStripe\Forms\FieldList::fieldByName() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
2473
                throw new LogicException('Primary search field name must be unique.');
2474
            }
2475
            $fields->unshift(HiddenField::create($primarySearch, _t(self::class . 'PRIMARYSEARCH', 'Primary Search')));
2476
        }
2477
2478
        return $fields;
2479
    }
2480
2481
    /**
2482
     * Scaffold a simple edit form for all properties on this dataobject,
2483
     * based on default {@link FormField} mapping in {@link DBField::scaffoldFormField()}.
2484
     * Field labels/titles will be auto generated from {@link DataObject::fieldLabels()}.
2485
     *
2486
     * @uses FormScaffolder
2487
     *
2488
     * @param array $_params Associative array passing through properties to {@link FormScaffolder}.
2489
     * @return FieldList
2490
     */
2491
    public function scaffoldFormFields($_params = null)
2492
    {
2493
        $params = array_merge(
2494
            [
2495
                'tabbed' => false,
2496
                'includeRelations' => false,
2497
                'restrictFields' => false,
2498
                'fieldClasses' => false,
2499
                'ajaxSafe' => false
2500
            ],
2501
            (array)$_params
2502
        );
2503
2504
        $fs = FormScaffolder::create($this);
2505
        $fs->tabbed = $params['tabbed'];
2506
        $fs->includeRelations = $params['includeRelations'];
2507
        $fs->restrictFields = $params['restrictFields'];
2508
        $fs->fieldClasses = $params['fieldClasses'];
2509
        $fs->ajaxSafe = $params['ajaxSafe'];
2510
2511
        $this->extend('updateFormScaffolder', $fs, $this);
2512
2513
        return $fs->getFieldList();
2514
    }
2515
2516
    /**
2517
     * Allows user code to hook into DataObject::getCMSFields prior to updateCMSFields
2518
     * being called on extensions
2519
     *
2520
     * @param callable $callback The callback to execute
2521
     */
2522
    protected function beforeUpdateCMSFields($callback)
2523
    {
2524
        $this->beforeExtending('updateCMSFields', $callback);
2525
    }
2526
2527
    /**
2528
     * Allows user code to hook into DataObject::getCMSFields after updateCMSFields
2529
     * being called on extensions
2530
     *
2531
     * @param callable $callback The callback to execute
2532
     */
2533
    protected function afterUpdateCMSFields(callable $callback)
2534
    {
2535
        $this->afterExtending('updateCMSFields', $callback);
2536
    }
2537
2538
    /**
2539
     * Centerpiece of every data administration interface in Silverstripe,
2540
     * which returns a {@link FieldList} suitable for a {@link Form} object.
2541
     * If not overloaded, we're using {@link scaffoldFormFields()} to automatically
2542
     * generate this set. To customize, overload this method in a subclass
2543
     * or extended onto it by using {@link DataExtension->updateCMSFields()}.
2544
     *
2545
     * <code>
2546
     * class MyCustomClass extends DataObject {
2547
     *  static $db = array('CustomProperty'=>'Boolean');
2548
     *
2549
     *  function getCMSFields() {
2550
     *    $fields = parent::getCMSFields();
2551
     *    $fields->addFieldToTab('Root.Content',new CheckboxField('CustomProperty'));
2552
     *    return $fields;
2553
     *  }
2554
     * }
2555
     * </code>
2556
     *
2557
     * @see Good example of complex FormField building: SiteTree::getCMSFields()
2558
     *
2559
     * @return FieldList Returns a TabSet for usage within the CMS - don't use for frontend forms.
2560
     */
2561
    public function getCMSFields()
2562
    {
2563
        $tabbedFields = $this->scaffoldFormFields([
2564
            // Don't allow has_many/many_many relationship editing before the record is first saved
2565
            'includeRelations' => ($this->ID > 0),
2566
            'tabbed' => true,
2567
            'ajaxSafe' => true
2568
        ]);
2569
2570
        $this->extend('updateCMSFields', $tabbedFields);
2571
2572
        return $tabbedFields;
2573
    }
2574
2575
    /**
2576
     * need to be overload by solid dataobject, so that the customised actions of that dataobject,
2577
     * including that dataobject's extensions customised actions could be added to the EditForm.
2578
     *
2579
     * @return FieldList an Empty FieldList(); need to be overload by solid subclass
2580
     */
2581
    public function getCMSActions()
2582
    {
2583
        $actions = new FieldList();
2584
        $this->extend('updateCMSActions', $actions);
2585
        return $actions;
2586
    }
2587
2588
    /**
2589
     * When extending this class and overriding this method, you will need to instantiate the CompositeValidator by
2590
     * calling parent::getCMSCompositeValidator(). This will ensure that the appropriate extension point is also
2591
     * invoked.
2592
     *
2593
     * You can also update the CompositeValidator by creating an Extension and implementing the
2594
     * updateCMSCompositeValidator(CompositeValidator $compositeValidator) method.
2595
     *
2596
     * @see CompositeValidator for examples of implementation
2597
     * @return CompositeValidator
2598
     */
2599
    public function getCMSCompositeValidator(): CompositeValidator
2600
    {
2601
        $compositeValidator = CompositeValidator::create();
2602
2603
        // Support for the old method during the deprecation period
2604
        if ($this->hasMethod('getCMSValidator')) {
2605
            Deprecation::notice(
2606
                '4.6',
2607
                'getCMSValidator() is removed in 5.0 in favour of getCMSCompositeValidator()'
2608
            );
2609
2610
            $compositeValidator->addValidator($this->getCMSValidator());
0 ignored issues
show
Bug introduced by
The method getCMSValidator() does not exist on SilverStripe\ORM\DataObject. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

2610
            $compositeValidator->addValidator($this->/** @scrutinizer ignore-call */ getCMSValidator());
Loading history...
2611
        }
2612
2613
        // Extend validator - forward support, will be supported beyond 5.0.0
2614
        $this->invokeWithExtensions('updateCMSCompositeValidator', $compositeValidator);
2615
2616
        return $compositeValidator;
2617
    }
2618
2619
    /**
2620
     * Used for simple frontend forms without relation editing
2621
     * or {@link TabSet} behaviour. Uses {@link scaffoldFormFields()}
2622
     * by default. To customize, either overload this method in your
2623
     * subclass, or extend it by {@link DataExtension->updateFrontEndFields()}.
2624
     *
2625
     * @todo Decide on naming for "website|frontend|site|page" and stick with it in the API
2626
     *
2627
     * @param array $params See {@link scaffoldFormFields()}
2628
     * @return FieldList Always returns a simple field collection without TabSet.
2629
     */
2630
    public function getFrontEndFields($params = null)
2631
    {
2632
        $untabbedFields = $this->scaffoldFormFields($params);
2633
        $this->extend('updateFrontEndFields', $untabbedFields);
2634
2635
        return $untabbedFields;
2636
    }
2637
2638
    public function getViewerTemplates($suffix = '')
2639
    {
2640
        return SSViewer::get_templates_by_class(static::class, $suffix, $this->baseClass());
2641
    }
2642
2643
    /**
2644
     * Gets the value of a field.
2645
     * Called by {@link __get()} and any getFieldName() methods you might create.
2646
     *
2647
     * @param string $field The name of the field
2648
     * @return mixed The field value
2649
     */
2650
    public function getField($field)
2651
    {
2652
        // If we already have a value in $this->record, then we should just return that
2653
        if (isset($this->record[$field])) {
2654
            return $this->record[$field];
2655
        }
2656
2657
        // Do we have a field that needs to be lazy loaded?
2658
        if (isset($this->record[$field . '_Lazy'])) {
2659
            $tableClass = $this->record[$field . '_Lazy'];
2660
            $this->loadLazyFields($tableClass);
2661
        }
2662
        $schema = static::getSchema();
2663
2664
        // Support unary relations as fields
2665
        if ($schema->unaryComponent(static::class, $field)) {
2666
            return $this->getComponent($field);
2667
        }
2668
2669
        // In case of complex fields, return the DBField object
2670
        if ($schema->compositeField(static::class, $field)) {
2671
            $this->record[$field] = $this->dbObject($field);
2672
        }
2673
2674
        return isset($this->record[$field]) ? $this->record[$field] : null;
2675
    }
2676
2677
    /**
2678
     * Loads all the stub fields that an initial lazy load didn't load fully.
2679
     *
2680
     * @param string $class Class to load the values from. Others are joined as required.
2681
     * Not specifying a tableClass will load all lazy fields from all tables.
2682
     * @return bool Flag if lazy loading succeeded
2683
     */
2684
    protected function loadLazyFields($class = null)
2685
    {
2686
        if (!$this->isInDB() || !is_numeric($this->ID)) {
2687
            return false;
2688
        }
2689
2690
        if (!$class) {
2691
            $loaded = [];
2692
2693
            foreach ($this->record as $key => $value) {
2694
                if (strlen($key ?? '') > 5 && substr($key ?? '', -5) == '_Lazy' && !array_key_exists($value, $loaded ?? [])) {
2695
                    $this->loadLazyFields($value);
2696
                    $loaded[$value] = $value;
2697
                }
2698
            }
2699
2700
            return false;
2701
        }
2702
2703
        $dataQuery = new DataQuery($class);
2704
2705
        // Reset query parameter context to that of this DataObject
2706
        if ($params = $this->getSourceQueryParams()) {
2707
            foreach ($params as $key => $value) {
2708
                $dataQuery->setQueryParam($key, $value);
2709
            }
2710
        }
2711
2712
        // Limit query to the current record, unless it has the Versioned extension,
2713
        // in which case it requires special handling through augmentLoadLazyFields()
2714
        $schema = static::getSchema();
2715
        $baseIDColumn = $schema->sqlColumnForField($this, 'ID');
2716
        $dataQuery->where([
2717
            $baseIDColumn => $this->record['ID']
2718
        ])->limit(1);
2719
2720
        $columns = [];
2721
2722
        // Add SQL for fields, both simple & multi-value
2723
        // TODO: This is copy & pasted from buildSQL(), it could be moved into a method
2724
        $databaseFields = $schema->databaseFields($class, false);
2725
        foreach ($databaseFields as $k => $v) {
2726
            if (!isset($this->record[$k]) || $this->record[$k] === null) {
2727
                $columns[] = $k;
2728
            }
2729
        }
2730
2731
        if ($columns) {
2732
            $query = $dataQuery->query();
2733
            $this->extend('augmentLoadLazyFields', $query, $dataQuery, $this);
2734
            $this->extend('augmentSQL', $query, $dataQuery);
2735
2736
            $dataQuery->setQueriedColumns($columns);
2737
            $newData = $dataQuery->execute()->record();
2738
2739
            // Load the data into record
2740
            if ($newData) {
2741
                foreach ($newData as $k => $v) {
2742
                    if (in_array($k, $columns ?? [])) {
2743
                        $this->record[$k] = $v;
2744
                        $this->original[$k] = $v;
2745
                        unset($this->record[$k . '_Lazy']);
2746
                    }
2747
                }
2748
2749
            // No data means that the query returned nothing; assign 'null' to all the requested fields
2750
            } else {
2751
                foreach ($columns as $k) {
2752
                    $this->record[$k] = null;
2753
                    $this->original[$k] = null;
2754
                    unset($this->record[$k . '_Lazy']);
2755
                }
2756
            }
2757
        }
2758
        return true;
2759
    }
2760
2761
    /**
2762
     * Return the fields that have changed since the last write.
2763
     *
2764
     * The change level affects what the functions defines as "changed":
2765
     * - Level CHANGE_STRICT (integer 1) will return strict changes, even !== ones.
2766
     * - Level CHANGE_VALUE (integer 2) is more lenient, it will only return real data changes,
2767
     *   for example a change from 0 to null would not be included.
2768
     *
2769
     * Example return:
2770
     * <code>
2771
     * array(
2772
     *   'Title' = array('before' => 'Home', 'after' => 'Home-Changed', 'level' => DataObject::CHANGE_VALUE)
2773
     * )
2774
     * </code>
2775
     *
2776
     * @param boolean|array $databaseFieldsOnly Filter to determine which fields to return. Set to true
2777
     * to return all database fields, or an array for an explicit filter. false returns all fields.
2778
     * @param int $changeLevel The strictness of what is defined as change. Defaults to strict
2779
     * @return array
2780
     */
2781
    public function getChangedFields($databaseFieldsOnly = false, $changeLevel = self::CHANGE_STRICT)
2782
    {
2783
        $changedFields = [];
2784
2785
        // Update the changed array with references to changed obj-fields
2786
        foreach ($this->record as $k => $v) {
2787
            // Prevents DBComposite infinite looping on isChanged
2788
            if (is_array($databaseFieldsOnly) && !in_array($k, $databaseFieldsOnly ?? [])) {
2789
                continue;
2790
            }
2791
            if (is_object($v) && method_exists($v, 'isChanged') && $v->isChanged()) {
2792
                $this->changed[$k] = self::CHANGE_VALUE;
2793
            }
2794
        }
2795
2796
        // If change was forced, then derive change data from $this->record
2797
        if ($this->changeForced && $changeLevel <= self::CHANGE_STRICT) {
2798
            $changed = array_combine(
2799
                array_keys($this->record ?? []),
2800
                array_fill(0, count($this->record ?? []), self::CHANGE_STRICT)
2801
            );
2802
            // @todo Find better way to allow versioned to write a new version after forceChange
2803
            unset($changed['Version']);
2804
        } else {
2805
            $changed = $this->changed;
2806
        }
2807
2808
        if (is_array($databaseFieldsOnly)) {
2809
            $fields = array_intersect_key($changed ?? [], array_flip($databaseFieldsOnly ?? []));
2810
        } elseif ($databaseFieldsOnly) {
2811
            $fieldsSpecs = static::getSchema()->fieldSpecs(static::class);
2812
            $fields = array_intersect_key($changed ?? [], $fieldsSpecs);
2813
        } else {
2814
            $fields = $changed;
2815
        }
2816
2817
        // Filter the list to those of a certain change level
2818
        if ($changeLevel > self::CHANGE_STRICT) {
2819
            if ($fields) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $fields 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...
2820
                foreach ($fields as $name => $level) {
2821
                    if ($level < $changeLevel) {
2822
                        unset($fields[$name]);
2823
                    }
2824
                }
2825
            }
2826
        }
2827
2828
        if ($fields) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $fields 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...
2829
            foreach ($fields as $name => $level) {
2830
                $changedFields[$name] = [
2831
                    'before' => array_key_exists($name, $this->original ?? []) ? $this->original[$name] : null,
2832
                    'after' => array_key_exists($name, $this->record ?? []) ? $this->record[$name] : null,
2833
                    'level' => $level
2834
                ];
2835
            }
2836
        }
2837
2838
        return $changedFields;
2839
    }
2840
2841
    /**
2842
     * Uses {@link getChangedFields()} to determine if fields have been changed
2843
     * since loading them from the database.
2844
     *
2845
     * @param string $fieldName Name of the database field to check, will check for any if not given
2846
     * @param int $changeLevel See {@link getChangedFields()}
2847
     * @return boolean
2848
     */
2849
    public function isChanged($fieldName = null, $changeLevel = self::CHANGE_STRICT)
2850
    {
2851
        $fields = $fieldName ? [$fieldName] : true;
2852
        $changed = $this->getChangedFields($fields, $changeLevel);
2853
        if (!isset($fieldName)) {
2854
            return !empty($changed);
2855
        } else {
2856
            return array_key_exists($fieldName, $changed ?? []);
2857
        }
2858
    }
2859
2860
    /**
2861
     * Set the value of the field
2862
     * Called by {@link __set()} and any setFieldName() methods you might create.
2863
     *
2864
     * @param string $fieldName Name of the field
2865
     * @param mixed $val New field value
2866
     * @return $this
2867
     */
2868
    public function setField($fieldName, $val)
2869
    {
2870
        $this->objCacheClear();
2871
        //if it's a has_one component, destroy the cache
2872
        if (substr($fieldName ?? '', -2) == 'ID') {
2873
            unset($this->components[substr($fieldName, 0, -2)]);
2874
        }
2875
2876
        // If we've just lazy-loaded the column, then we need to populate the $original array
2877
        if (isset($this->record[$fieldName . '_Lazy'])) {
2878
            $tableClass = $this->record[$fieldName . '_Lazy'];
2879
            $this->loadLazyFields($tableClass);
2880
        }
2881
2882
        // Support component assignent via field setter
2883
        $schema = static::getSchema();
2884
        if ($schema->unaryComponent(static::class, $fieldName)) {
2885
            unset($this->components[$fieldName]);
2886
            // Assign component directly
2887
            if (is_null($val) || $val instanceof DataObject) {
2888
                return $this->setComponent($fieldName, $val);
2889
            }
2890
            // Assign by ID instead of object
2891
            if (is_numeric($val)) {
2892
                $fieldName .= 'ID';
2893
            }
2894
        }
2895
2896
        // Situation 1: Passing an DBField
2897
        if ($val instanceof DBField) {
2898
            $val->setName($fieldName);
2899
            $val->saveInto($this);
2900
2901
            // Situation 1a: Composite fields should remain bound in case they are
2902
            // later referenced to update the parent dataobject
2903
            if ($val instanceof DBComposite) {
2904
                $val->bindTo($this);
2905
                $this->record[$fieldName] = $val;
2906
            }
2907
        // Situation 2: Passing a literal or non-DBField object
2908
        } else {
2909
            // If this is a proper database field, we shouldn't be getting non-DBField objects
2910
            if (is_object($val) && $schema->fieldSpec(static::class, $fieldName)) {
2911
                throw new InvalidArgumentException('DataObject::setField: passed an object that is not a DBField');
2912
            }
2913
2914
            if (!empty($val) && !is_scalar($val)) {
2915
                $dbField = $this->dbObject($fieldName);
2916
                if ($dbField && $dbField->scalarValueOnly()) {
2917
                    throw new InvalidArgumentException(
2918
                        sprintf(
2919
                            'DataObject::setField: %s only accepts scalars',
2920
                            $fieldName
2921
                        )
2922
                    );
2923
                }
2924
            }
2925
2926
            // if a field is not existing or has strictly changed
2927
            if (!array_key_exists($fieldName, $this->original ?? []) || $this->original[$fieldName] !== $val) {
2928
                // TODO Add check for php-level defaults which are not set in the db
2929
                // TODO Add check for hidden input-fields (readonly) which are not set in the db
2930
                // At the very least, the type has changed
2931
                $this->changed[$fieldName] = self::CHANGE_STRICT;
2932
2933
                if ((!array_key_exists($fieldName, $this->original ?? []) && $val)
2934
                    || (array_key_exists($fieldName, $this->original ?? []) && $this->original[$fieldName] != $val)
2935
                ) {
2936
                    // Value has changed as well, not just the type
2937
                    $this->changed[$fieldName] = self::CHANGE_VALUE;
2938
                }
2939
            // Value has been restored to its original, remove any record of the change
2940
            } elseif (isset($this->changed[$fieldName])) {
2941
                unset($this->changed[$fieldName]);
2942
            }
2943
2944
            // Value is saved regardless, since the change detection relates to the last write
2945
            $this->record[$fieldName] = $val;
2946
        }
2947
        return $this;
2948
    }
2949
2950
    /**
2951
     * Set the value of the field, using a casting object.
2952
     * This is useful when you aren't sure that a date is in SQL format, for example.
2953
     * setCastedField() can also be used, by forms, to set related data.  For example, uploaded images
2954
     * can be saved into the Image table.
2955
     *
2956
     * @param string $fieldName Name of the field
2957
     * @param mixed $value New field value
2958
     * @return $this
2959
     */
2960
    public function setCastedField($fieldName, $value)
2961
    {
2962
        if (!$fieldName) {
2963
            throw new InvalidArgumentException("DataObject::setCastedField: Called without a fieldName");
2964
        }
2965
        $fieldObj = $this->dbObject($fieldName);
2966
        if ($fieldObj) {
0 ignored issues
show
introduced by
$fieldObj is of type SilverStripe\ORM\FieldType\DBField, thus it always evaluated to true.
Loading history...
2967
            $fieldObj->setValue($value);
2968
            $fieldObj->saveInto($this);
2969
        } else {
2970
            $this->$fieldName = $value;
2971
        }
2972
        return $this;
2973
    }
2974
2975
    /**
2976
     * {@inheritdoc}
2977
     */
2978
    public function castingHelper($field)
2979
    {
2980
        $fieldSpec = static::getSchema()->fieldSpec(static::class, $field);
2981
        if ($fieldSpec) {
2982
            return $fieldSpec;
2983
        }
2984
2985
        // many_many_extraFields aren't presented by db(), so we check if the source query params
2986
        // provide us with meta-data for a many_many relation we can inspect for extra fields.
2987
        $queryParams = $this->getSourceQueryParams();
2988
        if (!empty($queryParams['Component.ExtraFields'])) {
2989
            $extraFields = $queryParams['Component.ExtraFields'];
2990
2991
            if (isset($extraFields[$field])) {
2992
                return $extraFields[$field];
2993
            }
2994
        }
2995
2996
        return parent::castingHelper($field);
2997
    }
2998
2999
    /**
3000
     * Returns true if the given field exists in a database column on any of
3001
     * the objects tables and optionally look up a dynamic getter with
3002
     * get<fieldName>().
3003
     *
3004
     * @param string $field Name of the field
3005
     * @return boolean True if the given field exists
3006
     */
3007
    public function hasField($field)
3008
    {
3009
        $schema = static::getSchema();
3010
        return (
3011
            array_key_exists($field, $this->record ?? [])
3012
            || array_key_exists($field, $this->components ?? [])
3013
            || $schema->fieldSpec(static::class, $field)
3014
            || $schema->unaryComponent(static::class, $field)
3015
            || $this->hasMethod("get{$field}")
3016
        );
3017
    }
3018
3019
    /**
3020
     * Returns true if the given field exists as a database column
3021
     *
3022
     * @param string $field Name of the field
3023
     *
3024
     * @return boolean
3025
     */
3026
    public function hasDatabaseField($field)
3027
    {
3028
        $spec = static::getSchema()->fieldSpec(static::class, $field, DataObjectSchema::DB_ONLY);
3029
        return !empty($spec);
3030
    }
3031
3032
    /**
3033
     * Returns true if the member is allowed to do the given action.
3034
     * See {@link extendedCan()} for a more versatile tri-state permission control.
3035
     *
3036
     * @param string $perm The permission to be checked, such as 'View'.
3037
     * @param Member $member The member whose permissions need checking.  Defaults to the currently logged
3038
     * in user.
3039
     * @param array $context Additional $context to pass to extendedCan()
3040
     *
3041
     * @return boolean True if the the member is allowed to do the given action
3042
     */
3043
    public function can($perm, $member = null, $context = [])
3044
    {
3045
        if (!$member) {
3046
            $member = Security::getCurrentUser();
3047
        }
3048
3049
        if ($member && Permission::checkMember($member, "ADMIN")) {
3050
            return true;
3051
        }
3052
3053
        if (is_string($perm) && method_exists($this, 'can' . ucfirst($perm ?? ''))) {
3054
            $method = 'can' . ucfirst($perm ?? '');
3055
            return $this->$method($member);
3056
        }
3057
3058
        $results = $this->extendedCan('can', $member);
3059
        if (isset($results)) {
3060
            return $results;
3061
        }
3062
3063
        return ($member && Permission::checkMember($member, $perm));
3064
    }
3065
3066
    /**
3067
     * Process tri-state responses from permission-alterting extensions.  The extensions are
3068
     * expected to return one of three values:
3069
     *
3070
     *  - false: Disallow this permission, regardless of what other extensions say
3071
     *  - true: Allow this permission, as long as no other extensions return false
3072
     *  - NULL: Don't affect the outcome
3073
     *
3074
     * This method itself returns a tri-state value, and is designed to be used like this:
3075
     *
3076
     * <code>
3077
     * $extended = $this->extendedCan('canDoSomething', $member);
3078
     * if ($extended !== null) return $extended;
3079
     * else return $normalValue;
3080
     * </code>
3081
     *
3082
     * @param string $methodName Method on the same object, e.g. {@link canEdit()}
3083
     * @param Member|int $member
3084
     * @param array $context Optional context
3085
     * @return boolean|null
3086
     */
3087
    public function extendedCan($methodName, $member, $context = [])
3088
    {
3089
        $results = $this->extend($methodName, $member, $context);
3090
        if ($results && is_array($results)) {
3091
            // Remove NULLs
3092
            $results = array_filter($results ?? [], function ($v) {
3093
                return !is_null($v);
3094
            });
3095
            // If there are any non-NULL responses, then return the lowest one of them.
3096
            // If any explicitly deny the permission, then we don't get access
3097
            if ($results) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $results 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...
3098
                return min($results);
3099
            }
3100
        }
3101
        return null;
3102
    }
3103
3104
    /**
3105
     * @param Member $member
3106
     * @return boolean
3107
     */
3108
    public function canView($member = null)
3109
    {
3110
        $extended = $this->extendedCan(__FUNCTION__, $member);
3111
        if ($extended !== null) {
3112
            return $extended;
3113
        }
3114
        return Permission::check('ADMIN', 'any', $member);
3115
    }
3116
3117
    /**
3118
     * @param Member $member
3119
     * @return boolean
3120
     */
3121
    public function canEdit($member = null)
3122
    {
3123
        $extended = $this->extendedCan(__FUNCTION__, $member);
3124
        if ($extended !== null) {
3125
            return $extended;
3126
        }
3127
        return Permission::check('ADMIN', 'any', $member);
3128
    }
3129
3130
    /**
3131
     * @param Member $member
3132
     * @return boolean
3133
     */
3134
    public function canDelete($member = null)
3135
    {
3136
        $extended = $this->extendedCan(__FUNCTION__, $member);
3137
        if ($extended !== null) {
3138
            return $extended;
3139
        }
3140
        return Permission::check('ADMIN', 'any', $member);
3141
    }
3142
3143
    /**
3144
     * @param Member $member
3145
     * @param array $context Additional context-specific data which might
3146
     * affect whether (or where) this object could be created.
3147
     * @return boolean
3148
     */
3149
    public function canCreate($member = null, $context = [])
3150
    {
3151
        $extended = $this->extendedCan(__FUNCTION__, $member, $context);
3152
        if ($extended !== null) {
3153
            return $extended;
3154
        }
3155
        return Permission::check('ADMIN', 'any', $member);
3156
    }
3157
3158
    /**
3159
     * Debugging used by Debug::show()
3160
     *
3161
     * @return string HTML data representing this object
3162
     */
3163
    public function debug()
3164
    {
3165
        $class = static::class;
3166
        $val = "<h3>Database record: {$class}</h3>\n<ul>\n";
3167
        if ($this->record) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->record 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...
3168
            foreach ($this->record as $fieldName => $fieldVal) {
3169
                $val .= "\t<li>$fieldName: " . Debug::text($fieldVal) . "</li>\n";
3170
            }
3171
        }
3172
        $val .= "</ul>\n";
3173
        return $val;
3174
    }
3175
3176
    /**
3177
     * Return the DBField object that represents the given field.
3178
     * This works similarly to obj() with 2 key differences:
3179
     *   - it still returns an object even when the field has no value.
3180
     *   - it only matches fields and not methods
3181
     *   - it matches foreign keys generated by has_one relationships, eg, "ParentID"
3182
     *
3183
     * @param string $fieldName Name of the field
3184
     * @return DBField The field as a DBField object
3185
     */
3186
    public function dbObject($fieldName)
3187
    {
3188
        // Check for field in DB
3189
        $schema = static::getSchema();
3190
        $helper = $schema->fieldSpec(static::class, $fieldName, DataObjectSchema::INCLUDE_CLASS);
3191
        if (!$helper) {
3192
            return null;
3193
        }
3194
3195
        if (!isset($this->record[$fieldName]) && isset($this->record[$fieldName . '_Lazy'])) {
3196
            $tableClass = $this->record[$fieldName . '_Lazy'];
3197
            $this->loadLazyFields($tableClass);
3198
        }
3199
3200
        $value = isset($this->record[$fieldName])
3201
            ? $this->record[$fieldName]
3202
            : null;
3203
3204
        // If we have a DBField object in $this->record, then return that
3205
        if ($value instanceof DBField) {
3206
            return $value;
3207
        }
3208
3209
        $pos = strpos($helper ?? '', '.');
3210
        $class = substr($helper ?? '', 0, $pos);
3211
        $spec = substr($helper ?? '', $pos + 1);
3212
3213
        /** @var DBField $obj */
3214
        $table = $schema->tableName($class);
3215
        $obj = Injector::inst()->create($spec, $fieldName);
3216
        $obj->setTable($table);
3217
        $obj->setValue($value, $this, false);
3218
        return $obj;
3219
    }
3220
3221
    /**
3222
     * Traverses to a DBField referenced by relationships between data objects.
3223
     *
3224
     * The path to the related field is specified with dot separated syntax
3225
     * (eg: Parent.Child.Child.FieldName).
3226
     *
3227
     * If a relation is blank, this will return null instead.
3228
     * If a relation name is invalid (e.g. non-relation on a parent) this
3229
     * can throw a LogicException.
3230
     *
3231
     * @param string $fieldPath List of paths on this object. All items in this path
3232
     * must be ViewableData implementors
3233
     *
3234
     * @return mixed DBField of the field on the object or a DataList instance.
3235
     * @throws LogicException If accessing invalid relations
3236
     */
3237
    public function relObject($fieldPath)
3238
    {
3239
        $object = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $object is dead and can be removed.
Loading history...
3240
        $component = $this;
3241
3242
        // Parse all relations
3243
        foreach (explode('.', $fieldPath ?? '') as $relation) {
3244
            if (!$component) {
3245
                return null;
3246
            }
3247
3248
            // Inspect relation type
3249
            if (ClassInfo::hasMethod($component, $relation)) {
3250
                $component = $component->$relation();
3251
            } elseif ($component instanceof Relation || $component instanceof DataList) {
3252
                // $relation could either be a field (aggregate), or another relation
3253
                $singleton = DataObject::singleton($component->dataClass());
0 ignored issues
show
Bug introduced by
The method dataClass() does not exist on SilverStripe\ORM\DataObject. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

3253
                $singleton = DataObject::singleton($component->/** @scrutinizer ignore-call */ dataClass());
Loading history...
3254
                $component = $singleton->dbObject($relation) ?: $component->relation($relation);
0 ignored issues
show
Bug introduced by
The method relation() does not exist on SilverStripe\ORM\DataObject. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

3254
                $component = $singleton->dbObject($relation) ?: $component->/** @scrutinizer ignore-call */ relation($relation);
Loading history...
3255
            } elseif ($component instanceof DataObject && ($dbObject = $component->dbObject($relation))) {
3256
                $component = $dbObject;
3257
            } elseif ($component instanceof ViewableData && $component->hasField($relation)) {
3258
                $component = $component->obj($relation);
3259
            } else {
3260
                throw new LogicException(
3261
                    "$relation is not a relation/field on " . get_class($component)
3262
                );
3263
            }
3264
        }
3265
        return $component;
3266
    }
3267
3268
    /**
3269
     * Traverses to a field referenced by relationships between data objects, returning the value
3270
     * The path to the related field is specified with dot separated syntax (eg: Parent.Child.Child.FieldName)
3271
     *
3272
     * @param string $fieldName string
3273
     * @return mixed Will return null on a missing value
3274
     */
3275
    public function relField($fieldName)
3276
    {
3277
        // Navigate to relative parent using relObject() if needed
3278
        $component = $this;
3279
        if (($pos = strrpos($fieldName ?? '', '.')) !== false) {
3280
            $relation = substr($fieldName ?? '', 0, $pos);
3281
            $fieldName = substr($fieldName ?? '', $pos + 1);
3282
            $component = $this->relObject($relation);
3283
        }
3284
3285
        // Bail if the component is null
3286
        if (!$component) {
3287
            return null;
3288
        }
3289
        if (ClassInfo::hasMethod($component, $fieldName)) {
3290
            return $component->$fieldName();
3291
        }
3292
        return $component->$fieldName;
3293
    }
3294
3295
    /**
3296
     * Temporary hack to return an association name, based on class, to get around the mangle
3297
     * of having to deal with reverse lookup of relationships to determine autogenerated foreign keys.
3298
     *
3299
     * @param string $className
3300
     * @return string
3301
     */
3302
    public function getReverseAssociation($className)
3303
    {
3304
        if (is_array($this->manyMany())) {
0 ignored issues
show
introduced by
The condition is_array($this->manyMany()) is always true.
Loading history...
3305
            $many_many = array_flip($this->manyMany() ?? []);
3306
            if (array_key_exists($className, $many_many ?? [])) {
3307
                return $many_many[$className];
3308
            }
3309
        }
3310
        if (is_array($this->hasMany())) {
3311
            $has_many = array_flip($this->hasMany() ?? []);
3312
            if (array_key_exists($className, $has_many ?? [])) {
3313
                return $has_many[$className];
3314
            }
3315
        }
3316
        if (is_array($this->hasOne())) {
0 ignored issues
show
introduced by
The condition is_array($this->hasOne()) is always true.
Loading history...
3317
            $has_one = array_flip($this->hasOne() ?? []);
3318
            if (array_key_exists($className, $has_one ?? [])) {
3319
                return $has_one[$className];
3320
            }
3321
        }
3322
3323
        return false;
3324
    }
3325
3326
    /**
3327
     * Return all objects matching the filter
3328
     * sub-classes are automatically selected and included
3329
     *
3330
     * @param string $callerClass The class of objects to be returned
3331
     * @param string|array $filter A filter to be inserted into the WHERE clause.
3332
     * Supports parameterised queries. See SQLSelect::addWhere() for syntax examples.
3333
     * @param string|array $sort A sort expression to be inserted into the ORDER
3334
     * BY clause.  If omitted, self::$default_sort will be used.
3335
     * @param string $join Deprecated 3.0 Join clause. Use leftJoin($table, $joinClause) instead.
3336
     * @param string|array $limit A limit expression to be inserted into the LIMIT clause.
3337
     * @param string $containerClass The container class to return the results in.
3338
     *
3339
     * @todo $containerClass is Ignored, why?
3340
     *
3341
     * @return DataList The objects matching the filter, in the class specified by $containerClass
3342
     */
3343
    public static function get(
3344
        $callerClass = null,
3345
        $filter = "",
3346
        $sort = "",
3347
        $join = "",
3348
        $limit = null,
3349
        $containerClass = DataList::class
3350
    ) {
3351
        // Validate arguments
3352
        if ($callerClass == null) {
0 ignored issues
show
Bug introduced by
It seems like you are loosely comparing $callerClass of type null|string against null; this is ambiguous if the string can be empty. Consider using a strict comparison === instead.
Loading history...
3353
            $callerClass = get_called_class();
3354
            if ($callerClass === self::class) {
3355
                throw new InvalidArgumentException('Call <classname>::get() instead of DataObject::get()');
3356
            }
3357
            if ($filter || $sort || $join || $limit || ($containerClass !== DataList::class)) {
3358
                throw new InvalidArgumentException('If calling <classname>::get() then you shouldn\'t pass any other'
3359
                    . ' arguments');
3360
            }
3361
        } elseif ($callerClass === self::class) {
3362
            throw new InvalidArgumentException('DataObject::get() cannot query non-subclass DataObject directly');
3363
        }
3364
        if ($join) {
3365
            throw new InvalidArgumentException(
3366
                'The $join argument has been removed. Use leftJoin($table, $joinClause) instead.'
3367
            );
3368
        }
3369
3370
        // Build and decorate with args
3371
        $result = DataList::create($callerClass);
3372
        if ($filter) {
3373
            $result = $result->where($filter);
3374
        }
3375
        if ($sort) {
3376
            $result = $result->sort($sort);
3377
        }
3378
        if ($limit && strpos($limit ?? '', ',') !== false) {
0 ignored issues
show
Bug introduced by
It seems like $limit ?? '' can also be of type array and null; however, parameter $haystack of strpos() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

3378
        if ($limit && strpos(/** @scrutinizer ignore-type */ $limit ?? '', ',') !== false) {
Loading history...
3379
            $limitArguments = explode(',', $limit ?? '');
0 ignored issues
show
Bug introduced by
It seems like $limit ?? '' can also be of type array and null; however, parameter $string of explode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

3379
            $limitArguments = explode(',', /** @scrutinizer ignore-type */ $limit ?? '');
Loading history...
3380
            $result = $result->limit($limitArguments[1], $limitArguments[0]);
3381
        } elseif ($limit) {
3382
            $result = $result->limit($limit);
3383
        }
3384
3385
        return $result;
3386
    }
3387
3388
3389
    /**
3390
     * Return the first item matching the given query.
3391
     *
3392
     * The object returned is cached, unlike DataObject::get()->first() {@link DataList::first()}
3393
     * and DataObject::get()->last() {@link DataList::last()}
3394
     *
3395
     * The filter argument supports parameterised queries (see SQLSelect::addWhere() for syntax examples). Because
3396
     * of that (and differently from e.g. DataList::filter()) you need to manually escape the field names:
3397
     * <code>
3398
     * $member = DataObject::get_one('Member', [ '"FirstName"' => 'John' ]);
3399
     * </code>
3400
     *
3401
     * @param string $callerClass The class of objects to be returned
3402
     * @param string|array $filter A filter to be inserted into the WHERE clause.
3403
     * @param boolean $cache Use caching
3404
     * @param string $orderby A sort expression to be inserted into the ORDER BY clause.
3405
     *
3406
     * @return DataObject|null The first item matching the query
3407
     */
3408
    public static function get_one($callerClass, $filter = "", $cache = true, $orderby = "")
3409
    {
3410
        /** @var DataObject $singleton */
3411
        $singleton = singleton($callerClass);
3412
3413
        $cacheComponents = [$filter, $orderby, $singleton->getUniqueKeyComponents()];
3414
        $cacheKey = md5(serialize($cacheComponents));
3415
3416
        $item = null;
3417
        if (!$cache || !isset(self::$_cache_get_one[$callerClass][$cacheKey])) {
3418
            $dl = DataObject::get($callerClass)->where($filter)->sort($orderby);
3419
            $item = $dl->first();
3420
3421
            if ($cache) {
3422
                self::$_cache_get_one[$callerClass][$cacheKey] = $item;
3423
                if (!self::$_cache_get_one[$callerClass][$cacheKey]) {
3424
                    self::$_cache_get_one[$callerClass][$cacheKey] = false;
3425
                }
3426
            }
3427
        }
3428
3429
        if ($cache) {
3430
            return self::$_cache_get_one[$callerClass][$cacheKey] ?: null;
3431
        }
3432
3433
        return $item;
3434
    }
3435
3436
    /**
3437
     * Flush the cached results for all relations (has_one, has_many, many_many)
3438
     * Also clears any cached aggregate data.
3439
     *
3440
     * @param boolean $persistent When true will also clear persistent data stored in the Cache system.
3441
     *                            When false will just clear session-local cached data
3442
     * @return DataObject $this
3443
     */
3444
    public function flushCache($persistent = true)
3445
    {
3446
        if (static::class == self::class) {
0 ignored issues
show
introduced by
The condition static::class == self::class is always true.
Loading history...
3447
            self::$_cache_get_one = [];
3448
            return $this;
3449
        }
3450
3451
        $classes = ClassInfo::ancestry(static::class);
3452
        foreach ($classes as $class) {
3453
            if (isset(self::$_cache_get_one[$class])) {
3454
                unset(self::$_cache_get_one[$class]);
3455
            }
3456
        }
3457
3458
        $this->extend('flushCache');
3459
3460
        $this->components = [];
3461
        return $this;
3462
    }
3463
3464
    /**
3465
     * Flush the get_one global cache and destroy associated objects.
3466
     */
3467
    public static function flush_and_destroy_cache()
3468
    {
3469
        if (self::$_cache_get_one) {
0 ignored issues
show
Bug Best Practice introduced by
The expression self::_cache_get_one 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...
3470
            foreach (self::$_cache_get_one as $class => $items) {
3471
                if (is_array($items)) {
3472
                    foreach ($items as $item) {
3473
                        if ($item) {
3474
                            $item->destroy();
3475
                        }
3476
                    }
3477
                }
3478
            }
3479
        }
3480
        self::$_cache_get_one = [];
3481
    }
3482
3483
    /**
3484
     * Reset all global caches associated with DataObject.
3485
     */
3486
    public static function reset()
3487
    {
3488
        // @todo Decouple these
3489
        DBEnum::flushCache();
3490
        ClassInfo::reset_db_cache();
3491
        static::getSchema()->reset();
3492
        self::$_cache_get_one = [];
3493
        self::$_cache_field_labels = [];
3494
    }
3495
3496
    /**
3497
     * Return the given element, searching by ID.
3498
     *
3499
     * This can be called either via `DataObject::get_by_id(MyClass::class, $id)`
3500
     * or `MyClass::get_by_id($id)`
3501
     *
3502
     * The object returned is cached, unlike DataObject::get()->byID() {@link DataList::byID()}
3503
     *
3504
     * @param string|int $classOrID The class of the object to be returned, or id if called on target class
3505
     * @param int|bool $idOrCache The id of the element, or cache if called on target class
3506
     * @param boolean $cache See {@link get_one()}
3507
     *
3508
     * @return static|null The element
3509
     */
3510
    public static function get_by_id($classOrID, $idOrCache = null, $cache = true)
3511
    {
3512
        // Shift arguments if passing id in first or second argument
3513
        list ($class, $id, $cached) = is_numeric($classOrID)
3514
            ? [get_called_class(), (int) $classOrID, isset($idOrCache) ? $idOrCache : $cache]
3515
            : [$classOrID, (int) $idOrCache, $cache];
3516
        if ($id < 1) {
3517
            return null;
3518
        }
3519
3520
        // Validate class
3521
        if ($class === self::class) {
3522
            throw new InvalidArgumentException('DataObject::get_by_id() cannot query non-subclass DataObject directly');
3523
        }
3524
3525
        // Pass to get_one
3526
        $column = static::getSchema()->sqlColumnForField($class, 'ID');
3527
        return DataObject::get_one($class, [$column => $id], $cached);
3528
    }
3529
3530
    /**
3531
     * Get the name of the base table for this object
3532
     *
3533
     * @return string
3534
     */
3535
    public function baseTable()
3536
    {
3537
        return static::getSchema()->baseDataTable($this);
3538
    }
3539
3540
    /**
3541
     * Get the base class for this object
3542
     *
3543
     * @return string
3544
     */
3545
    public function baseClass()
3546
    {
3547
        return static::getSchema()->baseDataClass($this);
3548
    }
3549
3550
    /**
3551
     * @var array Parameters used in the query that built this object.
3552
     * This can be used by decorators (e.g. lazy loading) to
3553
     * run additional queries using the same context.
3554
     */
3555
    protected $sourceQueryParams;
3556
3557
    /**
3558
     * @see $sourceQueryParams
3559
     * @return array
3560
     */
3561
    public function getSourceQueryParams()
3562
    {
3563
        return $this->sourceQueryParams;
3564
    }
3565
3566
    /**
3567
     * Get list of parameters that should be inherited to relations on this object
3568
     *
3569
     * @return array
3570
     */
3571
    public function getInheritableQueryParams()
3572
    {
3573
        $params = $this->getSourceQueryParams();
3574
        $this->extend('updateInheritableQueryParams', $params);
3575
        return $params;
3576
    }
3577
3578
    /**
3579
     * @see $sourceQueryParams
3580
     * @param array $array
3581
     */
3582
    public function setSourceQueryParams($array)
3583
    {
3584
        $this->sourceQueryParams = $array;
3585
    }
3586
3587
    /**
3588
     * @see $sourceQueryParams
3589
     * @param string $key
3590
     * @param string $value
3591
     */
3592
    public function setSourceQueryParam($key, $value)
3593
    {
3594
        $this->sourceQueryParams[$key] = $value;
3595
    }
3596
3597
    /**
3598
     * @see $sourceQueryParams
3599
     * @param string $key
3600
     * @return string
3601
     */
3602
    public function getSourceQueryParam($key)
3603
    {
3604
        if (isset($this->sourceQueryParams[$key])) {
3605
            return $this->sourceQueryParams[$key];
3606
        }
3607
        return null;
3608
    }
3609
3610
    //-------------------------------------------------------------------------------------------//
3611
3612
    /**
3613
     * Check the database schema and update it as necessary.
3614
     *
3615
     * @uses DataExtension::augmentDatabase()
3616
     */
3617
    public function requireTable()
3618
    {
3619
        // Only build the table if we've actually got fields
3620
        $schema = static::getSchema();
3621
        $table = $schema->tableName(static::class);
3622
        $fields = $schema->databaseFields(static::class, false);
3623
        $indexes = $schema->databaseIndexes(static::class, false);
3624
        $extensions = self::database_extensions(static::class);
0 ignored issues
show
Bug Best Practice introduced by
The method SilverStripe\ORM\DataObject::database_extensions() 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

3624
        /** @scrutinizer ignore-call */ 
3625
        $extensions = self::database_extensions(static::class);
Loading history...
3625
3626
        if (empty($table)) {
3627
            throw new LogicException(
3628
                "Class " . static::class . " not loaded by manifest, or no database table configured"
3629
            );
3630
        }
3631
3632
        if ($fields) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $fields 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...
3633
            $hasAutoIncPK = get_parent_class($this ?? '') === self::class;
3634
            DB::require_table(
3635
                $table,
3636
                $fields,
0 ignored issues
show
Bug introduced by
$fields of type array is incompatible with the type string expected by parameter $fieldSchema of SilverStripe\ORM\DB::require_table(). ( Ignorable by Annotation )

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

3636
                /** @scrutinizer ignore-type */ $fields,
Loading history...
3637
                $indexes,
0 ignored issues
show
Bug introduced by
$indexes of type array is incompatible with the type string expected by parameter $indexSchema of SilverStripe\ORM\DB::require_table(). ( Ignorable by Annotation )

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

3637
                /** @scrutinizer ignore-type */ $indexes,
Loading history...
3638
                $hasAutoIncPK,
3639
                $this->config()->get('create_table_options'),
3640
                $extensions
0 ignored issues
show
Bug introduced by
It seems like $extensions can also be of type false; however, parameter $extensions of SilverStripe\ORM\DB::require_table() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

3640
                /** @scrutinizer ignore-type */ $extensions
Loading history...
3641
            );
3642
        } else {
3643
            DB::dont_require_table($table);
3644
        }
3645
3646
        // Build any child tables for many_many items
3647
        if ($manyMany = $this->uninherited('many_many')) {
3648
            $extras = $this->uninherited('many_many_extraFields');
3649
            foreach ($manyMany as $component => $spec) {
3650
                // Get many_many spec
3651
                $manyManyComponent = $schema->manyManyComponent(static::class, $component);
3652
                $parentField = $manyManyComponent['parentField'];
3653
                $childField = $manyManyComponent['childField'];
3654
                $tableOrClass = $manyManyComponent['join'];
3655
3656
                // Skip if backed by actual class
3657
                if (class_exists($tableOrClass ?? '')) {
3658
                    continue;
3659
                }
3660
3661
                // Build fields
3662
                $manymanyFields = [
3663
                    $parentField => "Int",
3664
                    $childField => "Int",
3665
                ];
3666
                if (isset($extras[$component])) {
3667
                    $manymanyFields = array_merge($manymanyFields, $extras[$component]);
3668
                }
3669
3670
                // Build index list
3671
                $manymanyIndexes = [
3672
                    $parentField => [
3673
                        'type' => 'index',
3674
                        'name' => $parentField,
3675
                        'columns' => [$parentField],
3676
                    ],
3677
                    $childField => [
3678
                        'type' => 'index',
3679
                        'name' => $childField,
3680
                        'columns' => [$childField],
3681
                    ],
3682
                ];
3683
                DB::require_table($tableOrClass, $manymanyFields, $manymanyIndexes, true, null, $extensions);
0 ignored issues
show
Bug introduced by
$manymanyFields of type array|string[] is incompatible with the type string expected by parameter $fieldSchema of SilverStripe\ORM\DB::require_table(). ( Ignorable by Annotation )

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

3683
                DB::require_table($tableOrClass, /** @scrutinizer ignore-type */ $manymanyFields, $manymanyIndexes, true, null, $extensions);
Loading history...
Bug introduced by
$manymanyIndexes of type array<mixed,array<string,array|mixed|string>> is incompatible with the type string expected by parameter $indexSchema of SilverStripe\ORM\DB::require_table(). ( Ignorable by Annotation )

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

3683
                DB::require_table($tableOrClass, $manymanyFields, /** @scrutinizer ignore-type */ $manymanyIndexes, true, null, $extensions);
Loading history...
3684
            }
3685
        }
3686
3687
        // Let any extensions make their own database fields
3688
        $this->extend('augmentDatabase', $dummy);
3689
    }
3690
3691
    /**
3692
     * Add default records to database. This function is called whenever the
3693
     * database is built, after the database tables have all been created. Overload
3694
     * this to add default records when the database is built, but make sure you
3695
     * call parent::requireDefaultRecords().
3696
     *
3697
     * @uses DataExtension::requireDefaultRecords()
3698
     */
3699
    public function requireDefaultRecords()
3700
    {
3701
        $defaultRecords = $this->config()->uninherited('default_records');
3702
3703
        if (!empty($defaultRecords)) {
3704
            $hasData = DataObject::get_one(static::class);
3705
            if (!$hasData) {
3706
                $className = static::class;
3707
                foreach ($defaultRecords as $record) {
3708
                    $obj = Injector::inst()->create($className, $record);
3709
                    $obj->write();
3710
                }
3711
                DB::alteration_message("Added default records to $className table", "created");
3712
            }
3713
        }
3714
3715
        // Let any extensions make their own database default data
3716
        $this->extend('requireDefaultRecords', $dummy);
3717
    }
3718
3719
    /**
3720
     * Invoked after every database build is complete (including after table creation and
3721
     * default record population).
3722
     *
3723
     * See {@link DatabaseAdmin::doBuild()} for context.
3724
     */
3725
    public function onAfterBuild()
3726
    {
3727
        $this->extend('onAfterBuild');
3728
    }
3729
3730
    /**
3731
     * Get the default searchable fields for this object, as defined in the
3732
     * $searchable_fields list. If searchable fields are not defined on the
3733
     * data object, uses a default selection of summary fields.
3734
     *
3735
     * @return array
3736
     */
3737
    public function searchableFields()
3738
    {
3739
        // can have mixed format, need to make consistent in most verbose form
3740
        $fields = $this->config()->get('searchable_fields');
3741
        $labels = $this->fieldLabels();
3742
3743
        // fallback to summary fields (unless empty array is explicitly specified)
3744
        if (!$fields && !is_array($fields)) {
3745
            $summaryFields = array_keys($this->summaryFields() ?? []);
3746
            $fields = [];
3747
3748
            // remove the custom getters as the search should not include them
3749
            $schema = static::getSchema();
3750
            if ($summaryFields) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $summaryFields 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...
3751
                foreach ($summaryFields as $key => $name) {
3752
                    $spec = $name;
3753
3754
                    // Extract field name in case this is a method called on a field (e.g. "Date.Nice")
3755
                    if (($fieldPos = strpos($name ?? '', '.')) !== false) {
3756
                        $name = substr($name ?? '', 0, $fieldPos);
3757
                    }
3758
3759
                    if ($schema->fieldSpec($this, $name)) {
3760
                        $fields[] = $name;
3761
                    } elseif ($this->relObject($spec)) {
3762
                        $fields[] = $spec;
3763
                    }
3764
                }
3765
            }
3766
        }
3767
3768
        // we need to make sure the format is unified before
3769
        // augmenting fields, so extensions can apply consistent checks
3770
        // but also after augmenting fields, because the extension
3771
        // might use the shorthand notation as well
3772
3773
        // rewrite array, if it is using shorthand syntax
3774
        $rewrite = [];
3775
        foreach ($fields as $name => $specOrName) {
3776
            $identifier = (is_int($name)) ? $specOrName : $name;
3777
3778
            if (is_int($name)) {
3779
                // Format: array('MyFieldName')
3780
                $rewrite[$identifier] = [];
3781
            } elseif (is_array($specOrName) && (isset($specOrName['match_any']))) {
3782
                $rewrite[$identifier] = $fields[$identifier];
3783
                $rewrite[$identifier]['match_any'] = $specOrName['match_any'];
3784
            } elseif (is_array($specOrName) && ($relObject = $this->relObject($identifier))) {
3785
                // Format: array('MyFieldName' => array(
3786
                //   'filter => 'ExactMatchFilter',
3787
                //   'field' => 'NumericField', // optional
3788
                //   'title' => 'My Title', // optional
3789
                // ))
3790
                $rewrite[$identifier] = array_merge(
3791
                    ['filter' => $relObject->config()->get('default_search_filter_class')],
3792
                    (array)$specOrName
3793
                );
3794
            } else {
3795
                // Format: array('MyFieldName' => 'ExactMatchFilter')
3796
                $rewrite[$identifier] = [
3797
                    'filter' => $specOrName,
3798
                ];
3799
            }
3800
            if (!isset($rewrite[$identifier]['title'])) {
3801
                $rewrite[$identifier]['title'] = (isset($labels[$identifier]))
3802
                    ? $labels[$identifier] : FormField::name_to_label($identifier);
3803
            }
3804
            if (!isset($rewrite[$identifier]['filter'])) {
3805
                /** @skipUpgrade */
3806
                $rewrite[$identifier]['filter'] = 'PartialMatchFilter';
3807
            }
3808
        }
3809
3810
        $fields = $rewrite;
3811
3812
        // apply DataExtensions if present
3813
        $this->extend('updateSearchableFields', $fields);
3814
3815
        return $fields;
3816
    }
3817
3818
    /**
3819
     * Get any user defined searchable fields labels that
3820
     * exist. Allows overriding of default field names in the form
3821
     * interface actually presented to the user.
3822
     *
3823
     * The reason for keeping this separate from searchable_fields,
3824
     * which would be a logical place for this functionality, is to
3825
     * avoid bloating and complicating the configuration array. Currently
3826
     * much of this system is based on sensible defaults, and this property
3827
     * would generally only be set in the case of more complex relationships
3828
     * between data object being required in the search interface.
3829
     *
3830
     * Generates labels based on name of the field itself, if no static property
3831
     * {@link self::field_labels} exists.
3832
     *
3833
     * @uses $field_labels
3834
     * @uses FormField::name_to_label()
3835
     *
3836
     * @param boolean $includerelations a boolean value to indicate if the labels returned include relation fields
3837
     *
3838
     * @return array Array of all element labels
3839
     */
3840
    public function fieldLabels($includerelations = true)
3841
    {
3842
        $cacheKey = static::class . '_' . $includerelations;
3843
3844
        if (!isset(self::$_cache_field_labels[$cacheKey])) {
3845
            $customLabels = $this->config()->get('field_labels');
3846
            $autoLabels = [];
3847
3848
            // get all translated static properties as defined in i18nCollectStatics()
3849
            $ancestry = ClassInfo::ancestry(static::class);
3850
            $ancestry = array_reverse($ancestry ?? []);
3851
            if ($ancestry) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $ancestry 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...
3852
                foreach ($ancestry as $ancestorClass) {
3853
                    if ($ancestorClass === ViewableData::class) {
3854
                        break;
3855
                    }
3856
                    $types = [
3857
                        'db' => (array)Config::inst()->get($ancestorClass, 'db', Config::UNINHERITED)
3858
                    ];
3859
                    if ($includerelations) {
3860
                        $types['has_one'] = (array)Config::inst()->get($ancestorClass, 'has_one', Config::UNINHERITED);
3861
                        $types['has_many'] = (array)Config::inst()->get(
3862
                            $ancestorClass,
3863
                            'has_many',
3864
                            Config::UNINHERITED
3865
                        );
3866
                        $types['many_many'] = (array)Config::inst()->get(
3867
                            $ancestorClass,
3868
                            'many_many',
3869
                            Config::UNINHERITED
3870
                        );
3871
                        $types['belongs_many_many'] = (array)Config::inst()->get(
3872
                            $ancestorClass,
3873
                            'belongs_many_many',
3874
                            Config::UNINHERITED
3875
                        );
3876
                    }
3877
                    foreach ($types as $type => $attrs) {
3878
                        foreach ($attrs as $name => $spec) {
3879
                            $autoLabels[$name] = _t(
3880
                                "{$ancestorClass}.{$type}_{$name}",
3881
                                FormField::name_to_label($name)
3882
                            );
3883
                        }
3884
                    }
3885
                }
3886
            }
3887
3888
            $labels = array_merge((array)$autoLabels, (array)$customLabels);
3889
            $this->extend('updateFieldLabels', $labels);
3890
            self::$_cache_field_labels[$cacheKey] = $labels;
3891
        }
3892
3893
        return self::$_cache_field_labels[$cacheKey];
3894
    }
3895
3896
    /**
3897
     * Get a human-readable label for a single field,
3898
     * see {@link fieldLabels()} for more details.
3899
     *
3900
     * @uses fieldLabels()
3901
     * @uses FormField::name_to_label()
3902
     *
3903
     * @param string $name Name of the field
3904
     * @return string Label of the field
3905
     */
3906
    public function fieldLabel($name)
3907
    {
3908
        $labels = $this->fieldLabels();
3909
        return (isset($labels[$name])) ? $labels[$name] : FormField::name_to_label($name);
3910
    }
3911
3912
    /**
3913
     * Get the default summary fields for this object.
3914
     *
3915
     * @todo use the translation apparatus to return a default field selection for the language
3916
     *
3917
     * @return array
3918
     */
3919
    public function summaryFields()
3920
    {
3921
        $rawFields = $this->config()->get('summary_fields');
3922
3923
        // Merge associative / numeric keys
3924
        $fields = [];
3925
        foreach ($rawFields as $key => $value) {
3926
            if (is_int($key)) {
3927
                $key = $value;
3928
            }
3929
            $fields[$key] = $value;
3930
        }
3931
3932
        if (!$fields) {
3933
            $fields = [];
3934
            // try to scaffold a couple of usual suspects
3935
            if ($this->hasField('Name')) {
3936
                $fields['Name'] = 'Name';
3937
            }
3938
            if (static::getSchema()->fieldSpec($this, 'Title')) {
3939
                $fields['Title'] = 'Title';
3940
            }
3941
            if ($this->hasField('Description')) {
3942
                $fields['Description'] = 'Description';
3943
            }
3944
            if ($this->hasField('FirstName')) {
3945
                $fields['FirstName'] = 'First Name';
3946
            }
3947
        }
3948
        $this->extend("updateSummaryFields", $fields);
3949
3950
        // Final fail-over, just list ID field
3951
        if (!$fields) {
3952
            $fields['ID'] = 'ID';
3953
        }
3954
3955
        // Localize fields (if possible)
3956
        foreach ($this->fieldLabels(false) as $name => $label) {
3957
            // only attempt to localize if the label definition is the same as the field name.
3958
            // this will preserve any custom labels set in the summary_fields configuration
3959
            if (isset($fields[$name]) && $name === $fields[$name]) {
3960
                $fields[$name] = $label;
3961
            }
3962
        }
3963
3964
        return $fields;
3965
    }
3966
3967
    /**
3968
     * Defines a default list of filters for the search context.
3969
     *
3970
     * If a filter class mapping is defined on the data object,
3971
     * it is constructed here. Otherwise, the default filter specified in
3972
     * {@link DBField} is used.
3973
     *
3974
     * @todo error handling/type checking for valid FormField and SearchFilter subclasses?
3975
     *
3976
     * @return array
3977
     */
3978
    public function defaultSearchFilters()
3979
    {
3980
        $filters = [];
3981
3982
        foreach ($this->searchableFields() as $name => $spec) {
3983
            if (empty($spec['filter'])) {
3984
                /** @skipUpgrade */
3985
                $filters[$name] = 'PartialMatchFilter';
3986
            } elseif ($spec['filter'] instanceof SearchFilter) {
3987
                $filters[$name] = $spec['filter'];
3988
            } else {
3989
                $filters[$name] = Injector::inst()->create($spec['filter'], $name);
3990
            }
3991
        }
3992
3993
        return $filters;
3994
    }
3995
3996
    /**
3997
     * @return boolean True if the object is in the database
3998
     */
3999
    public function isInDB()
4000
    {
4001
        return is_numeric($this->ID) && $this->ID > 0;
4002
    }
4003
4004
    /*
4005
     * @ignore
4006
     */
4007
    private static $subclass_access = true;
4008
4009
    /**
4010
     * Temporarily disable subclass access in data object qeur
4011
     */
4012
    public static function disable_subclass_access()
4013
    {
4014
        self::$subclass_access = false;
4015
    }
4016
4017
    public static function enable_subclass_access()
4018
    {
4019
        self::$subclass_access = true;
4020
    }
4021
4022
    //-------------------------------------------------------------------------------------------//
4023
4024
    /**
4025
     * Database field definitions.
4026
     * This is a map from field names to field type. The field
4027
     * type should be a class that extends .
4028
     * @var array
4029
     * @config
4030
     */
4031
    private static $db = [];
4032
4033
    /**
4034
     * Use a casting object for a field. This is a map from
4035
     * field name to class name of the casting object.
4036
     *
4037
     * @var array
4038
     */
4039
    private static $casting = [
4040
        "Title" => 'Text',
4041
    ];
4042
4043
    /**
4044
     * Specify custom options for a CREATE TABLE call.
4045
     * Can be used to specify a custom storage engine for specific database table.
4046
     * All options have to be keyed for a specific database implementation,
4047
     * identified by their class name (extending from {@link SS_Database}).
4048
     *
4049
     * <code>
4050
     * array(
4051
     *  'MySQLDatabase' => 'ENGINE=MyISAM'
4052
     * )
4053
     * </code>
4054
     *
4055
     * Caution: This API is experimental, and might not be
4056
     * included in the next major release. Please use with care.
4057
     *
4058
     * @var array
4059
     * @config
4060
     */
4061
    private static $create_table_options = [
4062
        MySQLSchemaManager::ID => 'ENGINE=InnoDB'
4063
    ];
4064
4065
    /**
4066
     * If a field is in this array, then create a database index
4067
     * on that field. This is a map from fieldname to index type.
4068
     * See {@link SS_Database->requireIndex()} and custom subclasses for details on the array notation.
4069
     *
4070
     * @var array
4071
     * @config
4072
     */
4073
    private static $indexes = null;
4074
4075
    /**
4076
     * Inserts standard column-values when a DataObject
4077
     * is instantiated. Does not insert default records {@see $default_records}.
4078
     * This is a map from fieldname to default value.
4079
     *
4080
     *  - If you would like to change a default value in a sub-class, just specify it.
4081
     *  - If you would like to disable the default value given by a parent class, set the default value to 0,'',
4082
     *    or false in your subclass.  Setting it to null won't work.
4083
     *
4084
     * @var array
4085
     * @config
4086
     */
4087
    private static $defaults = [];
4088
4089
    /**
4090
     * Multidimensional array which inserts default data into the database
4091
     * on a db/build-call as long as the database-table is empty. Please use this only
4092
     * for simple constructs, not for SiteTree-Objects etc. which need special
4093
     * behaviour such as publishing and ParentNodes.
4094
     *
4095
     * Example:
4096
     * array(
4097
     *  array('Title' => "DefaultPage1", 'PageTitle' => 'page1'),
4098
     *  array('Title' => "DefaultPage2")
4099
     * ).
4100
     *
4101
     * @var array
4102
     * @config
4103
     */
4104
    private static $default_records = null;
4105
4106
    /**
4107
     * One-to-zero relationship definition. This is a map of component name to data type. In order to turn this into a
4108
     * true one-to-one relationship you can add a {@link DataObject::$belongs_to} relationship on the child class.
4109
     *
4110
     * Note that you cannot have a has_one and belongs_to relationship with the same name.
4111
     *
4112
     * @var array
4113
     * @config
4114
     */
4115
    private static $has_one = [];
4116
4117
    /**
4118
     * A meta-relationship that allows you to define the reverse side of a {@link DataObject::$has_one}.
4119
     *
4120
     * This does not actually create any data structures, but allows you to query the other object in a one-to-one
4121
     * relationship from the child object. If you have multiple belongs_to links to another object you can use the
4122
     * syntax "ClassName.HasOneName" to specify which foreign has_one key on the other object to use.
4123
     *
4124
     * Note that you cannot have a has_one and belongs_to relationship with the same name.
4125
     *
4126
     * @var array
4127
     * @config
4128
     */
4129
    private static $belongs_to = [];
4130
4131
    /**
4132
     * This defines a one-to-many relationship. It is a map of component name to the remote data class.
4133
     *
4134
     * This relationship type does not actually create a data structure itself - you need to define a matching $has_one
4135
     * relationship on the child class. Also, if the $has_one relationship on the child class has multiple links to this
4136
     * class you can use the syntax "ClassName.HasOneRelationshipName" in the remote data class definition to show
4137
     * which foreign key to use.
4138
     *
4139
     * @var array
4140
     * @config
4141
     */
4142
    private static $has_many = [];
4143
4144
    /**
4145
     * many-many relationship definitions.
4146
     * This is a map from component name to data type.
4147
     * @var array
4148
     * @config
4149
     */
4150
    private static $many_many = [];
4151
4152
    /**
4153
     * Extra fields to include on the connecting many-many table.
4154
     * This is a map from field name to field type.
4155
     *
4156
     * Example code:
4157
     * <code>
4158
     * public static $many_many_extraFields = array(
4159
     *  'Members' => array(
4160
     *          'Role' => 'Varchar(100)'
4161
     *      )
4162
     * );
4163
     * </code>
4164
     *
4165
     * @var array
4166
     * @config
4167
     */
4168
    private static $many_many_extraFields = [];
4169
4170
    /**
4171
     * The inverse side of a many-many relationship.
4172
     * This is a map from component name to data type.
4173
     * @var array
4174
     * @config
4175
     */
4176
    private static $belongs_many_many = [];
4177
4178
    /**
4179
     * The default sort expression. This will be inserted in the ORDER BY
4180
     * clause of a SQL query if no other sort expression is provided.
4181
     * @var string
4182
     * @config
4183
     */
4184
    private static $default_sort = null;
4185
4186
    /**
4187
     * Default list of fields that can be scaffolded by the ModelAdmin
4188
     * search interface.
4189
     *
4190
     * Overriding the default filter, with a custom defined filter:
4191
     * <code>
4192
     *  static $searchable_fields = array(
4193
     *     "Name" => "PartialMatchFilter"
4194
     *  );
4195
     * </code>
4196
     *
4197
     * Overriding the default form fields, with a custom defined field.
4198
     * The 'filter' parameter will be generated from {@link DBField::$default_search_filter_class}.
4199
     * The 'title' parameter will be generated from {@link DataObject->fieldLabels()}.
4200
     * <code>
4201
     *  static $searchable_fields = array(
4202
     *    "Name" => array(
4203
     *      "field" => "TextField"
4204
     *    )
4205
     *  );
4206
     * </code>
4207
     *
4208
     * Overriding the default form field, filter and title:
4209
     * <code>
4210
     *  static $searchable_fields = array(
4211
     *    "Organisation.ZipCode" => array(
4212
     *      "field" => "TextField",
4213
     *      "filter" => "PartialMatchFilter",
4214
     *      "title" => 'Organisation ZIP'
4215
     *    )
4216
     *  );
4217
     * </code>
4218
     * @config
4219
     * @var array
4220
     */
4221
    private static $searchable_fields = null;
4222
4223
    /**
4224
     * Name of the field which is used as a stand-in for searching across all searchable fields.
4225
     *
4226
     * If this is a blank string, global primary search functionality is disabled
4227
     * and the primary search field falls back to using the first field in
4228
     * the searchable fields array.
4229
     */
4230
    private static string $primary_search_field_name = 'q';
4231
4232
    /**
4233
     * User defined labels for searchable_fields, used to override
4234
     * default display in the search form.
4235
     * @config
4236
     * @var array
4237
     */
4238
    private static $field_labels = [];
4239
4240
    /**
4241
     * Provides a default list of fields to be used by a 'summary'
4242
     * view of this object.
4243
     * @config
4244
     * @var array
4245
     */
4246
    private static $summary_fields = [];
4247
4248
    public function provideI18nEntities()
4249
    {
4250
        // Note: see http://guides.rubyonrails.org/i18n.html#pluralization for rules
4251
        // Best guess for a/an rule. Better guesses require overriding in subclasses
4252
        $pluralName = $this->plural_name();
4253
        $singularName = $this->singular_name();
4254
        $conjunction = preg_match('/^[aeiou]/i', $singularName ?? '') ? 'An ' : 'A ';
4255
        return [
4256
            static::class . '.SINGULARNAME' => $this->singular_name(),
4257
            static::class . '.PLURALNAME' => $pluralName,
4258
            static::class . '.PLURALS' => [
4259
                'one' => $conjunction . $singularName,
4260
                'other' => '{count} ' . $pluralName
4261
            ]
4262
        ];
4263
    }
4264
4265
    /**
4266
     * Returns true if the given method/parameter has a value
4267
     * (Uses the DBField::hasValue if the parameter is a database field)
4268
     *
4269
     * @param string $field The field name
4270
     * @param array $arguments
4271
     * @param bool $cache
4272
     * @return boolean
4273
     */
4274
    public function hasValue($field, $arguments = null, $cache = true)
4275
    {
4276
        // has_one fields should not use dbObject to check if a value is given
4277
        $hasOne = static::getSchema()->hasOneComponent(static::class, $field);
4278
        if (!$hasOne && ($obj = $this->dbObject($field))) {
4279
            return $obj->exists();
4280
        } else {
4281
            return parent::hasValue($field, $arguments, $cache);
4282
        }
4283
    }
4284
4285
    /**
4286
     * If selected through a many_many through relation, this is the instance of the joined record
4287
     *
4288
     * @return DataObject
4289
     */
4290
    public function getJoin()
4291
    {
4292
        return $this->joinRecord;
4293
    }
4294
4295
    /**
4296
     * Set joining object
4297
     *
4298
     * @param DataObject $object
4299
     * @param string $alias Alias
4300
     * @return $this
4301
     */
4302
    public function setJoin(DataObject $object, $alias = null)
4303
    {
4304
        $this->joinRecord = $object;
4305
        if ($alias) {
4306
            if (static::getSchema()->fieldSpec(static::class, $alias)) {
4307
                throw new InvalidArgumentException(
4308
                    "Joined record $alias cannot also be a db field"
4309
                );
4310
            }
4311
            $this->record[$alias] = $object;
4312
        }
4313
        return $this;
4314
    }
4315
4316
    /**
4317
     * Find objects in the given relationships, merging them into the given list
4318
     *
4319
     * @param string $source Config property to extract relationships from
4320
     * @param bool $recursive True if recursive
4321
     * @param ArrayList $list If specified, items will be added to this list. If not, a new
4322
     * instance of ArrayList will be constructed and returned
4323
     * @return ArrayList The list of related objects
4324
     */
4325
    public function findRelatedObjects($source, $recursive = true, $list = null)
4326
    {
4327
        if (!$list) {
4328
            $list = new ArrayList();
4329
        }
4330
4331
        // Skip search for unsaved records
4332
        if (!$this->isInDB()) {
4333
            return $list;
4334
        }
4335
4336
        $relationships = $this->config()->get($source) ?: [];
4337
        foreach ($relationships as $relationship) {
4338
            // Warn if invalid config
4339
            if (!$this->hasMethod($relationship)) {
4340
                trigger_error(sprintf(
4341
                    "Invalid %s config value \"%s\" on object on class \"%s\"",
4342
                    $source,
4343
                    $relationship,
4344
                    get_class($this)
4345
                ), E_USER_WARNING);
4346
                continue;
4347
            }
4348
4349
            // Inspect value of this relationship
4350
            $items = $this->{$relationship}();
4351
4352
            // Merge any new item
4353
            $newItems = $this->mergeRelatedObjects($list, $items);
4354
4355
            // Recurse if necessary
4356
            if ($recursive) {
4357
                foreach ($newItems as $item) {
4358
                    /** @var DataObject $item */
4359
                    $item->findRelatedObjects($source, true, $list);
4360
                }
4361
            }
4362
        }
4363
        return $list;
4364
    }
4365
4366
    /**
4367
     * Helper method to merge owned/owning items into a list.
4368
     * Items already present in the list will be skipped.
4369
     *
4370
     * @param ArrayList $list Items to merge into
4371
     * @param mixed $items List of new items to merge
4372
     * @return ArrayList List of all newly added items that did not already exist in $list
4373
     */
4374
    public function mergeRelatedObjects($list, $items)
4375
    {
4376
        $added = new ArrayList();
4377
        if (!$items) {
4378
            return $added;
4379
        }
4380
        if ($items instanceof DataObject) {
4381
            $items = [$items];
4382
        }
4383
4384
        /** @var DataObject $item */
4385
        foreach ($items as $item) {
4386
            $this->mergeRelatedObject($list, $added, $item);
4387
        }
4388
        return $added;
4389
    }
4390
4391
    /**
4392
     * Generate a unique key for data object
4393
     * the unique key uses the @see DataObject::getUniqueKeyComponents() extension point so unique key modifiers
4394
     * such as versioned or fluent are covered
4395
     * i.e. same data object in different stages or different locales will produce different unique key
4396
     *
4397
     * recommended use:
4398
     * - when you need unique key for caching purposes
4399
     * - when you need unique id on the front end (for example JavaScript needs to target specific element)
4400
     *
4401
     * @return string
4402
     * @throws Exception
4403
     */
4404
    public function getUniqueKey(): string
4405
    {
4406
        /** @var UniqueKeyInterface $service */
4407
        $service = Injector::inst()->get(UniqueKeyInterface::class);
4408
        $keyComponents = $this->getUniqueKeyComponents();
4409
4410
        return $service->generateKey($this, $keyComponents);
4411
    }
4412
4413
    /**
4414
     * Merge single object into a list, but ensures that existing objects are not
4415
     * re-added.
4416
     *
4417
     * @param ArrayList $list Global list
4418
     * @param ArrayList $added Additional list to insert into
4419
     * @param DataObject $item Item to add
4420
     */
4421
    protected function mergeRelatedObject($list, $added, $item)
4422
    {
4423
        // Identify item
4424
        $itemKey = get_class($item) . '/' . $item->ID;
4425
4426
        // Write if saved, versioned, and not already added
4427
        if ($item->isInDB() && !isset($list[$itemKey])) {
4428
            $list[$itemKey] = $item;
4429
            $added[$itemKey] = $item;
4430
        }
4431
4432
        // Add joined record (from many_many through) automatically
4433
        $joined = $item->getJoin();
4434
        if ($joined) {
0 ignored issues
show
introduced by
$joined is of type SilverStripe\ORM\DataObject, thus it always evaluated to true.
Loading history...
4435
            $this->mergeRelatedObject($list, $added, $joined);
4436
        }
4437
    }
4438
4439
    /**
4440
     * Extension point to add more cache key components.
4441
     * The framework extend method will return combined values from DataExtension method(s) as an array
4442
     * The method on your DataExtension class should return a single scalar value. For example:
4443
     *
4444
     * public function cacheKeyComponent()
4445
     * {
4446
     *      return (string) $this->owner->MyColumn;
4447
     * }
4448
     *
4449
     * @return array
4450
     */
4451
    private function getUniqueKeyComponents(): array
4452
    {
4453
        return $this->extend('cacheKeyComponent');
4454
    }
4455
4456
    /**
4457
     * Find all other DataObject instances that are related to this DataObject in the database
4458
     * through has_one and many_many relationships. For example:
4459
     * This method is called on a File.  The MyPage model $has_one File.  There is a Page record that has
4460
     * a FileID = $this->ID. This SS_List returned by this method will include that Page instance.
4461
     *
4462
     * @param string[] $excludedClasses
4463
     * @return SS_List
4464
     * @internal
4465
     */
4466
    public function findAllRelatedData(array $excludedClasses = []): SS_List
4467
    {
4468
        $service = Injector::inst()->get(RelatedDataService::class);
4469
        return $service->findAll($this, $excludedClasses);
4470
    }
4471
}
4472