Passed
Pull Request — 4 (#10382)
by Guy
06:55
created

DataObject::scaffoldSearchFields()   D

Complexity

Conditions 18
Paths 61

Size

Total Lines 74
Code Lines 41

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 18
eloc 41
nc 61
nop 1
dl 0
loc 74
rs 4.8666
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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

578
                case array_key_exists($relation, /** @scrutinizer ignore-type */ $hasMany): {
Loading history...
579
                    $this->duplicateHasManyRelation($sourceObject, $destinationObject, $relation);
580
                    break;
581
                }
582
                case array_key_exists($relation, $hasOne): {
583
                    $this->duplicateHasOneRelation($sourceObject, $destinationObject, $relation);
584
                    break;
585
                }
586
                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

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

1197
                        $leftComponents->/** @scrutinizer ignore-call */ 
1198
                                         addMany($rightComponents->column('ID'));
Loading history...
1198
                    }
1199
                    $leftComponents->write();
1200
                }
1201
            }
1202
1203
            if ($hasMany = $this->hasMany()) {
1204
                foreach ($hasMany as $relationship => $class) {
1205
                    $leftComponents = $leftObj->getComponents($relationship);
1206
                    $rightComponents = $rightObj->getComponents($relationship);
1207
                    if ($rightComponents && $rightComponents->exists()) {
1208
                        $leftComponents->addMany($rightComponents->column('ID'));
1209
                    }
1210
                    $leftComponents->write();
0 ignored issues
show
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

1210
                    $leftComponents->/** @scrutinizer ignore-call */ 
1211
                                     write();
Loading history...
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

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

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

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

3254
                $singleton = DataObject::singleton($component->/** @scrutinizer ignore-call */ dataClass());
Loading history...
3255
                $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

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

3379
        if ($limit && strpos(/** @scrutinizer ignore-type */ $limit ?? '', ',') !== false) {
Loading history...
3380
            $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

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

3625
        /** @scrutinizer ignore-call */ 
3626
        $extensions = self::database_extensions(static::class);
Loading history...
3626
3627
        if (empty($table)) {
3628
            throw new LogicException(
3629
                "Class " . static::class . " not loaded by manifest, or no database table configured"
3630
            );
3631
        }
3632
3633
        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...
3634
            $hasAutoIncPK = get_parent_class($this ?? '') === self::class;
3635
            DB::require_table(
3636
                $table,
3637
                $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

3637
                /** @scrutinizer ignore-type */ $fields,
Loading history...
3638
                $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

3638
                /** @scrutinizer ignore-type */ $indexes,
Loading history...
3639
                $hasAutoIncPK,
3640
                $this->config()->get('create_table_options'),
3641
                $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

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

3684
                DB::require_table($tableOrClass, $manymanyFields, /** @scrutinizer ignore-type */ $manymanyIndexes, true, null, $extensions);
Loading history...
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

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