Issues (2882)

src/Dev/FixtureBlueprint.php (1 issue)

1
<?php
2
3
namespace SilverStripe\Dev;
4
5
use Exception;
6
use InvalidArgumentException;
7
use SilverStripe\Assets\File;
8
use SilverStripe\Core\Config\Config;
9
use SilverStripe\Core\Injector\Injector;
10
use SilverStripe\ORM\DataObject;
11
use SilverStripe\ORM\DB;
12
13
/**
14
 * A blueprint on how to create instances of a certain {@link DataObject} subclass.
15
 *
16
 * Relies on a {@link FixtureFactory} to manage database relationships between instances,
17
 * and manage the mappings between fixture identifiers and their database IDs.
18
 */
19
class FixtureBlueprint
20
{
21
22
    /**
23
     * @var array Map of field names to values. Supersedes {@link DataObject::$defaults}.
24
     */
25
    protected $defaults = array();
26
27
    /**
28
     * @var String Arbitrary name by which this fixture type can be referenced.
29
     */
30
    protected $name;
31
32
    /**
33
     * @var String Subclass of {@link DataObject}
34
     */
35
    protected $class;
36
37
    /**
38
     * @var array
39
     */
40
    protected $callbacks = array(
41
        'beforeCreate' => array(),
42
        'afterCreate' => array(),
43
    );
44
45
    /** @config */
46
    private static $dependencies = array(
47
        'factory' => '%$' . FixtureFactory::class,
48
    );
49
50
    /**
51
     * @param String $name
52
     * @param String $class Defaults to $name
53
     * @param array $defaults
54
     */
55
    public function __construct($name, $class = null, $defaults = array())
56
    {
57
        if (!$class) {
58
            $class = $name;
59
        }
60
61
        if (!is_subclass_of($class, DataObject::class)) {
62
            throw new InvalidArgumentException(sprintf(
63
                'Class "%s" is not a valid subclass of DataObject',
64
                $class
65
            ));
66
        }
67
68
        $this->name = $name;
69
        $this->class = $class;
70
        $this->defaults = $defaults;
71
    }
72
73
    /**
74
     * @param string $identifier Unique identifier for this fixture type
75
     * @param array $data Map of property names to their values.
76
     * @param array $fixtures Map of fixture names to an associative array of their in-memory
77
     *                        identifiers mapped to their database IDs. Used to look up
78
     *                        existing fixtures which might be referenced in the $data attribute
79
     *                        via the => notation.
80
     * @return DataObject
81
     * @throws Exception
82
     */
83
    public function createObject($identifier, $data = null, $fixtures = null)
84
    {
85
        // We have to disable validation while we import the fixtures, as the order in
86
        // which they are imported doesnt guarantee valid relations until after the import is complete.
87
        // Also disable filesystem manipulations
88
        Config::nest();
89
        Config::modify()->set(DataObject::class, 'validation_enabled', false);
90
        Config::modify()->set(File::class, 'update_filesystem', false);
91
92
        $this->invokeCallbacks('beforeCreate', array($identifier, &$data, &$fixtures));
93
94
        try {
95
            $class = $this->class;
96
            $schema = DataObject::getSchema();
97
            $obj = Injector::inst()->create($class);
98
99
            // If an ID is explicitly passed, then we'll sort out the initial write straight away
100
            // This is just in case field setters triggered by the population code in the next block
101
            // Call $this->write().  (For example, in FileTest)
102
            if (isset($data['ID'])) {
103
                $obj->ID = $data['ID'];
104
105
                // The database needs to allow inserting values into the foreign key column (ID in our case)
106
                $conn = DB::get_conn();
107
                $baseTable = DataObject::getSchema()->baseDataTable($class);
108
                if (method_exists($conn, 'allowPrimaryKeyEditing')) {
109
                    $conn->allowPrimaryKeyEditing($baseTable, true);
110
                }
111
                $obj->write(false, true);
112
                if (method_exists($conn, 'allowPrimaryKeyEditing')) {
113
                    $conn->allowPrimaryKeyEditing($baseTable, false);
114
                }
115
            }
116
117
            // Populate defaults
118
            if ($this->defaults) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->defaults 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...
119
                foreach ($this->defaults as $fieldName => $fieldVal) {
120
                    if (isset($data[$fieldName]) && $data[$fieldName] !== false) {
121
                        continue;
122
                    }
123
124
                    if (!is_string($fieldVal) && is_callable($fieldVal)) {
125
                        $obj->$fieldName = $fieldVal($obj, $data, $fixtures);
126
                    } else {
127
                        $obj->$fieldName = $fieldVal;
128
                    }
129
                }
130
            }
131
132
            // Populate overrides
133
            if ($data) {
134
                foreach ($data as $fieldName => $fieldVal) {
135
                    if ($schema->manyManyComponent($class, $fieldName)
136
                        || $schema->hasManyComponent($class, $fieldName)
137
                        || $schema->hasOneComponent($class, $fieldName)
138
                    ) {
139
                        continue;
140
                    }
141
142
                    $this->setValue($obj, $fieldName, $fieldVal, $fixtures);
143
                }
144
            }
145
146
            $obj->write();
147
148
            // Save to fixture before relationship processing in case of reflexive relationships
149
            if (!isset($fixtures[$class])) {
150
                $fixtures[$class] = array();
151
            }
152
            $fixtures[$class][$identifier] = $obj->ID;
153
154
            // Populate all relations
155
            if ($data) {
156
                foreach ($data as $fieldName => $fieldVal) {
157
                    $isManyMany = $schema->manyManyComponent($class, $fieldName);
158
                    $isHasMany = $schema->hasManyComponent($class, $fieldName);
159
                    if ($isManyMany && $isHasMany) {
160
                        throw new InvalidArgumentException("$fieldName is both many_many and has_many");
161
                    }
162
                    if ($isManyMany || $isHasMany) {
163
                        $obj->write();
164
165
                        // Many many components need a little extra work to extract extrafields
166
                        if (is_array($fieldVal) && $isManyMany) {
167
                            // handle lists of many_many relations. Each item can
168
                            // specify the many_many_extraFields against each
169
                            // related item.
170
                            foreach ($fieldVal as $relVal) {
171
                                // Check for many_many_extrafields
172
                                $extrafields = [];
173
                                if (is_array($relVal)) {
174
                                    // Item is either first row, or key in yet another nested array
175
                                    $item = key($relVal);
176
                                    if (is_array($relVal[$item]) && count($relVal) === 1) {
177
                                        // Extra fields from nested array
178
                                        $extrafields = $relVal[$item];
179
                                    } else {
180
                                        // Extra fields from subsequent items
181
                                        array_shift($relVal);
182
                                        $extrafields = $relVal;
183
                                    }
184
                                } else {
185
                                    $item = $relVal;
186
                                }
187
                                $id = $this->parseValue($item, $fixtures);
188
189
                                $obj->getManyManyComponents($fieldName)->add(
190
                                    $id,
191
                                    $extrafields
192
                                );
193
                            }
194
                        } else {
195
                            $items = is_array($fieldVal)
196
                            ? $fieldVal
197
                            : preg_split('/ *, */', trim($fieldVal));
198
199
                            $parsedItems = [];
200
                            foreach ($items as $item) {
201
                                // Check for correct format: =><relationname>.<identifier>.
202
                                // Ignore if the item has already been replaced with a numeric DB identifier
203
                                if (!is_numeric($item) && !preg_match('/^=>[^\.]+\.[^\.]+/', $item)) {
204
                                    throw new InvalidArgumentException(sprintf(
205
                                        'Invalid format for relation "%s" on class "%s" ("%s")',
206
                                        $fieldName,
207
                                        $class,
208
                                        $item
209
                                    ));
210
                                }
211
212
                                $parsedItems[] = $this->parseValue($item, $fixtures);
213
                            }
214
215
                            if ($isHasMany) {
216
                                $obj->getComponents($fieldName)->setByIDList($parsedItems);
217
                            } elseif ($isManyMany) {
218
                                $obj->getManyManyComponents($fieldName)->setByIDList($parsedItems);
219
                            }
220
                        }
221
                    } else {
222
                        $hasOneField = preg_replace('/ID$/', '', $fieldName);
223
                        if ($className = $schema->hasOneComponent($class, $hasOneField)) {
224
                            $obj->{$hasOneField . 'ID'} = $this->parseValue($fieldVal, $fixtures, $fieldClass);
225
                            // Inject class for polymorphic relation
226
                            if ($className === 'SilverStripe\\ORM\\DataObject') {
227
                                $obj->{$hasOneField . 'Class'} = $fieldClass;
228
                            }
229
                        }
230
                    }
231
                }
232
            }
233
            $obj->write();
234
235
            // If LastEdited was set in the fixture, set it here
236
            if ($data && array_key_exists('LastEdited', $data)) {
237
                $this->overrideField($obj, 'LastEdited', $data['LastEdited'], $fixtures);
238
            }
239
        } catch (Exception $e) {
240
            Config::unnest();
241
            throw $e;
242
        }
243
244
        Config::unnest();
245
        $this->invokeCallbacks('afterCreate', array($obj, $identifier, &$data, &$fixtures));
246
247
        return $obj;
248
    }
