VirtualFieldIndex   D
last analyzed

Complexity

Total Complexity 86

Size/Duplication

Total Lines 412
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 9

Test Coverage

Coverage 65.91%

Importance

Changes 13
Bugs 2 Features 5
Metric Value
wmc 86
c 13
b 2
f 5
lcom 1
cbo 9
dl 0
loc 412
ccs 145
cts 220
cp 0.6591
rs 4.8717

13 Methods

Rating   Name   Duplication   Size   Complexity  
A get_classes_with_vfi() 0 8 3
B get_extra_config() 0 20 5
C get_vfi_spec() 0 48 14
B build() 0 18 5
C rebuildVFI() 0 61 13
A getVFIFieldName() 0 4 1
A getVFISpec() 0 9 3
D getVFI() 0 32 9
A VFI() 0 4 1
A encode_list() 0 15 4
A decode_list() 0 14 4
D get_value() 0 36 10
C onBeforeWrite() 0 43 14

How to fix   Complexity   

Complex Class

Complex classes like VirtualFieldIndex 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. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

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 VirtualFieldIndex, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * Adds a new field to the mysql table which can hold the result
4
 * of a "virtual field" (i.e. method call on the model or relation).
5
 *
6
 * Usage (config.yml):
7
 *
8
 * Product:
9
 *   extensions:
10
 *     - VirtualFieldIndex
11
 * VirtualFieldIndex
12
 *   vfi_spec:
13
 *     Product:
14
 *       Price:
15
 *         Type: simple
16
 *         Source: sellingPrice
17
 *         DependsOn: BasePrice
18
 *         DBField: Currency
19
 *       Categories:
20
 *         Type: list
21
 *         DependsOn: all
22
 *         Source:
23
 *           - ParentID
24
 *           - ProductCategories.ID
25
 *
26
 * The above will create two new fields on Product: VFI_Price and VFI_Categories.
27
 * These will be updated whenever the object is changed and can be triggered via
28
 * a build task (dev/tasks/BuildVFI).
29
 *
30
 * The categories index will contain the merging of results from ParentID and
31
 * ProductCategories.ID in the form of a comma-delimited list.
32
 *
33
 * NOTE: having multiple sources doesn't equate with Type=list always. That's
34
 * just the default. Type=list means the output is a list. A single source could
35
 * also return an array and that would be a list as well.
36
 *
37
 * @author Mark Guinn <[email protected]>
38
 * @date 09.25.2013
39
 * @package shop_search
40
 * @subpackage helpers
41
 */
42
class VirtualFieldIndex extends DataExtension
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class must be in a namespace of at least one level to avoid collisions.

You can fix this by adding a namespace to your class:

namespace YourVendor;

class YourClass { }

When choosing a vendor namespace, try to pick something that is not too generic to avoid conflicts with other libraries.

