Passed
Push — pulls/manymanylist-add-callbac... ( 77b6c5...aed064 )
by Sam
07:52
created

DataObject::__construct()   F

Complexity

Conditions 17
Paths 300

Size

Total Lines 104
Code Lines 59

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 17
eloc 59
nc 300
nop 3
dl 0
loc 104
rs 3.1333
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\i18n\i18n;
20
use SilverStripe\i18n\i18nEntityProvider;
21
use SilverStripe\ORM\Connect\MySQLSchemaManager;
22
use SilverStripe\ORM\FieldType\DBComposite;
23
use SilverStripe\ORM\FieldType\DBDatetime;
24
use SilverStripe\ORM\FieldType\DBEnum;
25
use SilverStripe\ORM\FieldType\DBField;
26
use SilverStripe\ORM\Filters\SearchFilter;
27
use SilverStripe\ORM\Queries\SQLDelete;
28
use SilverStripe\ORM\Search\SearchContext;
29
use SilverStripe\ORM\UniqueKey\UniqueKeyInterface;
30
use SilverStripe\ORM\UniqueKey\UniqueKeyService;
31
use SilverStripe\Security\Member;
32
use SilverStripe\Security\Permission;
33
use SilverStripe\Security\Security;
34
use SilverStripe\View\SSViewer;
35
use SilverStripe\View\ViewableData;
36
use stdClass;
37
38
/**
39
 * A single database record & abstract class for the data-access-model.
40
 *
41
 * <h2>Extensions</h2>
42
 *
43
 * See {@link Extension} and {@link DataExtension}.
44
 *
45
 * <h2>Permission Control</h2>
46
 *
47
 * Object-level access control by {@link Permission}. Permission codes are arbitrary
48
 * strings which can be selected on a group-by-group basis.
49
 *
50
 * <code>
51
 * class Article extends DataObject implements PermissionProvider {
52
 *  static $api_access = true;
53
 *
54
 *  function canView($member = false) {
55
 *    return Permission::check('ARTICLE_VIEW');
56
 *  }
57
 *  function canEdit($member = false) {
58
 *    return Permission::check('ARTICLE_EDIT');
59
 *  }
60
 *  function canDelete() {
61
 *    return Permission::check('ARTICLE_DELETE');
62
 *  }
63
 *  function canCreate() {
64
 *    return Permission::check('ARTICLE_CREATE');
65
 *  }
66
 *  function providePermissions() {
67
 *    return array(
68
 *      'ARTICLE_VIEW' => 'Read an article object',
69
 *      'ARTICLE_EDIT' => 'Edit an article object',
70
 *      'ARTICLE_DELETE' => 'Delete an article object',
71
 *      'ARTICLE_CREATE' => 'Create an article object',
72
 *    );
73
 *  }
74
 * }
75
 * </code>
76
 *
77
 * Object-level access control by {@link Group} membership:
78
 * <code>
79
 * class Article extends DataObject {
80
 *   static $api_access = true;
81
 *
82
 *   function canView($member = false) {
83
 *     if(!$member) $member = Security::getCurrentUser();
84
 *     return $member->inGroup('Subscribers');
85
 *   }
86
 *   function canEdit($member = false) {
87
 *     if(!$member) $member = Security::getCurrentUser();
88
 *     return $member->inGroup('Editors');
89
 *   }
90
 *
91
 *   // ...
92
 * }
93
 * </code>
94
 *
95
 * If any public method on this class is prefixed with an underscore,
96
 * the results are cached in memory through {@link cachedCall()}.
97
 *
98
 *
99
 * @todo Add instance specific removeExtension() which undos loadExtraStatics()
100
 *  and defineMethods()
101
 *
102
 * @property int $ID ID of the DataObject, 0 if the DataObject doesn't exist in database.
103
 * @property int $OldID ID of object, if deleted
104
 * @property string $Title
105
 * @property string $ClassName Class name of the DataObject
106
 * @property string $LastEdited Date and time of DataObject's last modification.
107
 * @property string $Created Date and time of DataObject creation.
108
 * @property string $ObsoleteClassName If ClassName no longer exists this will be set to the legacy value
109
 */
110
class DataObject extends ViewableData implements DataObjectInterface, i18nEntityProvider, Resettable
111
{
112
113
    /**
114
     * Human-readable singular name.
115
     * @var string
116
     * @config
117
     */
118
    private static $singular_name = null;
119
120
    /**
121
     * Human-readable plural name
122
     * @var string
123
     * @config
124
     */
125
    private static $plural_name = null;
126
127
    /**
128
     * Allow API access to this object?
129
     * @todo Define the options that can be set here
130
     * @config
131
     */
132
    private static $api_access = false;
133
134
    /**
135
     * Allows specification of a default value for the ClassName field.
136
     * Configure this value only in subclasses of DataObject.
137
     *
138
     * @config
139
     * @var string
140
     */
141
    private static $default_classname = null;
142
143
    /**
144
     * @deprecated 4.0.0:5.0.0
145
     * @var bool
146
     */
147
    public $destroyed = false;
148
149
    /**
150
     * Data stored in this objects database record. An array indexed by fieldname.
151
     *
152
     * Use {@link toMap()} if you want an array representation
153
     * of this object, as the $record array might contain lazy loaded field aliases.
154
     *
155
     * @var array
156
     */
157
    protected $record;
158
159
    /**
160
     * If selected through a many_many through relation, this is the instance of the through record
161
     *
162
     * @var DataObject
163
     */
164
    protected $joinRecord;
165
166
    /**
167
     * Represents a field that hasn't changed (before === after, thus before == after)
168
     */
169
    const CHANGE_NONE = 0;
170
171
    /**
172
     * Represents a field that has changed type, although not the loosely defined value.
173
     * (before !== after && before == after)
174
     * E.g. change 1 to true or "true" to true, but not true to 0.
175
     * Value changes are by nature also considered strict changes.
176
     */
177
    const CHANGE_STRICT = 1;
178
179
    /**
180
     * Represents a field that has changed the loosely defined value
181
     * (before != after, thus, before !== after))
182
     * E.g. change false to true, but not false to 0
183
     */
184
    const CHANGE_VALUE = 2;
185
186
    /**
187
     * Value for 2nd argument to constructor, indicating that a new record is being created
188
     * Setters will be called on fields passed, and defaults will be populated
189
     */
190
    const CREATE_OBJECT = 0;
191
192
    /**
193
     * Value for 2nd argument to constructor, indicating that a record is a singleton representing the whole type,
194
     * e.g. to call requireTable() in dev/build
195
     * Defaults will not be populated and data passed will be ignored
196
     */
197
    const CREATE_SINGLETON = 1;
198
199
    /**
200
     * Value for 2nd argument to constructor, indicating that a record is being hydrated from the database
201
     * Neither setters and nor default population will be called
202
     */
203
    const CREATE_HYDRATED = 2;
204
205
    /**
206
     * An array indexed by fieldname, true if the field has been changed.
207
     * Use {@link getChangedFields()} and {@link isChanged()} to inspect
208
     * the changed state.
209
     *
210
     * @var array
211
     */
212
    private $changed = [];
213
214
    /**
215
     * A flag to indicate that a "strict" change of the entire record been forced
216
     * Use {@link getChangedFields()} and {@link isChanged()} to inspect
217
     * the changed state.
218
     *
219
     * @var boolean
220
     */
221
    private $changeForced = false;
222
223
    /**
224
     * The database record (in the same format as $record), before
225
     * any changes.
226
     * @var array
227
     */
228
    protected $original = [];
229
230
    /**
231
     * Used by onBeforeDelete() to ensure child classes call parent::onBeforeDelete()
232
     * @var boolean
233
     */
234
    protected $brokenOnDelete = false;
235
236
    /**
237
     * Used by onBeforeWrite() to ensure child classes call parent::onBeforeWrite()
238
     * @var boolean
239
     */
240
    protected $brokenOnWrite = false;
241
242
    /**
243
     * Should dataobjects be validated before they are written?
244
     *
245
     * Caution: Validation can contain safeguards against invalid/malicious data,
246
     * and check permission levels (e.g. on {@link Group}). Therefore it is recommended
247
     * to only disable validation for very specific use cases.
248
     *
249
     * @config
250
     * @var boolean
251
     */
252
    private static $validation_enabled = true;
253
254
    /**
255
     * Static caches used by relevant functions.
256
     *
257
     * @var array
258
     */
259
    protected static $_cache_get_one;
260
261
    /**
262
     * Cache of field labels
263
     *
264
     * @var array
265
     */
266
    protected static $_cache_field_labels = [];
267
268
    /**
269
     * Base fields which are not defined in static $db
270
     *
271
     * @config
272
     * @var array
273
     */
274
    private static $fixed_fields = [
275
        'ID' => 'PrimaryKey',
276
        'ClassName' => 'DBClassName',
277
        'LastEdited' => 'DBDatetime',
278
        'Created' => 'DBDatetime',
279
    ];
280
281
    /**
282
     * Override table name for this class. If ignored will default to FQN of class.
283
     * This option is not inheritable, and must be set on each class.
284
     * If left blank naming will default to the legacy (3.x) behaviour.
285
     *
286
     * @var string
287
     */
288
    private static $table_name = null;
289
290
    /**
291
     * Non-static relationship cache, indexed by component name.
292
     *
293
     * @var DataObject[]
294
     */
295
    protected $components = [];
296
297
    /**
298
     * Non-static cache of has_many and many_many relations that can't be written until this object is saved.
299
     *
300
     * @var UnsavedRelationList[]
301
     */
302
    protected $unsavedRelations;
303
304
    /**
305
     * List of relations that should be cascade deleted, similar to `owns`
306
     * Note: This will trigger delete on many_many objects, not only the mapping table.
307
     * For many_many through you can specify the components you want to delete separately
308
     * (many_many or has_many sub-component)
309
     *
310
     * @config
311
     * @var array
312
     */
313
    private static $cascade_deletes = [];
314
315
    /**
316
     * List of relations that should be cascade duplicate.
317
     * many_many duplications are shallow only.
318
     *
319
     * Note: If duplicating a many_many through you should refer to the
320
     * has_many intermediary relation instead, otherwise extra fields
321
     * will be omitted from the duplicated relation.
322
     *
323
     * @var array
324
     */
325
    private static $cascade_duplicates = [];
326
327
    /**
328
     * Get schema object
329
     *
330
     * @return DataObjectSchema
331
     */
332
    public static function getSchema()
333
    {
334
        return Injector::inst()->get(DataObjectSchema::class);
335
    }
336
337
    /**
338
     * Construct a new DataObject.
339
     *
340
     * @param array $record Initial record content, or rehydrated record content, depending on $creationType
341
     * @param int|boolean $creationType Set to DataObject::CREATE_OBJECT, DataObject::CREATE_HYDRATED, or DataObject::CREATE_SINGLETON. Used by SilverStripe internals as best left as the default by regular users.
342
     * @param array $queryParams List of DataQuery params necessary to lazy load, or load related objects.
343
     */
344
    public function __construct($record = [], $creationType = self::CREATE_OBJECT, $queryParams = [])
345
    {
346
        parent::__construct();
347
348
        // Legacy $record default
349
        if ($record === null) {
0 ignored issues
show
introduced by
The condition $record === null is always false.
Loading history...
350
            $record = [];
351
        }
352
353
        // Legacy $isSingleton boolean
354
        if (!is_int($creationType)) {
355
            if (!is_bool($creationType)) {
0 ignored issues
show
introduced by
The condition is_bool($creationType) is always true.
Loading history...
356
                user_error('Creation type is neither boolean (old isSingleton arg) nor integer (new arg), please review your code', E_USER_WARNING);
357
            }
358
            $creationType = $creationType ? self::CREATE_SINGLETON : self::CREATE_OBJECT;
359
        }
360
361
        // Set query params on the DataObject to tell the lazy loading mechanism the context the object creation context
362
        $this->setSourceQueryParams($queryParams);
363
364
        // Set $this->record to $record, but ignore NULLs
365
        $this->record = [];
366
367
        switch ($creationType) {
368
            // Hydrate a record from the database
369
            case self::CREATE_HYDRATED:
370
                if (!is_array($record) || empty($record['ID'])) {
0 ignored issues
show
introduced by
The condition is_array($record) is always true.
Loading history...
371
                    throw new \InvalidArgumentException("Hydrated records must be passed a record array including an ID");
372
                }
373
374
                $this->record = $record;
375
376
                // Identify fields that should be lazy loaded, but only on existing records
377
                // Get all field specs scoped to class for later lazy loading
378
                $fields = static::getSchema()->fieldSpecs(
379
                    static::class,
380
                    DataObjectSchema::INCLUDE_CLASS | DataObjectSchema::DB_ONLY
381
                );
382
                foreach ($fields as $field => $fieldSpec) {
383
                    $fieldClass = strtok($fieldSpec, ".");
384
                    if (!array_key_exists($field, $record)) {
385
                        $this->record[$field . '_Lazy'] = $fieldClass;
386
                    }
387
                }
388
389
                $this->original = $this->record;
390
                $this->changed = [];
391
                $this->changeForced = false;
392
393
                break;
394
395
            // Create a new object, using the constructor argument as the initial content
396
            case self::CREATE_OBJECT:
397
                if ($record instanceof stdClass) {
0 ignored issues
show
introduced by
$record is never a sub-type of stdClass.
Loading history...
398
                    $record = (array)$record;
399
                }
400
401
                if (!is_array($record)) {
0 ignored issues
show
introduced by
The condition is_array($record) is always true.
Loading history...
402
                    if (is_object($record)) {
403
                        $passed = "an object of type '" . get_class($record) . "'";
404
                    } else {
405
                        $passed = "The value '$record'";
406
                    }
407
408
                    user_error(
409
                        "DataObject::__construct passed $passed.  It's supposed to be passed an array,"
410
                        . " taken straight from the database.  Perhaps you should use DataList::create()->First(); instead?",
411
                        E_USER_WARNING
412
                    );
413
                    $record = [];
414
                }
415
416
                // Default columns
417
                $this->record['ID'] = empty($record['ID']) ? 0 : $record['ID'];
418
                $this->record['ClassName'] = static::class;
419
                $this->record['RecordClassName'] = static::class;
420
                unset($record['ID']);
421
                $this->original = $this->record;
422
423
                $this->populateDefaults();
424
425
                // prevent populateDefaults() and setField() from marking overwritten defaults as changed
426
                $this->changed = [];
427
                $this->changeForced = false;
428
429
                // Set the data passed in the constructor, allowing for defaults and calling setters
430
                // This will mark fields as changed
431
                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...
432
                    $this->update($record);
433
                }
434
                break;
435
436
            case self::CREATE_SINGLETON:
437
                // No setting happens for a singleton
438
                $this->record['ID'] = 0;
439
                $this->record['ClassName'] = static::class;
440
                $this->record['RecordClassName'] = static::class;
441
                $this->original = $this->record;
442
                $this->changed = [];
443
                $this->changeForced = false;
444
                break;
445
446
            default:
447
                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...
448
        }
449
    }