249
250
    /**
251
     * @param array $defaults
252
     * @return $this
253
     */
254
    public function setDefaults($defaults)
255
    {
256
        $this->defaults = $defaults;
257
        return $this;
258
    }
259
260
    /**
261
     * @return array
262
     */
263
    public function getDefaults()
264
    {
265
        return $this->defaults;
266
    }
267
268
    /**
269
     * @return string
270
     */
271
    public function getClass()
272
    {
273
        return $this->class;
274
    }
275
276
    /**
277
     * See class documentation.
278
     *
279
     * @param string $type
280
     * @param callable $callback
281
     * @return $this
282
     */
283
    public function addCallback($type, $callback)
284
    {
285
        if (!array_key_exists($type, $this->callbacks)) {
286
            throw new InvalidArgumentException(sprintf('Invalid type "%s"', $type));
287
        }
288
289
        $this->callbacks[$type][] = $callback;
290
        return $this;
291
    }
292
293
    /**
294
     * @param string $type
295
     * @param callable $callback
296
     * @return $this
297
     */
298
    public function removeCallback($type, $callback)
299
    {
300
        $pos = array_search($callback, $this->callbacks[$type]);
301
        if ($pos !== false) {
302
            unset($this->callbacks[$type][$pos]);
303
        }
304
305
        return $this;
306
    }