Loading history...
43
{
44
    const TYPE_LIST     = 'list';
45
    const TYPE_SIMPLE   = 'simple';
46
    const DEPENDS_ALL   = 'all';
47
    const DEPENDS_NONE  = 'none';
48
49
    /** @var array - central config for all models */
50
    private static $vfi_spec = array();
0 ignored issues
show
Unused Code introduced by
The property $vfi_spec is not used and could be removed.

This check marks private properties in classes that are never used. Those properties can be removed.

Loading history...
51
52
    /** @var bool - if you set this to true it will write to both live and stage using DB::query and save some time, possibly */
53
    private static $fast_writes_enabled = false;
0 ignored issues
show
Unused Code introduced by
The property $fast_writes_enabled is not used and could be removed.

This check marks private properties in classes that are never used. Those properties can be removed.

Loading history...
54
55
    /** @var bool - used to prevent an infinite loop in onBeforeWrite */
56
    protected $isRebuilding = false;
57
58
    public static $disable_building = false;
59
60
    /**
61
     * @return array
62
     */
63
    public static function get_classes_with_vfi()
64
    {
65
        $vfi_def = Config::inst()->get('VirtualFieldIndex', 'vfi_spec');
66
        if (!$vfi_def || !is_array($vfi_def)) {
67
            return array();
68
        }
69
        return array_keys($vfi_def);
70
    }
71
72
    /**
73
     * Define extra db fields and indexes.
74
     * @param $class
75
     * @param $extension
76
     * @param $args
77
     * @return array
78
     */
79 8
    public static function get_extra_config($class, $extension, $args)
80
    {
81 8
        $vfi_def = self::get_vfi_spec($class);
82 8
        if (!$vfi_def || !is_array($vfi_def)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $vfi_def 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...
83
            return array();
84
        }
85
86
        $out = array(
87 8
            'db'        => array(),
88 8
            'indexes'   => array(),
89 8
        );
90
91 8
        foreach ($vfi_def as $field => $spec) {
92 8
            $fn = 'VFI_' . $field;
93 8
            $out['db'][$fn] = isset($spec['DBField']) ? $spec['DBField'] : 'Varchar(255)';
94 8
            $out['indexes'][$fn] = true;
95 8
        }
96
97 8
        return $out;
98
    }
99
100
101
    /**
102
     * Return a normalized version of the vfi definition for a given class
103
     * @param string $class
104
     * @return array
105
     */
106 8
    public static function get_vfi_spec($class)
107
    {
108 8
        $vfi_master = Config::inst()->get('VirtualFieldIndex', 'vfi_spec');
109 8
        if (!$vfi_master || !is_array($vfi_master)) {
110
            return array();
111
        }
112
113
        // merge in all the vfi's from ancestors as well
114 8
        $vfi_def = array();
115 8
        foreach (ClassInfo::ancestry($class) as $c) {
116 8
            if (!empty($vfi_master[$c])) {
117
                // we want newer classes to override parent classes so we do it this way
118 8
                $vfi_def = $vfi_master[$c] + $vfi_def;
119 8
            }
120 8
        }
121 8
        if (empty($vfi_def)) {
122
            return array();
123
        }
124
125
        // convert shorthand to longhand
126 8
        foreach ($vfi_def as $k => $v) {
127 8
            if (is_numeric($k)) {
128
                $vfi_def[$v] = $v;
129
                unset($vfi_def[$k]);
130 8
            } elseif (is_string($v)) {
131 8
                $vfi_def[$k] = array(
132 8
                    'Type'      => self::TYPE_SIMPLE,
133 8
                    'DependsOn' => self::DEPENDS_ALL,
134 8
                    'Source'    => $v,
135
                );
136 8
            } elseif (is_array($v) && !isset($vfi_def[$k]['Source'])) {
137 8
                $vfi_def[$k] = array(
138 8
                    'Type'      => self::TYPE_LIST,
139 8
                    'DependsOn' => self::DEPENDS_ALL,
140 8
                    'Source'    => $v,
141
                );
142 8
            } else {
143 8
                if (!isset($v['Type'])) {
144 8
                    $vfi_def[$k]['Type'] = is_array($v['Source']) ? self::TYPE_LIST : self::TYPE_SIMPLE;
145 8
                }
146 8
                if (!isset($v['DependsOn'])) {
147
                    $vfi_def[$k]['DependsOn'] = self::DEPENDS_ALL;
148
                }
149
            }
150 8
        }
151
152 8
        return $vfi_def;
153
    }
154
155
    /**
156
     * Rebuilds any vfi fields on one class (or all). Doing it in chunks means a few more
157
     * queries but it means we can handle larger datasets without storing everything in memory.
158
     *
159
     * @param string $class [optional] - if not given all indexes will be rebuilt
160
     */
161 4
    public static function build($class='')
162
    {
163 4
        if ($class) {
164 4
            $list   = DataObject::get($class);
165 4
            $count  = $list->count();
166 4
            for ($i = 0; $i < $count; $i += 10) {
167 4
                $chunk = $list->limit(10, $i);
168
//				if (Controller::curr() instanceof TaskRunner) echo "Processing VFI #$i...\n";
0 ignored issues
show
Unused Code Comprehensibility introduced by
53% 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...
169 4
                foreach ($chunk as $rec) {
170 4
                    $rec->rebuildVFI();
171 4
                }
172 4
            }
173 4
        } else {
174
            foreach (self::get_classes_with_vfi() as $c) {
175
                self::build($c);
176
            }
177
        }
178 4
    }
179
180
    /**
181
     * Rebuild all vfi fields.
182
     */
183 8
    public function rebuildVFI($field = '')
184
    {
185 8
        if ($field) {
186 8
            $this->isRebuilding = true;
187 8
            $spec   = $this->getVFISpec($field);
188 8
            $fn     = $this->getVFIFieldName($field);
189 8
            $val    = $this->getVFI($field, true);
190
191 8
            if ($spec['Type'] == self::TYPE_LIST) {
192 8
                if (is_object($val)) {
193
                    $val = $val->toArray();
194
                }    // this would be an ArrayList or DataList
195 8
                if (!is_array($val)) {
196
                    $val = array($val);
197
                }        // this would be a scalar value
198 8
                $val = self::encode_list($val);
199 8
            } else {
200 8
                if (is_array($val)) {
201
                    $val = (string)$val[0];
202
                }    // if they give us an array, just take the first value
203 8
                if (is_object($val)) {
204
                    $val = (string)$val->first();
205
                }  // if a SS_List, take the first as well
206
            }
207
208 8
            if (Config::inst()->get('VirtualFieldIndex', 'fast_writes_enabled')) {
209
                // NOTE: this is usually going to be bad practice, but if you
210
                // have a lot of products and a lot of on...Write handlers that
211
                // can get tedious really quick. This is just here as an option.
212
                $table = '';
213
                foreach ($this->owner->getClassAncestry() as $ancestor) {
214
                    if (DataObject::has_own_table($ancestor)) {
215
                        $sing = singleton($ancestor);
216
                        if ($sing->hasOwnTableDatabaseField($fn)) {
217
                            $table = $ancestor;
218
                            break;
219
                        }
220
                    }
221
                }
222
223
                if (!empty($table)) {
224
                    DB::query($sql = sprintf("UPDATE %s SET %s = '%s' WHERE ID = '%d'", $table, $fn, Convert::raw2sql($val), $this->owner->ID));
225
                    DB::query(sprintf("UPDATE %s_Live SET %s = '%s' WHERE ID = '%d'", $table, $fn, Convert::raw2sql($val), $this->owner->ID));
226
                    $this->owner->setField($fn, $val);
227
                } else {
228
                    // if we couldn't figure out the right table, fall back to the old fashioned way
229
                    $this->owner->setField($fn, $val);
230
                    $this->owner->write();
231
                }
232
            } else {
233 8
                $this->owner->setField($fn, $val);
234 8
                $this->owner->write();
235
            }
236 8
            $this->isRebuilding = false;
237 8
        } else {
238
            // rebuild all fields if they didn't specify
239 4
            foreach ($this->getVFISpec() as $field => $spec) {
0 ignored issues
show
Bug introduced by
The expression $this->getVFISpec() of type array|false is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
240 4
                $this->rebuildVFI($field);
241 4
            }
242
        }
243 8
    }
244
245
    /**
246
     * @param $name
247
     * @return string
248
     */
249 8
    public function getVFIFieldName($name)
250
    {
251 8
        return 'VFI_' . $name;
252
    }
253
254
255
    /**
256
     * @param string $field [optional]
257
     * @return array|false
258
     */
259 8
    public function getVFISpec($field = '')
260
    {
261 8
        $spec = self::get_vfi_spec($this->owner->class);
262 8
        if ($field) {
263 8
            return empty($spec[$field]) ? false : $spec[$field];
264
        } else {
265 8
            return $spec;
266
        }
267
    }
268
269
270
    /**
271
     * @param string $field
272
     * @param bool   $fromSource [optional] - if true, it will regenerate the data from the source fields
273
     * @param bool   $forceIDs [optional] - if true, it will return an ID even if the norm is to return a DataObject
274
     * @return string|array|SS_List
275
     */
276 8
    public function getVFI($field, $fromSource=false, $forceIDs=false)
277
    {
278 8
        $spec = $this->getVFISpec($field);
279 8
        if (!$spec) {
280 1
            return null;
281
        }
282 8
        if ($fromSource) {
283 8
            if (is_array($spec['Source'])) {
284 8
                $out = array();
285 8
                foreach ($spec['Source'] as $src) {
286 8
                    $myOut = self::get_value($src, $this->owner);
287 8
                    if (is_array($myOut)) {
288
                        $out = array_merge($out, $myOut);
289 8
                    } elseif (is_object($myOut) && $myOut instanceof SS_List) {
290 8
                        $out = array_merge($out, $myOut->toArray());
291 8
                    } else {
292 8
                        $out[] = $myOut;
293
                    }
294 8
                }
295 8
                return $out;
296
            } else {
297 8
                return self::get_value($spec['Source'], $this->owner);
298
            }
299
        } else {
300 3
            $val = $this->owner->getField($this->getVFIFieldName($field));
301 3
            if ($spec['Type'] == self::TYPE_LIST) {
302 3
                return self::decode_list($val, $forceIDs);
303
            } else {
304 1
                return $val;
305
            }
306
        }
307
    }
308
309
310
    /**
311
     * Template version
312
     * @param string $field
313
     * @return string|array|SS_List
314
     */
315
    public function VFI($field)
316
    {
317
        return $this->getVFI($field);
318
    }
319
320
321
    /**
322
     * @param array $list
323
     * @return string
324
     */
325 8
    protected static function encode_list(array $list)
326
    {
327
        // If we've got objects, encode them a little differently
328 8
        if (count($list) > 0 && is_object($list[0])) {
329 8
            $ids = array();
330 8
            foreach ($list as $rec) {
331 8
                $ids[] = $rec->ID;
332 8
            }
333 8
            $val = '>' . $list[0]->ClassName . '|' . implode('|', $ids) . '|';
334 8
        } else {
335
            $val = '|' . implode('|', $list) . '|';
336
        }
337
338 8
        return $val;
339
    }
340
341
342
    /**
343
     * @param string $val
344
     * @param bool   $forceIDs [optional] - if true encoded objects will not be returned as objects but as id's
345
     * @return array
346
     */
347 8
    protected static function decode_list($val, $forceIDs=false)
348
    {
349 3
        if ($val[0] == '>') {
350 3
            $firstBar = strpos($val, '|');
351 3
            if ($firstBar < 3) {
352
                return array();
353
            }
354 3
            $className = substr($val, 1, $firstBar-1);
355 8
            $ids = explode('|', trim(substr($val, $firstBar), '|'));
356 3
            return $forceIDs ? $ids : DataObject::get($className)->filter('ID', $ids)->toArray();
357
        } else {
358
            return explode('|', trim($val, '|'));
359
        }
360
    }
361
362
363
    /**
364
     * This is largely borrowed from DataObject::relField, but
365
     * adapted to work with many-many and has-many fields.
366
     * @param string $fieldName
367
     * @param DataObject $rec
368
     * @return mixed
369
     */
370 8
    protected static function get_value($fieldName, DataObject $rec)
371
    {
372 8
        $component = $rec;
373
374
        // We're dealing with relations here so we traverse the dot syntax
375 8
        if (strpos($fieldName, '.') !== false) {
376
            $relations = explode('.', $fieldName);
377
            $fieldName = array_pop($relations);
378
            foreach ($relations as $relation) {
379
                // Inspect $component for element $relation
380
                if ($component->hasMethod($relation)) {
381
                    // Check nested method
382
                    $component = $component->$relation();
383
                } elseif ($component instanceof SS_List) {
384
                    // Select adjacent relation from DataList
385
                    $component = $component->relation($relation);
386
                } elseif ($component instanceof DataObject && ($dbObject = $component->dbObject($relation))) {
387
                    // Select db object
388
                    $component = $dbObject;
389
                } else {
390
                    user_error("$relation is not a relation/field on ".get_class($component), E_USER_ERROR);
391
                }
392
            }
393
        }
394
395
        // Bail if the component is null
396 8
        if (!$component) {
397
            return null;
398 8
        } elseif ($component instanceof SS_List) {
399
            return $component->column($fieldName);
400 8
        } elseif ($component->hasMethod($fieldName)) {
401 8
            return $component->$fieldName();
402
        } else {
403
            return $component->$fieldName;
404
        }
405
    }
406
407
    /**
408
     * Trigger rebuild if needed
409
     */
410 8
    public function onBeforeWrite()
411
    {
412 8
        if ($this->isRebuilding || self::$disable_building) {
413 8
            return;
414
        }
415
416 8
        $queueFields = interface_exists('QueuedJob') ? array() : false;
417
418 8
        foreach ($this->getVFISpec() as $field => $spec) {
0 ignored issues
show
Bug introduced by
The expression $this->getVFISpec() of type array|false is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
419 8
            $rebuild = false;
420
421 8
            if ($spec['DependsOn'] == self::DEPENDS_NONE) {
422
                continue;
423 8
            } elseif ($spec['DependsOn'] == self::DEPENDS_ALL) {
424 8
                $rebuild = true;
425 8
            } elseif (is_array($spec['DependsOn'])) {
426
                foreach ($spec['DependsOn'] as $f) {
427
                    if ($this->owner->isChanged($f)) {
428
                        $rebuild = true;
429
                        break;
430
                    }
431
                }
432
            } else {
433 8
                if ($this->owner->isChanged($spec['DependsOn'])) {
434
                    $rebuild = true;
435
                }
436
            }
437
438 8
            if ($rebuild) {
439 8
                if ($queueFields === false) {
440 8
                    $this->rebuildVFI($field);
441 8
                } else {
442
                    $queueFields[] = $field;
443
                }
444 8
            }
445 8
        }
446
447
        // if the queued-jobs module is present, then queue up the rebuild
448 8
        if ($queueFields) {
449
            $job = new VirtualFieldIndexQueuedJob($this->owner, $queueFields);
450
            $job->triggerProcessing();
451
        }
452 8
    }
453
}
454