Completed
Push — master ( c17796...052b15 )
by Damian
01:29
created

FixtureBlueprint::addCallback()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 4
nc 2
nop 2
dl 0
loc 8
rs 9.4285
c 0
b 0
f 0
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) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $class of type null|string is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
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)
0 ignored issues
show
Unused Code Comprehensibility introduced by
40% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
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_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)
0 ignored issues
show
Bug Best Practice introduced by
The expression $schema->hasManyComponent($class, $fieldName) of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
137
                        || $schema->hasOneComponent($class, $fieldName)
0 ignored issues
show
Bug Best Practice introduced by
The expression $schema->hasOneComponent($class, $fieldName) of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
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) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $isHasMany of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
160
                        throw new InvalidArgumentException("$fieldName is both many_many and has_many");
161
                    }
162
                    if ($isManyMany || $isHasMany) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $isHasMany of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
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) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $isHasMany of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
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