Completed
Push — master ( 4cc8d7...01120a )
by Russell
02:11
created

JSONText   B

Complexity

Total Complexity 43

Size/Duplication

Total Lines 391
Duplicated Lines 0 %

Coupling/Cohesion

Components 3
Dependencies 3

Importance

Changes 4
Bugs 0 Features 3
Metric Value
wmc 43
c 4
b 0
f 3
lcom 3
cbo 3
dl 0
loc 391
rs 8.3157

20 Methods

Rating   Name   Duplication   Size   Complexity  
A updateCache() 0 3 1
A __construct() 0 4 1
A requireField() 0 16 1
A scaffoldSearchField() 0 4 1
A scaffoldFormField() 0 4 1
A setReturnType() 0 9 2
A getReturnType() 0 4 1
A getValueAsIterable() 0 20 4
A getValueAsArray() 0 4 1
A isJson() 0 4 1
A toJson() 0 12 2
A toArray() 0 11 2
A first() 0 13 2
A last() 0 13 2
B nth() 0 23 5
B query() 0 24 5
A extract() 0 4 1
B marshallQuery() 0 30 5
A returnAsType() 0 10 3
A isValidOperator() 0 6 2

How to fix   Complexity   

Complex Class

Complex classes like JSONText 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 JSONText, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * Simple text-based database field for storing and querying JSON structured data. 
5
 * 
6
 * JSON sub-structures can be queried in a variety of ways using special operators who's syntax closely mimics those used
7
 * in native JSON queries in PostGreSQL v9.2+.
8
 * 
9
 * Note: The extraction techniques employed here are simple key / value comparisons. They do not use any native JSON
10
 * features of your project's underlying RDBMS, e.g. those found either in PostGreSQL >= v9.2 or MySQL >= v5.7. As such
11
 * any JSON "queries" you construct will never be as performant as a native implementation. 
12
 *
13
 * Example definition via {@link DataObject::$db} static:
14
 * 
15
 * <code>
16
 * static $db = [
17
 *  'MyJSONStructure' => 'JSONText'
18
 * ];
19
 * </code>
20
 * 
21
 * @package silverstripe-jsontext
22
 * @subpackage fields
23
 * @author Russell Michell <[email protected]>
24
 * @todo Make the current default of "strict mode" into ss config and default to strict.
25
 */