450
451
    /**
452
     * Destroy all of this objects dependant objects and local caches.
453
     * You'll need to call this to get the memory of an object that has components or extensions freed.
454
     */
455
    public function destroy()
456
    {
457
        $this->flushCache(false);
458
    }
459
460
    /**
461
     * Create a duplicate of this node. Can duplicate many_many relations
462
     *
463
     * @param bool $doWrite Perform a write() operation before returning the object.
464
     * If this is true, it will create the duplicate in the database.
465
     * @param array|null|false $relations List of relations to duplicate.
466
     * Will default to `cascade_duplicates` if null.
467
     * Set to 'false' to force none.
468
     * Set to specific array of names to duplicate to override these.
469
     * Note: If using versioned, this will additionally failover to `owns` config.
470
     * @return static A duplicate of this node. The exact type will be the type of this node.
471
     */
472
    public function duplicate($doWrite = true, $relations = null)
473
    {
474
        // Handle legacy behaviour
475
        if (is_string($relations) || $relations === true) {
0 ignored issues
show
introduced by
The condition $relations === true is always false.
Loading history...
476
            if ($relations === true) {
477
                $relations = 'many_many';
478
            }
479
            Deprecation::notice('5.0', 'Use cascade_duplicates config instead of providing a string to duplicate()');
480
            $relations = array_keys($this->config()->get($relations)) ?: [];
481
        }
482
483
        // Get duplicates
484
        if ($relations === null) {
485
            $relations = $this->config()->get('cascade_duplicates');
486
            // Remove any duplicate entries before duplicating them
487
            if (is_array($relations)) {
488
                $relations = array_unique($relations);
489
            }
490
        }
491
492
        // Create unsaved raw duplicate
493
        $map = $this->toMap();
494
        unset($map['Created']);
495
        /** @var static $clone */
496
        $clone = Injector::inst()->create(static::class, $map, false, $this->getSourceQueryParams());
497
        $clone->ID = 0;
498
499
        // Note: Extensions such as versioned may update $relations here
500
        $clone->invokeWithExtensions('onBeforeDuplicate', $this, $doWrite, $relations);
501
        if ($relations) {
502
            $this->duplicateRelations($this, $clone, $relations);
503
        }
504
        if ($doWrite) {
505
            $clone->write();
506
        }
507
        $clone->invokeWithExtensions('onAfterDuplicate', $this, $doWrite, $relations);
508
509
        return $clone;
510
    }
511
512
    /**
513
     * Copies the given relations from this object to the destination
514
     *
515
     * @param DataObject $sourceObject the source object to duplicate from
516
     * @param DataObject $destinationObject the destination object to populate with the duplicated relations
517
     * @param array $relations List of relations
518
     */
519
    protected function duplicateRelations($sourceObject, $destinationObject, $relations)
520
    {
521
        // Get list of duplicable relation types
522
        $manyMany = $sourceObject->manyMany();
523
        $hasMany = $sourceObject->hasMany();
524
        $hasOne = $sourceObject->hasOne();
525
        $belongsTo = $sourceObject->belongsTo();
526
527
        // Duplicate each relation based on type
528
        foreach ($relations as $relation) {
529
            switch (true) {
530
                case array_key_exists($relation, $manyMany): {
531
                    $this->duplicateManyManyRelation($sourceObject, $destinationObject, $relation);
532
                    break;
533
                }
534
                case array_key_exists($relation, $hasMany): {
535
                    $this->duplicateHasManyRelation($sourceObject, $destinationObject, $relation);
536
                    break;
537
                }
538
                case array_key_exists($relation, $hasOne): {
539
                    $this->duplicateHasOneRelation($sourceObject, $destinationObject, $relation);
540
                    break;
541
                }
542
                case array_key_exists($relation, $belongsTo): {
543
                    $this->duplicateBelongsToRelation($sourceObject, $destinationObject, $relation);
544
                    break;
545
                }
546
                default: {
547
                    $sourceType = get_class($sourceObject);
548
                    throw new InvalidArgumentException(
549
                        "Cannot duplicate unknown relation {$relation} on parent type {$sourceType}"
550
                    );
551
                }
552
            }
553
        }
554
    }
555
556
    /**
557
     * Copies the many_many and belongs_many_many relations from one object to another instance of the name of object.
558
     *
559
     * @deprecated 4.1.0:5.0.0 Use duplicateRelations() instead
560
     * @param DataObject $sourceObject the source object to duplicate from
561
     * @param DataObject $destinationObject the destination object to populate with the duplicated relations
562
     * @param bool|string $filter
563
     */
564
    protected function duplicateManyManyRelations($sourceObject, $destinationObject, $filter)
565
    {
566
        Deprecation::notice('5.0', 'Use duplicateRelations() instead');
567
568
        // Get list of relations to duplicate
569
        if ($filter === 'many_many' || $filter === 'belongs_many_many') {
570
            $relations = $sourceObject->config()->get($filter);
571
        } elseif ($filter === true) {
572
            $relations = $sourceObject->manyMany();
573
        } else {
574
            throw new InvalidArgumentException("Invalid many_many duplication filter");
575
        }
576
        foreach ($relations as $manyManyName => $type) {
577
            $this->duplicateManyManyRelation($sourceObject, $destinationObject, $manyManyName);
578
        }
579
    }
580
581
    /**
582
     * Duplicates a single many_many relation from one object to another.
583
     *
584
     * @param DataObject $sourceObject
585
     * @param DataObject $destinationObject
586
     * @param string $relation
587
     */
