Passed
Pull Request — 4 (#10229)
by Thomas
07:57
created

DataObject   F

Complexity

Total Complexity 582

Size/Duplication

Total Lines 4328
Duplicated Lines 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 1433
c 3
b 0
f 0
dl 0
loc 4328
rs 0.8
wmc 582

129 Methods

Rating   Name   Duplication   Size   Complexity  
A getSchema() 0 3 1
D __construct() 0 83 14
A duplicateManyManyRelation() 0 19 4
A setClassName() 0 10 3
B defineMethods() 0 27 10
A updateChanges() 0 10 3
A duplicateHasManyRelation() 0 13 2
B update() 0 50 10
A writeBaseRecord() 0 14 2
A findCascadeDeletes() 0 4 1
A skipWriteComponents() 0 31 5
A newClassInstance() 0 19 3
B getRelationClass() 0 29 7
A database_extensions() 0 7 2
A getTitle() 0 11 3
A validateWrite() 0 17 4
B duplicate() 0 38 9
A hasMany() 0 7 4
A toMap() 0 5 1
C prepareManipulationTable() 0 53 12
A duplicateBelongsToRelation() 0 14 2
A i18n_singular_name() 0 3 1
A delete() 0 38 4
A onAfterDelete() 0 3 1
A onBeforeDelete() 0 11 2
A getObsoleteClassName() 0 7 2
A onAfterWrite() 0 4 1
B writeManipulation() 0 37 9
A singular_name() 0 10 2
A getQueriedDatabaseFields() 0 3 1
B getManyManyComponents() 0 59 7
A beforeUpdateCMSFields() 0 3 1
A getRelationType() 0 11 4
B populateDefaults() 0 36 11
A onBeforeWrite() 0 6 1
A duplicateHasOneRelation() 0 10 2
A destroy() 0 3 1
A preWrite() 0 16 3
D merge() 0 71 21
B setComponent() 0 37 10
C inferReciprocalComponent() 0 94 13
A duplicateManyManyRelations() 0 14 5
A manyManyExtraFields() 0 3 1
A manyMany() 0 7 1
A i18n_plural_name() 0 3 1
A castedUpdate() 0 6 2
B duplicateRelations() 0 31 6
A getDefaultSearchContext() 0 6 1
A isEmpty() 0 18 5
C write() 0 62 12
A doValidate() 0 4 1
A delete_by_id() 0 7 2
A i18n_pluralise() 0 7 1
B getComponents() 0 38 6
A hasOne() 0 3 1
A scaffoldFormFields() 0 23 1
A validate() 0 5 1
A forceChange() 0 15 3
A writeComponents() 0 24 6
A writeRelations() 0 12 4
A getClassName() 0 7 2
A exists() 0 3 2
C scaffoldSearchFields() 0 63 14
A belongsTo() 0 7 4
A data() 0 3 1
C getComponent() 0 76 14
A plural_name() 0 11 3
A getClassAncestry() 0 3 1
B hydrate() 0 46 9
D loadLazyFields() 0 75 18
A getCMSActions() 0 5 1
A flush_and_destroy_cache() 0 14 6
A flushCache() 0 18 4
A getFrontEndFields() 0 6 1
A canDelete() 0 7 2
A getCMSCompositeValidator() 0 18 2
A hasField() 0 9 5
A isChanged() 0 8 3
A getViewerTemplates() 0 3 1
A canEdit() 0 7 2
A reset() 0 8 1
A setCastedField() 0 13 3
A hasDatabaseField() 0 4 1
C get() 0 43 15
F setField() 0 80 22
A getField() 0 25 6
A afterUpdateCMSFields() 0 3 1
B relObject() 0 29 11
B dbObject() 0 33 6
A canCreate() 0 7 2
B getReverseAssociation() 0 22 7
A relField() 0 18 4
A canView() 0 7 2
A debug() 0 11 3
A castingHelper() 0 19 4
B get_one() 0 26 7
B can() 0 21 8
A extendedCan() 0 15 4
D getChangedFields() 0 58 19
A getCMSFields() 0 12 1
A getSourceQueryParams() 0 3 1
A onAfterBuild() 0 3 1
A baseClass() 0 3 1
A get_by_id() 0 18 5
A setSourceQueryParam() 0 3 1
A getInheritableQueryParams() 0 5 1
B requireTable() 0 72 7
A baseTable() 0 3 1
A getSourceQueryParam() 0 6 2
A requireDefaultRecords() 0 18 4
A setSourceQueryParams() 0 3 1
B findRelatedObjects() 0 39 8
A getUniqueKey() 0 7 1
A provideI18nEntities() 0 13 2
A getUniqueKeyComponents() 0 3 1
A setJoin() 0 12 3
A mergeRelatedObject() 0 15 4
B fieldLabels() 0 54 8
D summaryFields() 0 46 12
D searchableFields() 0 81 18
A disable_subclass_access() 0 3 1
A getJoin() 0 3 1
A hasValue() 0 8 3
A fieldLabel() 0 4 2
A defaultSearchFilters() 0 16 4
A enable_subclass_access() 0 3 1
A findAllRelatedData() 0 4 1
A isInDB() 0 3 2
A mergeRelatedObjects() 0 15 4

How to fix   Complexity   

Complex Class

Complex classes like DataObject often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use DataObject, and based on these observations, apply Extract Interface, too.

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

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

1208
                    $leftComponents->/** @scrutinizer ignore-call */ 
1209
                                     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

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

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

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

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

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

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

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

3600
        /** @scrutinizer ignore-call */ 
3601
        $extensions = self::database_extensions(static::class);
Loading history...
3601
3602
        if (empty($table)) {
3603
            throw new LogicException(
3604
                "Class " . static::class . " not loaded by manifest, or no database table configured"
3605
            );
3606
        }
3607
3608
        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...
3609
            $hasAutoIncPK = get_parent_class($this) === self::class;
3610
            DB::require_table(
3611
                $table,
3612
                $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

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

3613
                /** @scrutinizer ignore-type */ $indexes,
Loading history...
3614
                $hasAutoIncPK,
3615
                $this->config()->get('create_table_options'),
3616
                $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

3616
                /** @scrutinizer ignore-type */ $extensions
Loading history...
3617
            );
3618
        } else {
3619
            DB::dont_require_table($table);
3620
        }