26
27
namespace JSONText\Fields;
28
29
use JSONText\Exceptions\JSONTextException;
30
31
class JSONText extends \StringField
32
{
33
    /**
34
     * Which RDBMS backend are we using? The value set here changes the actual operators and operator-routines for the
35
     * given backend.
36
     * 
37
     * @var string
38
     * @config
39
     */
40
    private static $backend = 'postgres';
1 ignored issue
show
Unused Code introduced by
The property $backend 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...
41
    
42
    /**
43
     * @var array
44
     * @config
45
     * 
46
     * [<backend>] => [
47
     *  [<method> => <operator>]
48
     * ]; // For use in query() method.
49
     */
50
    private static $allowed_operators = [
0 ignored issues
show
Unused Code introduced by
The property $allowed_operators 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
        'postgres' => [
52
            'matchIfKeyIsInt'   => '->',
53
            'matchIfKeyIsStr'   => '->>',
54
            'matchOnPath'       => '#>'
55
        ]
56
    ];
57
58
    /**
59
     * @var string
60
     */
61
    protected $returnType = 'json';
62
63
    /**
64
     * Object cache for performance improvements.
65
     * 
66
     * @var \RecursiveIteratorIterator
67
     */
68
    protected $data;
69
70
    /**
71
     * @var array
72
     */
73
    protected $cache = [];
74
    
75
    public function updateCache($val) {
76
        $this->cache[] = $val;
77
    }
78
    
79
    /**
80
     * Returns an input field.
81
     *
82
     * @param string $name
83
     * @param null|string $title
84
     * @param string $value
85
     */
86
    public function __construct($name, $title = null, $value = '')
87
    {
88
        parent::__construct($name, $title, $value);
0 ignored issues
show
Documentation introduced by
$title is of type null|string, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
Unused Code introduced by
The call to StringField::__construct() has too many arguments starting with $value.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
89
    }
90
91
    /**
92
     * Taken from {@link TextField}.
93
     * @see DBField::requireField()
94
     * @return void
95
     */
96
    public function requireField()
97
    {
98
        $parts = [
99
            'datatype'      => 'mediumtext',
100
            'character set' => 'utf8',
101
            'collate'       => 'utf8_general_ci',
102
            'arrayValue'    => $this->arrayValue
103
        ];
104
105
        $values = [
106
            'type'  => 'text',
107
            'parts' => $parts
108
        ];
109
110
        DB::require_field($this->tableName, $this->name, $values, $this->default);
0 ignored issues
show
Bug introduced by
The property default does not seem to exist. Did you mean defaultVal?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
111
    }
112
113
    /**
114
     * @param string $title
115
     * @return HiddenField
116
     */
117
    public function scaffoldSearchField($title = null)
118
    {
119
        return HiddenField::create($this->getName());
120
    }
121
122
    /**
123
     * @param string $title
124
     * @return HiddenField
125
     */
126
    public function scaffoldFormField($title = null)
127
    {
128
        return HiddenField::create($this->getName());
129
    }
130
131
    /**
132
     * Tell all class methods to return data as JSON or an array.
133
     * 
134
     * @param string $type
135
     * @return \JSONText
136
     * @throws \JSONText\Exceptions\JSONTextException
137
     */
138
    public function setReturnType($type)
139
    {
140
        if (!in_array($type, ['json', 'array'])) {
141
            $msg = 'Bad type: ' . $type . ' passed to ' . __FUNCTION__;
142
            throw new JSONTextException($msg);
143
        }
144
        
145
        $this->returnType = $type;
146
    }
147
148
    /**
149
     * @return string
150
     */
151
    public function getReturnType()
152
    {
153
        return $this->returnType;
154
    }
155
156
    /**
157
     * Returns the value of this field as an iterable.
158
     * 
159
     * @return \RecursiveIteratorIterator
160
     * @throws \JSONText\Exceptions\JSONTextException
161
     */
162
    public function getValueAsIterable()
163
    {
164
        if (!$json = $this->getValue()) {
165
            return [];
0 ignored issues
show
Bug Best Practice introduced by
The return type of return array(); (array) is incompatible with the return type documented by JSONText\Fields\JSONText::getValueAsIterable of type RecursiveIteratorIterator.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
166
        }
167
        
168
        if (!$this->isJson($json)) {
169
            $msg = 'DB data is munged.';
170
            throw new JSONTextException($msg);
171
        }
172
173
        if (!$this->data) {
174
            $this->data = new \RecursiveIteratorIterator(
175
                new \RecursiveArrayIterator(json_decode($json, true)),
176
                \RecursiveIteratorIterator::SELF_FIRST
177
            );
178
        }
179
        
180
        return $this->data;
181
    }
182
183
    /**
184
     * Returns the value of this field as a flattened array
185
     *
186
     * @return array
187
     */
188
    public function getValueAsArray()
189
    {
190
        return iterator_to_array($this->getValueAsIterable());
191
    }
192
193
    /**
194
     * Utility method to determine whether the data is really JSON or not.
195
     * 
196
     * @param string $value
197
     * @return boolean
198
     */
199
    public function isJson($value)
200
    {
201
        return !is_null(json_decode($value, true));
202
    }
203
204
    /**
205
     * @param array $value
206
     * @return mixed null|string
207
     */
208
    public function toJson($value)
209
    {
210
        if (!is_array($value)) {
211
            $value = (array) $value;
212
        }
213
        
214
        $opts = (
215
            JSON_UNESCAPED_SLASHES
216
        );
217
        
218
        return json_encode($value, $opts);
219
    }
220
221
    /**
222
     * @param mixed $value
223
     * @return array
224
     * @throws \JSONText\Exceptions\JSONTextException
225
     */
226
    public function toArray($value)
227
    {
228
        $decode = json_decode($value, true);
229
        
230
        if (is_null($decode)) {
231
            $msg = 'Decoded JSON is invalid.';
232
            throw new JSONTextException($msg);
233
        }
234
        
235
        return $decode;
236
    }
237
    
238
    /**
239
     * Return an array of the JSON key + value represented as first (top-level) JSON node. 
240
     *
241
     * @return array
242
     */
243
    public function first()
244
    {
245
        $data = $this->getValueAsIterable();
246
        
247
        if (!$data) {
248
            return $this->returnAsType([]);
249
        }
250
251
        $flattened = iterator_to_array($data, true);
252
        return $this->returnAsType([
253
                array_keys($flattened)[0] => array_values($flattened)[0]
254
            ]);
255
    }
256
257
    /**
258
     * Return an array of the JSON key + value represented as last JSON node.
259
     *
260
     * @return array
261
     */
262
    public function last()
263
    {
264
        $data = $this->getValueAsIterable();
265
266
        if (!$data) {
267
            return $this->returnAsType([]);
268
        }
269
270
        $flattened = iterator_to_array($data, true);
271
        return $this->returnAsType([
272
                array_keys($flattened)[count($flattened) -1] => array_values($flattened)[count($flattened) -1]
273
            ]);
274
    }
275
276
    /**
277
     * Return an array of the JSON key + value represented as the $n'th JSON node.
278
     *
279
     * @param int $n
280
     * @return mixed array
281
     * @throws \JSONText\Exceptions\JSONTextException
282
     */
283
    public function nth($n)
284
    {
285
        $data = $this->getValueAsIterable();
286
287
        if (!$data) {
288
            return $this->returnAsType([]);
289
        }
290
        
291
        if (!is_int($n)) {
292
            $msg = 'Argument passed to ' . __FUNCTION__ . ' must be an integer.';
293
            throw new JSONTextException($msg);
294
        }
295
296
        $i = 0;
297
        foreach ($data as $key => $val) {
298
            if ($i === $n) {
299
                return $this->returnAsType([$key => $val]);
300
            }
301
            $i++;
302
        }
303
        
304
        return $this->returnAsType($data);
0 ignored issues
show
Documentation introduced by
$data is of type object<RecursiveIteratorIterator>, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
305
    }
306
307
    /**
308
     * Return an array of the JSON key(s) + value(s) represented by $operator extracting relevant result in a JSON 
309
     * node's value.
310
     *
311
     * @param string $operator
312
     * @param string $operand
313
     * @return mixed null|array
314
     * @throws \JSONText\Exceptions\JSONTextException
315
     * @todo How to increment an interator for each depth using $data->getDepth() and $i ??
316
     */
317
    public function query($operator, $operand)
0 ignored issues
show
Unused Code introduced by
The parameter $operand is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
318
    {
319
        $data = $this->getValueAsIterable();
320
        
321
        if (!$data) {
322
            return $this->returnAsType([]);
323
        }
324
        
325
        if (!$this->isValidOperator($operator)) {
326
            $msg = 'JSON operator: ' . $operator . ' is invalid.';
327
            throw new JSONTextException($msg);
328
        }
329
        
330
        $i = 0;
331
        foreach ($data as $key => $val) {
332
            if ($marshalled = $this->marshallQuery($key, $val, $i, func_get_args())) {
333
                return $this->returnAsType($marshalled);
334
            }
335
            
336
            $i++;
337
        }
338
339
        return $this->returnAsType([]);
340
    }
341
342
    /**
343
     * Alias of self::query().
344
     * 
345
     * @param string $operator
346
     * @return mixed string|array
347
     * @throws \JSONText\Exceptions\JSONTextException
348
     */
349
    public function extract($operator)
350
    {
351
        return $this->extract($operator);
352
    }
353
354
    /**
355
     * @param mixed $key
356
     * @param mixed $val
357
     * @param int $idx
358
     * @param array $args
359
     * @return array
360
     * @throws \JSONText\Exceptions\JSONTextException
361
     */
362
    private function marshallQuery($key, $val, $idx, $args)
363
    {
364
        $backend = $this->config()->backend;
365
        $operator = $args[0];
366
        $operand = $args[1];
367
        $operators = $this->config()->allowed_operators[$backend];
368
        
369
        if (!in_array($operator, $operators)) {
370
            $msg = 'Invalid ' . $backend . ' operator: ' . $operator . ', used for JSON query.';
371
            throw new JSONTextException($msg);
372
        }
373
        
374
        foreach ($operators as $routine => $backendOperator) {
375
            $backendDBApiInst = \Injector::inst()->createWithArgs(
376
                '\JSONText\Backends\JSONBackend', [
377
                    $key, 
378
                    $val, 
379
                    $idx,
380
                    $backendOperator,
381
                    $operand,
382
                    $this
383
                ]);
384
            
385
            if ($operator === $backendOperator && $result = $backendDBApiInst->$routine()) {
386
               return $result;
387
            }
388
        }
389
        
390
        return [];
391
    }
392
393
    /**
394
     * @param array $data
395
     * @return mixed
396
     */
397
    private function returnAsType(array $data)
398
    {
399
        if (($this->getReturnType() === 'array')) {
400
            return $data;
401
        }
402
403
        if (($this->getReturnType() === 'json')) {
404
            return $this->toJson($data);
405
        }
406
    }
407
408
    /**
409
     * Is the passed JSON operator valid?
410
     *
411
     * @param string $operator
412
     * @return boolean
413
     */
414
    private function isValidOperator($operator)
415
    {
416
        $backend = $this->config()->backend;
417
418
        return $operator && in_array($operator, $this->config()->allowed_operators[$backend], true);
419
    }
420
421
}
422
423
/**
424
 * @package silverstripe-advancedcontent
425
 * @author Russell Michell 2016 <[email protected]>
426
 */
427
428
namespace JSONText\Exceptions;
429
430
class JSONTextException extends \Exception
0 ignored issues
show
Coding Style Compatibility introduced by
PSR1 recommends that each class should be in its own file to aid autoloaders.

Having each class in a dedicated file usually plays nice with PSR autoloaders and is therefore a well established practice. If you use other autoloaders, you might not want to follow this rule.

Loading history...
431
{
432
}
433