588
    protected function duplicateManyManyRelation($sourceObject, $destinationObject, $relation)
589
    {
590
        // Copy all components from source to destination
591
        $source = $sourceObject->getManyManyComponents($relation);
592
        $dest = $destinationObject->getManyManyComponents($relation);
593
594
        if ($source instanceof ManyManyList) {
595
            $extraFieldNames = $source->getExtraFields();
596
        } else {
597
            $extraFieldNames = [];
598
        }
599
600
        foreach ($source as $item) {
601
            // Merge extra fields
602
            $extraFields = [];
603
            foreach ($extraFieldNames as $fieldName => $fieldType) {
604
                $extraFields[$fieldName] = $item->getField($fieldName);
605
            }
606
            $dest->add($item, $extraFields);
607
        }
608
    }
609
610
    /**
611
     * Duplicates a single many_many relation from one object to another.
612
     *
613
     * @param DataObject $sourceObject
614
     * @param DataObject $destinationObject
615
     * @param string $relation
616
     */
617
    protected function duplicateHasManyRelation($sourceObject, $destinationObject, $relation)
618
    {
619
        // Copy all components from source to destination
620
        $source = $sourceObject->getComponents($relation);
621
        $dest = $destinationObject->getComponents($relation);
622
623
        /** @var DataObject $item */
624
        foreach ($source as $item) {
625
            // Don't write on duplicate; Wait until ParentID is available later.
626
            // writeRelations() will eventually write these records when converting
627
            // from UnsavedRelationList
628
            $clonedItem = $item->duplicate(false);
629
            $dest->add($clonedItem);
630
        }
631
    }
632
633
    /**
634
     * Duplicates a single has_one relation from one object to another.
635
     * Note: Child object will be force written.
636
     *
637
     * @param DataObject $sourceObject
638
     * @param DataObject $destinationObject
639
     * @param string $relation
640
     */
641
    protected function duplicateHasOneRelation($sourceObject, $destinationObject, $relation)
642
    {
643
        // Check if original object exists
644
        $item = $sourceObject->getComponent($relation);
645
        if (!$item->isInDB()) {
646
            return;
647
        }
648
649
        $clonedItem = $item->duplicate(false);
650
        $destinationObject->setComponent($relation, $clonedItem);
651
    }
652
653
    /**
654
     * Duplicates a single belongs_to relation from one object to another.
655
     * Note: This will force a write on both parent / child objects.
656
     *
657
     * @param DataObject $sourceObject
658
     * @param DataObject $destinationObject
659
     * @param string $relation
660
     */
661
    protected function duplicateBelongsToRelation($sourceObject, $destinationObject, $relation)
662
    {
663
        // Check if original object exists
664
        $item = $sourceObject->getComponent($relation);
665
        if (!$item->isInDB()) {
666
            return;
667
        }
668
669
        $clonedItem = $item->duplicate(false);
670
        $destinationObject->setComponent($relation, $clonedItem);
671
        // After $clonedItem is assigned the appropriate FieldID / FieldClass, force write
672
        // @todo Write this component in onAfterWrite instead, assigning the FieldID then
673
        // https://github.com/silverstripe/silverstripe-framework/issues/7818
674
        $clonedItem->write();
675
    }
676
677
    /**
678
     * Return obsolete class name, if this is no longer a valid class
679
     *
680
     * @return string
681
     */
682
    public function getObsoleteClassName()
683
    {
684
        $className = $this->getField("ClassName");
685
        if (!ClassInfo::exists($className)) {
686
            return $className;
687
        }
688
        return null;
689
    }
690
691
    /**
692
     * Gets name of this class
693
     *
694
     * @return string
695
     */
696
    public function getClassName()
697
    {
698
        $className = $this->getField("ClassName");
699
        if (!ClassInfo::exists($className)) {
700
            return static::class;
701
        }
702
        return $className;
703
    }
704
705
    /**
706
     * Set the ClassName attribute. {@link $class} is also updated.
707
     * Warning: This will produce an inconsistent record, as the object
708
     * instance will not automatically switch to the new subclass.
709
     * Please use {@link newClassInstance()} for this purpose,
710
     * or destroy and reinstanciate the record.
711
     *
712
     * @param string $className The new ClassName attribute (a subclass of {@link DataObject})
713
     * @return $this
714
     */
715
    public function setClassName($className)
716
    {
717
        $className = trim($className);
718
        if (!$className || !is_subclass_of($className, self::class)) {
719
            return $this;
720
        }
721
722
        $this->setField("ClassName", $className);
723
        $this->setField('RecordClassName', $className);
724
        return $this;
725
    }
726
727
    /**
728
     * Create a new instance of a different class from this object's record.
729
     * This is useful when dynamically changing the type of an instance. Specifically,
730
     * it ensures that the instance of the class is a match for the className of the
731
     * record. Don't set the {@link DataObject->class} or {@link DataObject->ClassName}
732
     * property manually before calling this method, as it will confuse change detection.
733
     *
734
     * If the new class is different to the original class, defaults are populated again
735
     * because this will only occur automatically on instantiation of a DataObject if
736
     * there is no record, or the record has no ID. In this case, we do have an ID but
737
     * we still need to repopulate the defaults.
738
     *
739
     * @param string $newClassName The name of the new class
740
     *
741
     * @return DataObject The new instance of the new class, The exact type will be of the class name provided.
742
     */
743
    public function newClassInstance($newClassName)
744
    {
745
        if (!is_subclass_of($newClassName, self::class)) {
746
            throw new InvalidArgumentException("$newClassName is not a valid subclass of DataObject");
747
        }
748
749
        $originalClass = $this->ClassName;
750
751
        /** @var DataObject $newInstance */
752
        $newInstance = Injector::inst()->create($newClassName, $this->record, self::CREATE_HYDRATED);
753
754
        // Modify ClassName
755
        if ($newClassName != $originalClass) {
756
            $newInstance->setClassName($newClassName);
757
            $newInstance->populateDefaults();
758
            $newInstance->forceChange();
759
        }
760
761
        return $newInstance;
762
    }
763
764
    /**
765
     * Adds methods from the extensions.
766
     * Called by Object::__construct() once per class.
767
     */
768
    public function defineMethods()
769
    {
770
        parent::defineMethods();
771
772
        if (static::class === self::class) {
0 ignored issues
show
introduced by
The condition static::class === self::class is always true.
Loading history...
773
            return;
774
        }
775
776
        // Set up accessors for joined items
777
        if ($manyMany = $this->manyMany()) {
778
            foreach ($manyMany as $relationship => $class) {
779
                $this->addWrapperMethod($relationship, 'getManyManyComponents');
780
            }
781
        }
782
        if ($hasMany = $this->hasMany()) {
783
            foreach ($hasMany as $relationship => $class) {
784
                $this->addWrapperMethod($relationship, 'getComponents');
785
            }
786
        }
787
        if ($hasOne = $this->hasOne()) {
788
            foreach ($hasOne as $relationship => $class) {
789
                $this->addWrapperMethod($relationship, 'getComponent');
790
            }
791
        }
792
        if ($belongsTo = $this->belongsTo()) {
793
            foreach (array_keys($belongsTo) as $relationship) {
794
                $this->addWrapperMethod($relationship, 'getComponent');
795
            }
796
        }
797
    }
798
799
    /**
800
     * Returns true if this object "exists", i.e., has a sensible value.
801
     * The default behaviour for a DataObject is to return true if
802
     * the object exists in the database, you can override this in subclasses.
803
     *
804
     * @return boolean true if this object exists
805
     */
806
    public function exists()
807
    {
808
        return (isset($this->record['ID']) && $this->record['ID'] > 0);
809
    }
810
811
    /**
812
     * Returns TRUE if all values (other than "ID") are
813
     * considered empty (by weak boolean comparison).
814
     *
815
     * @return boolean
816
     */
817
    public function isEmpty()
818
    {
819
        $fixed = DataObject::config()->uninherited('fixed_fields');
820
        foreach ($this->toMap() as $field => $value) {
821
            // only look at custom fields
822
            if (isset($fixed[$field])) {
823
                continue;
824
            }
825
826
            $dbObject = $this->dbObject($field);
827
            if (!$dbObject) {
828
                continue;
829
            }
830
            if ($dbObject->exists()) {
831
                return false;
832
            }
833
        }
834
        return true;
835
    }
836
837
    /**
838
     * Pluralise this item given a specific count.
839
     *
840
     * E.g. "0 Pages", "1 File", "3 Images"
841
     *
842
     * @param string $count
843
     * @return string
844
     */
845
    public function i18n_pluralise($count)
846
    {
847
        $default = 'one ' . $this->i18n_singular_name() . '|{count} ' . $this->i18n_plural_name();
848
        return i18n::_t(
849
            static::class . '.PLURALS',
850
            $default,
851
            ['count' => $count]
852
        );
853
    }
854
855
    /**
856
     * Get the user friendly singular name of this DataObject.
857
     * If the name is not defined (by redefining $singular_name in the subclass),
858
     * this returns the class name.
859
     *
860
     * @return string User friendly singular name of this DataObject
861
     */
862
    public function singular_name()
863
    {
864
        $name = $this->config()->get('singular_name');
865
        if ($name) {
866
            return $name;
867
        }
868
        return ucwords(trim(strtolower(preg_replace(
869
            '/_?([A-Z])/',
870
            ' $1',
871
            ClassInfo::shortName($this)
872
        ))));
873
    }
874
875
    /**
876
     * Get the translated user friendly singular name of this DataObject
877
     * same as singular_name() but runs it through the translating function
878
     *
879
     * Translating string is in the form:
880
     *     $this->class.SINGULARNAME
881
     * Example:
882
     *     Page.SINGULARNAME
883
     *
884
     * @return string User friendly translated singular name of this DataObject
885
     */
886
    public function i18n_singular_name()
887
    {
888
        return _t(static::class . '.SINGULARNAME', $this->singular_name());
889
    }
890
891
    /**
892
     * Get the user friendly plural name of this DataObject
893
     * If the name is not defined (by renaming $plural_name in the subclass),
894
     * this returns a pluralised version of the class name.
895
     *
896
     * @return string User friendly plural name of this DataObject
897
     */
898
    public function plural_name()