3621
3622
        // Build any child tables for many_many items
3623
        if ($manyMany = $this->uninherited('many_many')) {
3624
            $extras = $this->uninherited('many_many_extraFields');
3625
            foreach ($manyMany as $component => $spec) {
3626
                // Get many_many spec
3627
                $manyManyComponent = $schema->manyManyComponent(static::class, $component);
3628
                $parentField = $manyManyComponent['parentField'];
3629
                $childField = $manyManyComponent['childField'];
3630
                $tableOrClass = $manyManyComponent['join'];
3631
3632
                // Skip if backed by actual class
3633
                if (class_exists($tableOrClass)) {
3634
                    continue;
3635
                }
3636
3637
                // Build fields
3638
                $manymanyFields = [
3639
                    $parentField => "Int",
3640
                    $childField => "Int",
3641
                ];
3642
                if (isset($extras[$component])) {
3643
                    $manymanyFields = array_merge($manymanyFields, $extras[$component]);
3644
                }
3645
3646
                // Build index list
3647
                $manymanyIndexes = [
3648
                    $parentField => [
3649
                        'type' => 'index',
3650
                        'name' => $parentField,
3651
                        'columns' => [$parentField],
3652
                    ],
3653
                    $childField => [
3654
                        'type' => 'index',
3655
                        'name' => $childField,
3656
                        'columns' => [$childField],
3657
                    ],
3658
                ];
3659
                DB::require_table($tableOrClass, $manymanyFields, $manymanyIndexes, true, null, $extensions);
0 ignored issues
show
Bug introduced by
$manymanyFields of type array|string[] is incompatible with the type string expected by parameter $fieldSchema of SilverStripe\ORM\DB::require_table(). ( Ignorable by Annotation )

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

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

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

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