307
308
    protected function invokeCallbacks($type, $args = array())
309
    {
310
        foreach ($this->callbacks[$type] as $callback) {
311
            call_user_func_array($callback, $args);
312
        }
313
    }
314
315
    /**
316
     * Parse a value from a fixture file.  If it starts with =>
317
     * it will get an ID from the fixture dictionary
318
     *
319
     * @param string $value
320
     * @param array $fixtures See {@link createObject()}
321
     * @param string $class If the value parsed is a class relation, this parameter
322
     * will be given the value of that class's name
323
     * @return string Fixture database ID, or the original value
324
     */
325
    protected function parseValue($value, $fixtures = null, &$class = null)
326
    {
327
        if (substr($value, 0, 2) == '=>') {
328
            // Parse a dictionary reference - used to set foreign keys
329
            list($class, $identifier) = explode('.', substr($value, 2), 2);
330
331
            if ($fixtures && !isset($fixtures[$class][$identifier])) {
332
                throw new InvalidArgumentException(sprintf(
333
                    'No fixture definitions found for "%s"',
334
                    $value
335
                ));
336
            }
337
338
            return $fixtures[$class][$identifier];
339
        } else {
340
            // Regular field value setting
341
            return $value;
342
        }
343
    }
344
345
    protected function setValue($obj, $name, $value, $fixtures = null)
346
    {
347
        $obj->$name = $this->parseValue($value, $fixtures);
348
    }
349
350
    protected function overrideField($obj, $fieldName, $value, $fixtures = null)
351
    {
352
        $class = get_class($obj);
353
        $table = DataObject::getSchema()->tableForField($class, $fieldName);
354
        $value = $this->parseValue($value, $fixtures);
355
356
        DB::manipulate(array(
357
            $table => array(
358
                "command" => "update",
359
                "id" => $obj->ID,
360
                "class" => $class,
361
                "fields" => array($fieldName => $value),
362
            )
363
        ));
364
        $obj->$fieldName = $value;
365
    }
366
}
367