899
    {
900
        if ($name = $this->config()->get('plural_name')) {
901
            return $name;
902
        }
903
        $name = $this->singular_name();
904
        //if the penultimate character is not a vowel, replace "y" with "ies"
905
        if (preg_match('/[^aeiou]y$/i', $name)) {
906
            $name = substr($name, 0, -1) . 'ie';
907
        }
908
        return ucfirst($name . 's');
909
    }
910
911
    /**
912
     * Get the translated user friendly plural name of this DataObject
913
     * Same as plural_name but runs it through the translation function
914
     * Translation string is in the form:
915
     *      $this->class.PLURALNAME
916
     * Example:
917
     *      Page.PLURALNAME
918
     *
919
     * @return string User friendly translated plural name of this DataObject
920
     */
921
    public function i18n_plural_name()
922
    {
923
        return _t(static::class . '.PLURALNAME', $this->plural_name());
924
    }
925
926
    /**
927
     * Standard implementation of a title/label for a specific
928
     * record. Tries to find properties 'Title' or 'Name',
929
     * and falls back to the 'ID'. Useful to provide
930
     * user-friendly identification of a record, e.g. in errormessages
931
     * or UI-selections.
932
     *
933
     * Overload this method to have a more specialized implementation,
934
     * e.g. for an Address record this could be:
935
     * <code>
936
     * function getTitle() {
937
     *   return "{$this->StreetNumber} {$this->StreetName} {$this->City}";
938
     * }
939
     * </code>
940
     *
941
     * @return string
942
     */
943
    public function getTitle()
944
    {
945
        $schema = static::getSchema();
946
        if ($schema->fieldSpec($this, 'Title')) {
947
            return $this->getField('Title');
948
        }
949
        if ($schema->fieldSpec($this, 'Name')) {
950
            return $this->getField('Name');
951
        }
952
953
        return "#{$this->ID}";
954
    }
955
956
    /**
957
     * Returns the associated database record - in this case, the object itself.
958
     * This is included so that you can call $dataOrController->data() and get a DataObject all the time.
959
     *
960
     * @return DataObject Associated database record
961
     */
962
    public function data()
963
    {
964
        return $this;
965
    }
966
967
    /**
968
     * Convert this object to a map.
969
     * Note that it has the following quirks:
970
     *  - custom getters, including those that adjust the result of database fields, won't be executed
971
     *  - NULL values won't be returned.
972
     *
973
     * @return array The data as a map.
974
     */
975
    public function toMap()
976
    {
977
        $this->loadLazyFields();
978
        return array_filter($this->record, function ($val) {
979
            return $val !== null;
980
        });
981
    }
982
983
    /**
984
     * Return all currently fetched database fields.
985
     *
986
     * This function is similar to toMap() but doesn't trigger the lazy-loading of all unfetched fields.
987
     * Obviously, this makes it a lot faster.
988
     *
989
     * @return array The data as a map.
990
     */
991
    public function getQueriedDatabaseFields()
992
    {
993
        return $this->record;
994
    }
995
996
    /**
997
     * Update a number of fields on this object, given a map of the desired changes.
998
     *
999
     * The field names can be simple names, or you can use a dot syntax to access $has_one relations.
1000
     * For example, array("Author.FirstName" => "Jim") will set $this->Author()->FirstName to "Jim".
1001
     *
1002
     * Doesn't write the main object, but if you use the dot syntax, it will write()
1003
     * the related objects that it alters.
1004
     *
1005
     * When using this method with user supplied data, it's very important to
1006
     * whitelist the allowed keys.
1007
     *
1008
     * @param array $data A map of field name to data values to update.
1009
     * @return DataObject $this
1010
     */
1011
    public function update($data)
1012
    {
1013
        foreach ($data as $key => $value) {
1014
            // Implement dot syntax for updates
1015
            if (strpos($key, '.') !== false) {
1016
                $relations = explode('.', $key);
1017
                $fieldName = array_pop($relations);
1018
                /** @var static $relObj */
1019
                $relObj = $this;
1020
                $relation = null;
1021
                foreach ($relations as $i => $relation) {
1022
                    // no support for has_many or many_many relationships,
1023
                    // as the updater wouldn't know which object to write to (or create)
1024
                    if ($relObj->$relation() instanceof DataObject) {
1025
                        $parentObj = $relObj;
1026
                        $relObj = $relObj->$relation();
1027
                        // If the intermediate relationship objects haven't been created, then write them
1028
                        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...
1029
                            $relObj->write();
1030
                            $relatedFieldName = $relation . "ID";
1031
                            $parentObj->$relatedFieldName = $relObj->ID;
1032
                            $parentObj->write();
1033
                        }
1034
                    } else {
1035
                        user_error(
1036
                            "DataObject::update(): Can't traverse relationship '$relation'," .
1037
                            "it has to be a has_one relationship or return a single DataObject",
1038
                            E_USER_NOTICE
1039
                        );
1040
                        // unset relation object so we don't write properties to the wrong object
1041
                        $relObj = null;
1042
                        break;
1043
                    }
1044
                }
1045
1046
                if ($relObj) {
1047
                    $relObj->$fieldName = $value;
1048
                    $relObj->write();
1049
                    $relatedFieldName = $relation . "ID";
1050
                    $this->$relatedFieldName = $relObj->ID;
1051
                    $relObj->flushCache();
1052
                } else {
1053
                    $class = static::class;
1054
                    user_error("Couldn't follow dot syntax '{$key}' on '{$class}' object", E_USER_WARNING);
1055
                }
1056
            } else {
1057
                $this->$key = $value;
1058
            }
1059
        }
1060
        return $this;
1061
    }
1062
1063
    /**
1064
     * Pass changes as a map, and try to
1065
     * get automatic casting for these fields.
1066
     * Doesn't write to the database. To write the data,
1067
     * use the write() method.
1068
     *
1069
     * @param array $data A map of field name to data values to update.
1070
     * @return DataObject $this
1071
     */
1072
    public function castedUpdate($data)
1073
    {
1074
        foreach ($data as $k => $v) {
1075
            $this->setCastedField($k, $v);
1076
        }
1077
        return $this;
1078
    }
1079
1080
    /**
1081
     * Merges data and relations from another object of same class,
1082
     * without conflict resolution. Allows to specify which
1083
     * dataset takes priority in case its not empty.
1084
     * has_one-relations are just transferred with priority 'right'.
1085
     * has_many and many_many-relations are added regardless of priority.
1086
     *
1087
     * Caution: has_many/many_many relations are moved rather than duplicated,
1088
     * meaning they are not connected to the merged object any longer.
1089
     * Caution: Just saves updated has_many/many_many relations to the database,
1090
     * doesn't write the updated object itself (just writes the object-properties).
1091
     * Caution: Does not delete the merged object.
1092
     * Caution: Does now overwrite Created date on the original object.
1093
     *
1094
     * @param DataObject $rightObj
1095
     * @param string $priority left|right Determines who wins in case of a conflict (optional)
1096
     * @param bool $includeRelations Merge any existing relations (optional)
1097
     * @param bool $overwriteWithEmpty Overwrite existing left values with empty right values.
1098
     *                            Only applicable with $priority='right'. (optional)
1099
     * @return Boolean
1100
     */
1101
    public function merge($rightObj, $priority = 'right', $includeRelations = true, $overwriteWithEmpty = false)
1102
    {
1103
        $leftObj = $this;
1104
1105
        if ($leftObj->ClassName != $rightObj->ClassName) {
1106
            // we can't merge similiar subclasses because they might have additional relations
1107
            user_error("DataObject->merge(): Invalid object class '{$rightObj->ClassName}'
1108
			(expected '{$leftObj->ClassName}').", E_USER_WARNING);
1109
            return false;
1110
        }
1111
1112
        if (!$rightObj->ID) {
1113
            user_error("DataObject->merge(): Please write your merged-in object to the database before merging,
1114
				to make sure all relations are transferred properly.').", E_USER_WARNING);
1115
            return false;
1116
        }
1117
1118
        // makes sure we don't merge data like ID or ClassName
1119
        $rightData = DataObject::getSchema()->fieldSpecs(get_class($rightObj));
1120
        foreach ($rightData as $key => $rightSpec) {
1121
            // Don't merge ID
1122
            if ($key === 'ID') {
1123
                continue;
1124
            }
1125
1126
            // Only merge relations if allowed
1127
            if ($rightSpec === 'ForeignKey' && !$includeRelations) {
1128
                continue;
1129
            }
1130
1131
            // don't merge conflicting values if priority is 'left'
1132
            if ($priority == 'left' && $leftObj->{$key} !== $rightObj->{$key}) {
1133
                continue;
1134
            }
1135
1136
            // don't overwrite existing left values with empty right values (if $overwriteWithEmpty is set)
1137
            if ($priority == 'right' && !$overwriteWithEmpty && empty($rightObj->{$key})) {
1138
                continue;
1139
            }
1140
1141
            // TODO remove redundant merge of has_one fields
1142
            $leftObj->{$key} = $rightObj->{$key};
1143
        }
1144
1145
        // merge relations
1146
        if ($includeRelations) {
1147
            if ($manyMany = $this->manyMany()) {
1148
                foreach ($manyMany as $relationship => $class) {
1149
                    /** @var DataObject $leftComponents */
1150
                    $leftComponents = $leftObj->getManyManyComponents($relationship);
1151
                    $rightComponents = $rightObj->getManyManyComponents($relationship);
1152
                    if ($rightComponents && $rightComponents->exists()) {
1153
                        $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

1153
                        $leftComponents->/** @scrutinizer ignore-call */ 
1154
                                         addMany($rightComponents->column('ID'));
Loading history...
1154
                    }
1155
                    $leftComponents->write();
1156
                }
1157
            }
1158
1159
            if ($hasMany = $this->hasMany()) {
1160
                foreach ($hasMany as $relationship => $class) {
1161
                    $leftComponents = $leftObj->getComponents($relationship);
1162
                    $rightComponents = $rightObj->getComponents($relationship);
1163
                    if ($rightComponents && $rightComponents->exists()) {
1164
                        $leftComponents->addMany($rightComponents->column('ID'));
1165
                    }
1166
                    $leftComponents->write();
0 ignored issues
show
Bug introduced by
The method write() does not exist on SilverStripe\ORM\UnsavedRelationList. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

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

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

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

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

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

2524
            $compositeValidator->addValidator($this->/** @scrutinizer ignore-call */ getCMSValidator());
Loading history...
2525
        }
2526
2527
        // Extend validator - forward support, will be supported beyond 5.0.0
2528
        $this->invokeWithExtensions('updateCMSCompositeValidator', $compositeValidator);
2529
2530
        return $compositeValidator;
2531
    }
2532
2533
    /**
2534
     * Used for simple frontend forms without relation editing
2535
     * or {@link TabSet} behaviour. Uses {@link scaffoldFormFields()}
2536
     * by default. To customize, either overload this method in your
2537
     * subclass, or extend it by {@link DataExtension->updateFrontEndFields()}.
2538
     *
2539
     * @todo Decide on naming for "website|frontend|site|page" and stick with it in the API
2540
     *
2541
     * @param array $params See {@link scaffoldFormFields()}
2542
     * @return FieldList Always returns a simple field collection without TabSet.
2543
     */
2544
    public function getFrontEndFields($params = null)
2545
    {
2546
        $untabbedFields = $this->scaffoldFormFields($params);
2547
        $this->extend('updateFrontEndFields', $untabbedFields);
2548
2549
        return $untabbedFields;
2550
    }
2551
2552
    public function getViewerTemplates($suffix = '')
2553
    {
2554
        return SSViewer::get_templates_by_class(static::class, $suffix, $this->baseClass());
2555
    }
2556
2557
    /**
2558
     * Gets the value of a field.
2559
     * Called by {@link __get()} and any getFieldName() methods you might create.
2560
     *
2561
     * @param string $field The name of the field
2562
     * @return mixed The field value
2563
     */
2564
    public function getField($field)
2565
    {
2566
        // If we already have a value in $this->record, then we should just return that
2567
        if (isset($this->record[$field])) {
2568
            return $this->record[$field];
2569
        }
2570
2571
        // Do we have a field that needs to be lazy loaded?
2572
        if (isset($this->record[$field . '_Lazy'])) {
2573
            $tableClass = $this->record[$field . '_Lazy'];
2574
            $this->loadLazyFields($tableClass);
2575
        }
2576
        $schema = static::getSchema();
2577
2578
        // Support unary relations as fields
2579
        if ($schema->unaryComponent(static::class, $field)) {
2580
            return $this->getComponent($field);
2581
        }
2582
2583
        // In case of complex fields, return the DBField object
2584
        if ($schema->compositeField(static::class, $field)) {
2585
            $this->record[$field] = $this->dbObject($field);
2586
        }
2587
2588
        return isset($this->record[$field]) ? $this->record[$field] : null;
2589
    }
2590
2591
    /**
2592
     * Loads all the stub fields that an initial lazy load didn't load fully.
2593
     *
2594
     * @param string $class Class to load the values from. Others are joined as required.
2595
     * Not specifying a tableClass will load all lazy fields from all tables.
2596
     * @return bool Flag if lazy loading succeeded
2597
     */
2598
    protected function loadLazyFields($class = null)
2599
    {
2600
        if (!$this->isInDB() || !is_numeric($this->ID)) {
2601
            return false;
2602
        }
2603
2604
        if (!$class) {
2605
            $loaded = [];
2606
2607
            foreach ($this->record as $key => $value) {
2608
                if (strlen($key) > 5 && substr($key, -5) == '_Lazy' && !array_key_exists($value, $loaded)) {
2609
                    $this->loadLazyFields($value);
2610
                    $loaded[$value] = $value;
2611
                }
2612
            }
2613
2614
            return false;
2615
        }
2616
2617
        $dataQuery = new DataQuery($class);
2618
2619
        // Reset query parameter context to that of this DataObject
2620
        if ($params = $this->getSourceQueryParams()) {
2621
            foreach ($params as $key => $value) {
2622
                $dataQuery->setQueryParam($key, $value);
2623
            }
2624
        }
2625
2626
        // Limit query to the current record, unless it has the Versioned extension,
2627
        // in which case it requires special handling through augmentLoadLazyFields()
2628
        $schema = static::getSchema();
2629
        $baseIDColumn = $schema->sqlColumnForField($this, 'ID');
2630
        $dataQuery->where([
2631
            $baseIDColumn => $this->record['ID']
2632
        ])->limit(1);
2633
2634
        $columns = [];
2635
2636
        // Add SQL for fields, both simple & multi-value
2637
        // TODO: This is copy & pasted from buildSQL(), it could be moved into a method
2638
        $databaseFields = $schema->databaseFields($class, false);
2639
        foreach ($databaseFields as $k => $v) {
2640
            if (!isset($this->record[$k]) || $this->record[$k] === null) {
2641
                $columns[] = $k;
2642
            }
2643
        }
2644
2645
        if ($columns) {
2646
            $query = $dataQuery->query();
2647
            $this->extend('augmentLoadLazyFields', $query, $dataQuery, $this);
2648
            $this->extend('augmentSQL', $query, $dataQuery);
2649
2650
            $dataQuery->setQueriedColumns($columns);
2651
            $newData = $dataQuery->execute()->record();
2652
2653
            // Load the data into record
2654
            if ($newData) {
2655
                foreach ($newData as $k => $v) {
2656
                    if (in_array($k, $columns)) {
2657
                        $this->record[$k] = $v;
2658
                        $this->original[$k] = $v;
2659
                        unset($this->record[$k . '_Lazy']);
2660
                    }
2661
                }
2662
2663
            // No data means that the query returned nothing; assign 'null' to all the requested fields
2664
            } else {
2665
                foreach ($columns as $k) {
2666
                    $this->record[$k] = null;
2667
                    $this->original[$k] = null;
2668
                    unset($this->record[$k . '_Lazy']);
2669
                }
2670
            }
2671
        }
2672
        return true;
2673
    }
2674
2675
    /**
2676
     * Return the fields that have changed since the last write.
2677
     *
2678
     * The change level affects what the functions defines as "changed":
2679
     * - Level CHANGE_STRICT (integer 1) will return strict changes, even !== ones.
2680
     * - Level CHANGE_VALUE (integer 2) is more lenient, it will only return real data changes,
2681
     *   for example a change from 0 to null would not be included.
2682
     *
2683
     * Example return:
2684
     * <code>
2685
     * array(
2686
     *   'Title' = array('before' => 'Home', 'after' => 'Home-Changed', 'level' => DataObject::CHANGE_VALUE)
2687
     * )
2688
     * </code>
2689
     *
2690
     * @param boolean|array $databaseFieldsOnly Filter to determine which fields to return. Set to true
2691
     * to return all database fields, or an array for an explicit filter. false returns all fields.
2692
     * @param int $changeLevel The strictness of what is defined as change. Defaults to strict
2693
     * @return array
2694
     */
2695
    public function getChangedFields($databaseFieldsOnly = false, $changeLevel = self::CHANGE_STRICT)
2696
    {
2697
        $changedFields = [];
2698
2699
        // Update the changed array with references to changed obj-fields
2700
        foreach ($this->record as $k => $v) {
2701
            // Prevents DBComposite infinite looping on isChanged
2702
            if (is_array($databaseFieldsOnly) && !in_array($k, $databaseFieldsOnly)) {
2703
                continue;
2704
            }
2705
            if (is_object($v) && method_exists($v, 'isChanged') && $v->isChanged()) {
2706
                $this->changed[$k] = self::CHANGE_VALUE;
2707
            }
2708
        }
2709
2710
        // If change was forced, then derive change data from $this->record
2711
        if ($this->changeForced && $changeLevel <= self::CHANGE_STRICT) {
2712
            $changed = array_combine(
2713
                array_keys($this->record),
2714
                array_fill(0, count($this->record), self::CHANGE_STRICT)
2715
            );
2716
            // @todo Find better way to allow versioned to write a new version after forceChange
2717
            unset($changed['Version']);
2718
        } else {
2719
            $changed = $this->changed;
2720
        }
2721
2722
        if (is_array($databaseFieldsOnly)) {
2723
            $fields = array_intersect_key($changed, array_flip($databaseFieldsOnly));
0 ignored issues
show
Bug introduced by
It seems like $changed can also be of type false; however, parameter $array1 of array_intersect_key() 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

2723
            $fields = array_intersect_key(/** @scrutinizer ignore-type */ $changed, array_flip($databaseFieldsOnly));
Loading history...
2724
        } elseif ($databaseFieldsOnly) {
2725
            $fieldsSpecs = static::getSchema()->fieldSpecs(static::class);
2726
            $fields = array_intersect_key($changed, $fieldsSpecs);
2727
        } else {
2728
            $fields = $changed;
2729
        }
2730
2731
        // Filter the list to those of a certain change level
2732
        if ($changeLevel > self::CHANGE_STRICT) {
2733
            if ($fields) {
2734
                foreach ($fields as $name => $level) {
2735
                    if ($level < $changeLevel) {
2736
                        unset($fields[$name]);
2737
                    }
2738
                }
2739
            }
2740
        }
2741
2742
        if ($fields) {
2743
            foreach ($fields as $name => $level) {
2744
                $changedFields[$name] = [
2745
                    'before' => array_key_exists($name, $this->original) ? $this->original[$name] : null,
2746
                    'after' => array_key_exists($name, $this->record) ? $this->record[$name] : null,
2747
                    'level' => $level
2748
                ];
2749
            }
2750
        }
2751
2752
        return $changedFields;
2753
    }
2754
2755
    /**
2756
     * Uses {@link getChangedFields()} to determine if fields have been changed
2757
     * since loading them from the database.
2758
     *
2759
     * @param string $fieldName Name of the database field to check, will check for any if not given
2760
     * @param int $changeLevel See {@link getChangedFields()}
2761
     * @return boolean
2762
     */
2763
    public function isChanged($fieldName = null, $changeLevel = self::CHANGE_STRICT)
2764
    {
2765
        $fields = $fieldName ? [$fieldName] : true;
2766
        $changed = $this->getChangedFields($fields, $changeLevel);
2767
        if (!isset($fieldName)) {
2768
            return !empty($changed);
2769
        } else {
2770
            return array_key_exists($fieldName, $changed);
2771
        }
2772
    }
2773
2774
    /**
2775
     * Set the value of the field
2776
     * Called by {@link __set()} and any setFieldName() methods you might create.
2777
     *
2778
     * @param string $fieldName Name of the field
2779
     * @param mixed $val New field value
2780
     * @return $this
2781
     */
2782
    public function setField($fieldName, $val)
2783
    {
2784
        $this->objCacheClear();
2785
        //if it's a has_one component, destroy the cache
2786
        if (substr($fieldName, -2) == 'ID') {
2787
            unset($this->components[substr($fieldName, 0, -2)]);
2788
        }
2789
2790
        // If we've just lazy-loaded the column, then we need to populate the $original array
2791
        if (isset($this->record[$fieldName . '_Lazy'])) {
2792
            $tableClass = $this->record[$fieldName . '_Lazy'];
2793
            $this->loadLazyFields($tableClass);
2794
        }
2795
2796
        // Support component assignent via field setter
2797
        $schema = static::getSchema();
2798
        if ($schema->unaryComponent(static::class, $fieldName)) {
2799
            unset($this->components[$fieldName]);
2800
            // Assign component directly
2801
            if (is_null($val) || $val instanceof DataObject) {
2802
                return $this->setComponent($fieldName, $val);
2803
            }
2804
            // Assign by ID instead of object
2805
            if (is_numeric($val)) {
2806
                $fieldName .= 'ID';
2807
            }
2808
        }
2809
2810
        // Situation 1: Passing an DBField
2811
        if ($val instanceof DBField) {
2812
            $val->setName($fieldName);
2813
            $val->saveInto($this);
2814
2815
            // Situation 1a: Composite fields should remain bound in case they are
2816
            // later referenced to update the parent dataobject
2817
            if ($val instanceof DBComposite) {
2818
                $val->bindTo($this);
2819
                $this->record[$fieldName] = $val;
2820
            }
2821
        // Situation 2: Passing a literal or non-DBField object
2822
        } else {
2823
            // If this is a proper database field, we shouldn't be getting non-DBField objects
2824
            if (is_object($val) && $schema->fieldSpec(static::class, $fieldName)) {
2825
                throw new InvalidArgumentException('DataObject::setField: passed an object that is not a DBField');
2826
            }
2827
2828
            if (!empty($val) && !is_scalar($val)) {
2829
                $dbField = $this->dbObject($fieldName);
2830
                if ($dbField && $dbField->scalarValueOnly()) {
2831
                    throw new InvalidArgumentException(
2832
                        sprintf(
2833
                            'DataObject::setField: %s only accepts scalars',
2834
                            $fieldName
2835
                        )
2836
                    );
2837
                }
2838
            }
2839
2840
            // if a field is not existing or has strictly changed
2841
            if (!array_key_exists($fieldName, $this->original) || $this->original[$fieldName] !== $val) {
2842
                // TODO Add check for php-level defaults which are not set in the db
2843
                // TODO Add check for hidden input-fields (readonly) which are not set in the db
2844
                // At the very least, the type has changed
2845
                $this->changed[$fieldName] = self::CHANGE_STRICT;
2846
2847
                if ((!array_key_exists($fieldName, $this->original) && $val)
2848
                    || (array_key_exists($fieldName, $this->original) && $this->original[$fieldName] != $val)
2849
                ) {
2850
                    // Value has changed as well, not just the type
2851
                    $this->changed[$fieldName] = self::CHANGE_VALUE;
2852
                }
2853
            // Value has been restored to its original, remove any record of the change
2854
            } elseif (isset($this->changed[$fieldName])) {
2855
                unset($this->changed[$fieldName]);
2856
            }
2857
2858
            // Value is saved regardless, since the change detection relates to the last write
2859
            $this->record[$fieldName] = $val;
2860
        }
2861
        return $this;
2862
    }
2863
2864
    /**
2865
     * Set the value of the field, using a casting object.
2866
     * This is useful when you aren't sure that a date is in SQL format, for example.
2867
     * setCastedField() can also be used, by forms, to set related data.  For example, uploaded images
2868
     * can be saved into the Image table.
2869
     *
2870
     * @param string $fieldName Name of the field
2871
     * @param mixed $value New field value
2872
     * @return $this
2873
     */
2874
    public function setCastedField($fieldName, $value)
2875
    {
2876
        if (!$fieldName) {
2877
            user_error("DataObject::setCastedField: Called without a fieldName", E_USER_ERROR);
2878
        }
2879
        $fieldObj = $this->dbObject($fieldName);
2880
        if ($fieldObj) {
0 ignored issues
show
introduced by
$fieldObj is of type SilverStripe\ORM\FieldType\DBField, thus it always evaluated to true.
Loading history...
2881
            $fieldObj->setValue($value);
2882
            $fieldObj->saveInto($this);
2883
        } else {
2884
            $this->$fieldName = $value;
2885
        }
2886
        return $this;
2887
    }
2888
2889
    /**
2890
     * {@inheritdoc}
2891
     */
2892
    public function castingHelper($field)
2893
    {
2894
        $fieldSpec = static::getSchema()->fieldSpec(static::class, $field);
2895
        if ($fieldSpec) {
2896
            return $fieldSpec;
2897
        }
2898
2899
        // many_many_extraFields aren't presented by db(), so we check if the source query params
2900
        // provide us with meta-data for a many_many relation we can inspect for extra fields.
2901
        $queryParams = $this->getSourceQueryParams();
2902
        if (!empty($queryParams['Component.ExtraFields'])) {
2903
            $extraFields = $queryParams['Component.ExtraFields'];
2904
2905
            if (isset($extraFields[$field])) {
2906
                return $extraFields[$field];
2907
            }
2908
        }
2909
2910
        return parent::castingHelper($field);
2911
    }
2912
2913
    /**
2914
     * Returns true if the given field exists in a database column on any of
2915
     * the objects tables and optionally look up a dynamic getter with
2916
     * get<fieldName>().
2917
     *
2918
     * @param string $field Name of the field
2919
     * @return boolean True if the given field exists
2920
     */
2921
    public function hasField($field)
2922
    {
2923
        $schema = static::getSchema();
2924
        return (
2925
            array_key_exists($field, $this->record)
2926
            || array_key_exists($field, $this->components)
2927
            || $schema->fieldSpec(static::class, $field)
2928
            || $schema->unaryComponent(static::class, $field)
2929
            || $this->hasMethod("get{$field}")
2930
        );
2931
    }
2932
2933
    /**
2934
     * Returns true if the given field exists as a database column
2935
     *
2936
     * @param string $field Name of the field
2937
     *
2938
     * @return boolean
2939
     */
2940
    public function hasDatabaseField($field)
2941
    {
2942
        $spec = static::getSchema()->fieldSpec(static::class, $field, DataObjectSchema::DB_ONLY);
2943
        return !empty($spec);
2944
    }
2945
2946
    /**
2947
     * Returns true if the member is allowed to do the given action.
2948
     * See {@link extendedCan()} for a more versatile tri-state permission control.
2949
     *
2950
     * @param string $perm The permission to be checked, such as 'View'.
2951
     * @param Member $member The member whose permissions need checking.  Defaults to the currently logged
2952
     * in user.
2953
     * @param array $context Additional $context to pass to extendedCan()
2954
     *
2955
     * @return boolean True if the the member is allowed to do the given action
2956
     */
2957
    public function can($perm, $member = null, $context = [])
2958
    {
2959
        if (!$member) {
2960
            $member = Security::getCurrentUser();
2961
        }
2962
2963
        if ($member && Permission::checkMember($member, "ADMIN")) {
2964
            return true;
2965
        }
2966
2967
        if (is_string($perm) && method_exists($this, 'can' . ucfirst($perm))) {
2968
            $method = 'can' . ucfirst($perm);
2969
            return $this->$method($member);
2970
        }
2971
2972
        $results = $this->extendedCan('can', $member);
2973
        if (isset($results)) {
2974
            return $results;
2975
        }
2976
2977
        return ($member && Permission::checkMember($member, $perm));
2978
    }
2979
2980
    /**
2981
     * Process tri-state responses from permission-alterting extensions.  The extensions are
2982
     * expected to return one of three values:
2983
     *
2984
     *  - false: Disallow this permission, regardless of what other extensions say
2985
     *  - true: Allow this permission, as long as no other extensions return false
2986
     *  - NULL: Don't affect the outcome
2987
     *
2988
     * This method itself returns a tri-state value, and is designed to be used like this:
2989
     *
2990
     * <code>
2991
     * $extended = $this->extendedCan('canDoSomething', $member);
2992
     * if($extended !== null) return $extended;
2993
     * else return $normalValue;
2994
     * </code>
2995
     *
2996
     * @param string $methodName Method on the same object, e.g. {@link canEdit()}
2997
     * @param Member|int $member
2998
     * @param array $context Optional context
2999
     * @return boolean|null
3000
     */
3001
    public function extendedCan($methodName, $member, $context = [])
3002
    {
3003
        $results = $this->extend($methodName, $member, $context);
3004
        if ($results && is_array($results)) {
3005
            // Remove NULLs
3006
            $results = array_filter($results, function ($v) {
3007
                return !is_null($v);
3008
            });
3009
            // If there are any non-NULL responses, then return the lowest one of them.
3010
            // If any explicitly deny the permission, then we don't get access
3011
            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...
3012
                return min($results);
3013
            }
3014
        }
3015
        return null;
3016
    }
3017
3018
    /**
3019
     * @param Member $member
3020
     * @return boolean
3021
     */
3022
    public function canView($member = null)
3023
    {
3024
        $extended = $this->extendedCan(__FUNCTION__, $member);
3025
        if ($extended !== null) {
3026
            return $extended;
3027
        }
3028
        return Permission::check('ADMIN', 'any', $member);
3029
    }
3030
3031
    /**
3032
     * @param Member $member
3033
     * @return boolean
3034
     */
3035
    public function canEdit($member = null)
3036
    {
3037
        $extended = $this->extendedCan(__FUNCTION__, $member);
3038
        if ($extended !== null) {
3039
            return $extended;
3040
        }
3041
        return Permission::check('ADMIN', 'any', $member);
3042
    }
3043
3044
    /**
3045
     * @param Member $member
3046
     * @return boolean
3047
     */
3048
    public function canDelete($member = null)
3049
    {
3050
        $extended = $this->extendedCan(__FUNCTION__, $member);
3051
        if ($extended !== null) {
3052
            return $extended;
3053
        }
3054
        return Permission::check('ADMIN', 'any', $member);
3055
    }
3056
3057
    /**
3058
     * @param Member $member
3059
     * @param array $context Additional context-specific data which might
3060
     * affect whether (or where) this object could be created.
3061
     * @return boolean
3062
     */
3063
    public function canCreate($member = null, $context = [])
3064
    {
3065
        $extended = $this->extendedCan(__FUNCTION__, $member, $context);
3066
        if ($extended !== null) {
3067
            return $extended;
3068
        }
3069
        return Permission::check('ADMIN', 'any', $member);
3070
    }
3071
3072
    /**
3073
     * Debugging used by Debug::show()
3074
     *
3075
     * @return string HTML data representing this object
3076
     */
3077
    public function debug()
3078
    {
3079
        $class = static::class;
3080
        $val = "<h3>Database record: {$class}</h3>\n<ul>\n";
3081
        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...
3082
            foreach ($this->record as $fieldName => $fieldVal) {
3083
                $val .= "\t<li>$fieldName: " . Debug::text($fieldVal) . "</li>\n";
3084
            }
3085
        }
3086
        $val .= "</ul>\n";
3087
        return $val;
3088
    }
3089
3090
    /**
3091
     * Return the DBField object that represents the given field.
3092
     * This works similarly to obj() with 2 key differences:
3093
     *   - it still returns an object even when the field has no value.
3094
     *   - it only matches fields and not methods
3095
     *   - it matches foreign keys generated by has_one relationships, eg, "ParentID"
3096
     *
3097
     * @param string $fieldName Name of the field
3098
     * @return DBField The field as a DBField object
3099
     */
3100
    public function dbObject($fieldName)
3101
    {
3102
        // Check for field in DB
3103
        $schema = static::getSchema();
3104
        $helper = $schema->fieldSpec(static::class, $fieldName, DataObjectSchema::INCLUDE_CLASS);
3105
        if (!$helper) {
3106
            return null;
3107
        }
3108
3109
        if (!isset($this->record[$fieldName]) && isset($this->record[$fieldName . '_Lazy'])) {
3110
            $tableClass = $this->record[$fieldName . '_Lazy'];
3111
            $this->loadLazyFields($tableClass);
3112
        }
3113
3114
        $value = isset($this->record[$fieldName])
3115
            ? $this->record[$fieldName]
3116
            : null;
3117
3118
        // If we have a DBField object in $this->record, then return that
3119
        if ($value instanceof DBField) {
3120
            return $value;
3121
        }
3122
3123
        list($class, $spec) = explode('.', $helper);
3124
        /** @var DBField $obj */
3125
        $table = $schema->tableName($class);
3126
        $obj = Injector::inst()->create($spec, $fieldName);
3127
        $obj->setTable($table);
3128
        $obj->setValue($value, $this, false);
3129
        return $obj;
3130
    }
3131
3132
    /**
3133
     * Traverses to a DBField referenced by relationships between data objects.
3134
     *
3135
     * The path to the related field is specified with dot separated syntax
3136
     * (eg: Parent.Child.Child.FieldName).
3137
     *
3138
     * If a relation is blank, this will return null instead.
3139
     * If a relation name is invalid (e.g. non-relation on a parent) this
3140
     * can throw a LogicException.
3141
     *
3142
     * @param string $fieldPath List of paths on this object. All items in this path
3143
     * must be ViewableData implementors
3144
     *
3145
     * @return mixed DBField of the field on the object or a DataList instance.
3146
     * @throws LogicException If accessing invalid relations
3147
     */
3148
    public function relObject($fieldPath)
3149
    {
3150
        $object = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $object is dead and can be removed.
Loading history...
3151
        $component = $this;
3152
3153
        // Parse all relations
3154
        foreach (explode('.', $fieldPath) as $relation) {
3155
            if (!$component) {
3156
                return null;
3157
            }
3158
3159
            // Inspect relation type
3160
            if (ClassInfo::hasMethod($component, $relation)) {
3161
                $component = $component->$relation();
3162
            } elseif ($component instanceof Relation || $component instanceof DataList) {
3163
                // $relation could either be a field (aggregate), or another relation
3164
                $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

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

3165
                $component = $singleton->dbObject($relation) ?: $component->/** @scrutinizer ignore-call */ relation($relation);
Loading history...
3166
            } elseif ($component instanceof DataObject && ($dbObject = $component->dbObject($relation))) {
3167
                $component = $dbObject;
3168
            } elseif ($component instanceof ViewableData && $component->hasField($relation)) {
3169
                $component = $component->obj($relation);
3170
            } else {
3171
                throw new LogicException(
3172
                    "$relation is not a relation/field on " . get_class($component)
3173
                );
3174
            }
3175
        }
3176
        return $component;
3177
    }
3178
3179
    /**
3180
     * Traverses to a field referenced by relationships between data objects, returning the value
3181
     * The path to the related field is specified with dot separated syntax (eg: Parent.Child.Child.FieldName)
3182
     *
3183
     * @param string $fieldName string
3184
     * @return mixed Will return null on a missing value
3185
     */
3186
    public function relField($fieldName)
3187
    {
3188
        // Navigate to relative parent using relObject() if needed
3189
        $component = $this;
3190
        if (($pos = strrpos($fieldName, '.')) !== false) {
3191
            $relation = substr($fieldName, 0, $pos);
3192
            $fieldName = substr($fieldName, $pos + 1);
3193
            $component = $this->relObject($relation);
3194
        }
3195
3196
        // Bail if the component is null
3197
        if (!$component) {
3198
            return null;
3199
        }
3200
        if (ClassInfo::hasMethod($component, $fieldName)) {
3201
            return $component->$fieldName();
3202
        }
3203
        return $component->$fieldName;
3204
    }
3205
3206
    /**
3207
     * Temporary hack to return an association name, based on class, to get around the mangle
3208
     * of having to deal with reverse lookup of relationships to determine autogenerated foreign keys.
3209
     *
3210
     * @param string $className
3211
     * @return string
3212
     */
3213
    public function getReverseAssociation($className)
3214
    {
3215
        if (is_array($this->manyMany())) {
0 ignored issues
show
introduced by
The condition is_array($this->manyMany()) is always true.
Loading history...
3216
            $many_many = array_flip($this->manyMany());
3217
            if (array_key_exists($className, $many_many)) {
3218
                return $many_many[$className];
3219
            }
3220
        }
3221
        if (is_array($this->hasMany())) {
0 ignored issues
show
introduced by
The condition is_array($this->hasMany()) is always true.
Loading history...
3222
            $has_many = array_flip($this->hasMany());
3223
            if (array_key_exists($className, $has_many)) {
3224
                return $has_many[$className];
3225
            }
3226
        }
3227
        if (is_array($this->hasOne())) {
0 ignored issues
show
introduced by
The condition is_array($this->hasOne()) is always true.
Loading history...
3228
            $has_one = array_flip($this->hasOne());
3229
            if (array_key_exists($className, $has_one)) {
3230
                return $has_one[$className];
3231
            }
3232
        }
3233
3234
        return false;
0 ignored issues
show
Bug Best Practice introduced by
The expression return false returns the type false which is incompatible with the documented return type string.
Loading history...
3235
    }
3236
3237
    /**
3238
     * Return all objects matching the filter
3239
     * sub-classes are automatically selected and included
3240
     *
3241
     * @param string $callerClass The class of objects to be returned
3242
     * @param string|array $filter A filter to be inserted into the WHERE clause.
3243
     * Supports parameterised queries. See SQLSelect::addWhere() for syntax examples.
3244
     * @param string|array $sort A sort expression to be inserted into the ORDER
3245
     * BY clause.  If omitted, self::$default_sort will be used.
3246
     * @param string $join Deprecated 3.0 Join clause. Use leftJoin($table, $joinClause) instead.
3247
     * @param string|array $limit A limit expression to be inserted into the LIMIT clause.
3248
     * @param string $containerClass The container class to return the results in.
3249
     *
3250
     * @todo $containerClass is Ignored, why?
3251
     *
3252
     * @return DataList The objects matching the filter, in the class specified by $containerClass
3253
     */
3254
    public static function get(
3255
        $callerClass = null,
3256
        $filter = "",
3257
        $sort = "",
3258
        $join = "",
3259
        $limit = null,
3260
        $containerClass = DataList::class
3261
    ) {
3262
        // Validate arguments
3263
        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...
3264
            $callerClass = get_called_class();
3265
            if ($callerClass === self::class) {
3266
                throw new InvalidArgumentException('Call <classname>::get() instead of DataObject::get()');
3267
            }
3268
            if ($filter || $sort || $join || $limit || ($containerClass !== DataList::class)) {
3269
                throw new InvalidArgumentException('If calling <classname>::get() then you shouldn\'t pass any other'
3270
                    . ' arguments');
3271
            }
3272
        } elseif ($callerClass === self::class) {
3273
            throw new InvalidArgumentException('DataObject::get() cannot query non-subclass DataObject directly');
3274
        }
3275
        if ($join) {
3276
            throw new InvalidArgumentException(
3277
                'The $join argument has been removed. Use leftJoin($table, $joinClause) instead.'
3278
            );
3279
        }
3280
3281
        // Build and decorate with args
3282
        $result = DataList::create($callerClass);
3283
        if ($filter) {
3284
            $result = $result->where($filter);
3285
        }
3286
        if ($sort) {
3287
            $result = $result->sort($sort);
3288
        }
3289
        if ($limit && strpos($limit, ',') !== false) {
0 ignored issues
show
Bug introduced by
It seems like $limit can also be of type array; 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

3289
        if ($limit && strpos(/** @scrutinizer ignore-type */ $limit, ',') !== false) {
Loading history...
3290
            $limitArguments = explode(',', $limit);
0 ignored issues
show
Bug introduced by
It seems like $limit can also be of type array; 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

3290
            $limitArguments = explode(',', /** @scrutinizer ignore-type */ $limit);
Loading history...
3291
            $result = $result->limit($limitArguments[1], $limitArguments[0]);
3292
        } elseif ($limit) {
3293
            $result = $result->limit($limit);
3294
        }
3295
3296
        return $result;
3297
    }
3298
3299
3300
    /**
3301
     * Return the first item matching the given query.
3302
     *
3303
     * The object returned is cached, unlike DataObject::get()->first() {@link DataList::first()}
3304
     * and DataObject::get()->last() {@link DataList::last()}
3305
     *
3306
     * The filter argument supports parameterised queries (see SQLSelect::addWhere() for syntax examples). Because
3307
     * of that (and differently from e.g. DataList::filter()) you need to manually escape the field names:
3308
     * <code>
3309
     * $member = DataObject::get_one('Member', [ '"FirstName"' => 'John' ]);
3310
     * </code>
3311
     *
3312
     * @param string $callerClass The class of objects to be returned
3313
     * @param string|array $filter A filter to be inserted into the WHERE clause.
3314
     * @param boolean $cache Use caching
3315
     * @param string $orderby A sort expression to be inserted into the ORDER BY clause.
3316
     *
3317
     * @return DataObject|null The first item matching the query
3318
     */
3319
    public static function get_one($callerClass, $filter = "", $cache = true, $orderby = "")
3320
    {
3321
        /** @var DataObject $singleton */
3322
        $singleton = singleton($callerClass);
3323
3324
        $cacheComponents = [$filter, $orderby, $singleton->getUniqueKeyComponents()];
3325
        $cacheKey = md5(serialize($cacheComponents));
3326
3327
        $item = null;
3328
        if (!$cache || !isset(self::$_cache_get_one[$callerClass][$cacheKey])) {
3329
            $dl = DataObject::get($callerClass)->where($filter)->sort($orderby);
3330
            $item = $dl->first();
3331
3332
            if ($cache) {
3333
                self::$_cache_get_one[$callerClass][$cacheKey] = $item;
3334
                if (!self::$_cache_get_one[$callerClass][$cacheKey]) {
3335
                    self::$_cache_get_one[$callerClass][$cacheKey] = false;
3336
                }
3337
            }
3338
        }
3339
3340
        if ($cache) {
3341
            return self::$_cache_get_one[$callerClass][$cacheKey] ?: null;
3342
        }
3343
3344
        return $item;
3345
    }
3346
3347
    /**
3348
     * Flush the cached results for all relations (has_one, has_many, many_many)
3349
     * Also clears any cached aggregate data.
3350
     *
3351
     * @param boolean $persistent When true will also clear persistent data stored in the Cache system.
3352
     *                            When false will just clear session-local cached data
3353
     * @return DataObject $this
3354
     */
3355
    public function flushCache($persistent = true)
3356
    {
3357
        if (static::class == self::class) {
0 ignored issues
show
introduced by
The condition static::class == self::class is always true.
Loading history...
3358
            self::$_cache_get_one = [];
3359
            return $this;
3360
        }
3361
3362
        $classes = ClassInfo::ancestry(static::class);
3363
        foreach ($classes as $class) {
3364
            if (isset(self::$_cache_get_one[$class])) {
3365
                unset(self::$_cache_get_one[$class]);
3366
            }
3367
        }
3368
3369
        $this->extend('flushCache');
3370
3371
        $this->components = [];
3372
        return $this;
3373
    }
3374
3375
    /**
3376
     * Flush the get_one global cache and destroy associated objects.
3377
     */
3378
    public static function flush_and_destroy_cache()
3379
    {
3380
        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...
3381
            foreach (self::$_cache_get_one as $class => $items) {
3382
                if (is_array($items)) {
3383
                    foreach ($items as $item) {
3384
                        if ($item) {
3385
                            $item->destroy();
3386
                        }
3387
                    }
3388
                }
3389
            }
3390
        }
3391
        self::$_cache_get_one = [];
3392
    }
3393
3394
    /**
3395
     * Reset all global caches associated with DataObject.
3396
     */
3397
    public static function reset()
3398
    {
3399
        // @todo Decouple these
3400
        DBEnum::flushCache();
3401
        ClassInfo::reset_db_cache();
3402
        static::getSchema()->reset();
3403
        self::$_cache_get_one = [];
3404
        self::$_cache_field_labels = [];
3405
    }
3406
3407
    /**
3408
     * Return the given element, searching by ID.
3409
     *
3410
     * This can be called either via `DataObject::get_by_id(MyClass::class, $id)`
3411
     * or `MyClass::get_by_id($id)`
3412
     *
3413
     * The object returned is cached, unlike DataObject::get()->byID() {@link DataList::byID()}
3414
     *
3415
     * @param string|int $classOrID The class of the object to be returned, or id if called on target class
3416
     * @param int|bool $idOrCache The id of the element, or cache if called on target class
3417
     * @param boolean $cache See {@link get_one()}
3418
     *
3419
     * @return static The element
3420
     */
3421
    public static function get_by_id($classOrID, $idOrCache = null, $cache = true)
3422
    {
3423
        // Shift arguments if passing id in first or second argument
3424
        list ($class, $id, $cached) = is_numeric($classOrID)
3425
            ? [get_called_class(), $classOrID, isset($idOrCache) ? $idOrCache : $cache]
3426
            : [$classOrID, $idOrCache, $cache];
3427
3428
        // Validate class
3429
        if ($class === self::class) {
3430
            throw new InvalidArgumentException('DataObject::get_by_id() cannot query non-subclass DataObject directly');
3431
        }
3432
3433
        // Pass to get_one
3434
        $column = static::getSchema()->sqlColumnForField($class, 'ID');
3435
        return DataObject::get_one($class, [$column => $id], $cached);
3436
    }
3437
3438
    /**
3439
     * Get the name of the base table for this object
3440
     *
3441
     * @return string
3442
     */
3443
    public function baseTable()
3444
    {
3445
        return static::getSchema()->baseDataTable($this);
3446
    }
3447
3448
    /**
3449
     * Get the base class for this object
3450
     *
3451
     * @return string
3452
     */
3453
    public function baseClass()
3454
    {
3455
        return static::getSchema()->baseDataClass($this);
3456
    }
3457
3458
    /**
3459
     * @var array Parameters used in the query that built this object.
3460
     * This can be used by decorators (e.g. lazy loading) to
3461
     * run additional queries using the same context.
3462
     */
3463
    protected $sourceQueryParams;
3464
3465
    /**
3466
     * @see $sourceQueryParams
3467
     * @return array
3468
     */
3469
    public function getSourceQueryParams()
3470
    {
3471
        return $this->sourceQueryParams;
3472
    }
3473
3474
    /**
3475
     * Get list of parameters that should be inherited to relations on this object
3476
     *
3477
     * @return array
3478
     */
3479
    public function getInheritableQueryParams()
3480
    {
3481
        $params = $this->getSourceQueryParams();
3482
        $this->extend('updateInheritableQueryParams', $params);
3483
        return $params;
3484
    }
3485
3486
    /**
3487
     * @see $sourceQueryParams
3488
     * @param array
3489
     */
3490
    public function setSourceQueryParams($array)
3491
    {
3492
        $this->sourceQueryParams = $array;
3493
    }
3494
3495
    /**
3496
     * @see $sourceQueryParams
3497
     * @param string $key
3498
     * @param string $value
3499
     */
3500
    public function setSourceQueryParam($key, $value)
3501
    {
3502
        $this->sourceQueryParams[$key] = $value;
3503
    }
3504
3505
    /**
3506
     * @see $sourceQueryParams
3507
     * @param string $key
3508
     * @return string
3509
     */
3510
    public function getSourceQueryParam($key)
3511
    {
3512
        if (isset($this->sourceQueryParams[$key])) {
3513
            return $this->sourceQueryParams[$key];
3514
        }
3515
        return null;
3516
    }
3517
3518
    //-------------------------------------------------------------------------------------------//
3519
3520
    /**
3521
     * Check the database schema and update it as necessary.
3522
     *
3523
     * @uses DataExtension->augmentDatabase()
3524
     */
3525
    public function requireTable()
3526
    {
3527
        // Only build the table if we've actually got fields
3528
        $schema = static::getSchema();
3529
        $table = $schema->tableName(static::class);
3530
        $fields = $schema->databaseFields(static::class, false);
3531
        $indexes = $schema->databaseIndexes(static::class, false);
3532
        $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

3532
        /** @scrutinizer ignore-call */ 
3533
        $extensions = self::database_extensions(static::class);
Loading history...
3533
3534
        if (empty($table)) {
3535
            throw new LogicException(
3536
                "Class " . static::class . " not loaded by manifest, or no database table configured"
3537
            );
3538
        }
3539
3540
        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...
3541
            $hasAutoIncPK = get_parent_class($this) === self::class;
3542
            DB::require_table(
3543
                $table,
3544
                $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

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

3545
                /** @scrutinizer ignore-type */ $indexes,
Loading history...
3546
                $hasAutoIncPK,
3547
                $this->config()->get('create_table_options'),
3548
                $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

3548
                /** @scrutinizer ignore-type */ $extensions
Loading history...
3549
            );
3550
        } else {
3551
            DB::dont_require_table($table);
3552
        }
3553
3554
        // Build any child tables for many_many items
3555
        if ($manyMany = $this->uninherited('many_many')) {
3556
            $extras = $this->uninherited('many_many_extraFields');
3557
            foreach ($manyMany as $component => $spec) {
3558
                // Get many_many spec
3559
                $manyManyComponent = $schema->manyManyComponent(static::class, $component);
3560
                $parentField = $manyManyComponent['parentField'];
3561
                $childField = $manyManyComponent['childField'];
3562
                $tableOrClass = $manyManyComponent['join'];
3563
3564
                // Skip if backed by actual class
3565
                if (class_exists($tableOrClass)) {
3566
                    continue;
3567
                }
3568
3569
                // Build fields
3570
                $manymanyFields = [
3571
                    $parentField => "Int",
3572
                    $childField => "Int",
3573
                ];
3574
                if (isset($extras[$component])) {
3575
                    $manymanyFields = array_merge($manymanyFields, $extras[$component]);
3576
                }
3577
3578
                // Build index list
3579
                $manymanyIndexes = [
3580
                    $parentField => [
3581
                        'type' => 'index',
3582
                        'name' => $parentField,
3583
                        'columns' => [$parentField],
3584
                    ],
3585
                    $childField => [
3586
                        'type' => 'index',
3587
                        'name' => $childField,
3588
                        'columns' => [$childField],
3589
                    ],
3590
                ];
3591
                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

3591
                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

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