Completed
Push — master ( 83f346...123e90 )
by Russell
06:33
created

JSONText   B

Complexity

Total Complexity 41

Size/Duplication

Total Lines 373
Duplicated Lines 0 %

Coupling/Cohesion

Components 2
Dependencies 3

Importance

Changes 3
Bugs 0 Features 2
Metric Value
wmc 41
c 3
b 0
f 2
lcom 2
cbo 3
dl 0
loc 373
rs 8.2769

18 Methods

Rating   Name   Duplication   Size   Complexity  
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 21 4
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
     * Returns an input field.
72
     *
73
     * @param string $name
74
     * @param null|string $title
75
     * @param string $value
76
     */
77
    public function __construct($name, $title = null, $value = '')
78
    {
79
        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...
80
    }
81
82
    /**
83
     * Taken from {@link TextField}.
84
     * @see DBField::requireField()
85
     * @return void
86
     */
87
    public function requireField()
88
    {
89
        $parts = [
90
            'datatype'      => 'mediumtext',
91
            'character set' => 'utf8',
92
            'collate'       => 'utf8_general_ci',
93
            'arrayValue'    => $this->arrayValue
94
        ];
95
96
        $values = [
97
            'type'  => 'text',
98
            'parts' => $parts
99
        ];
100
101
        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...
102
    }
103
104
    /**
105
     * @param string $title
106
     * @return HiddenField
107
     */
108
    public function scaffoldSearchField($title = null)
109
    {
110
        return HiddenField::create($this->getName());
111
    }
112
113
    /**
114
     * @param string $title
115
     * @return HiddenField
116
     */
117
    public function scaffoldFormField($title = null)
118
    {
119
        return HiddenField::create($this->getName());
120
    }
121
122
    /**
123
     * Tell all class methods to return data as JSON or an array.
124
     * 
125
     * @param string $type
126
     * @return \JSONText
127
     * @throws \JSONText\Exceptions\JSONTextException
128
     */
129
    public function setReturnType($type)
130
    {
131
        if (!in_array($type, ['json', 'array'])) {
132
            $msg = 'Bad type: ' . $type . ' passed to ' . __FUNCTION__;
133
            throw new \JSONText\Exceptions\JSONTextException($msg);
134
        }
135
        
136
        $this->returnType = $type;
137
    }
138
139
    /**
140
     * @return string
141
     */
142
    public function getReturnType()
143
    {
144
        return $this->returnType;
145
    }
146
147
    /**
148
     * Returns the value of this field as an iterable.
149
     * 
150
     * @return \RecursiveIteratorIterator
151
     * @throws \JSONText\Exceptions\JSONTextException
152
     */
153
    public function getValueAsIterable()
154
    {
155
        if (!$json = $this->getValue()) {
156
            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...
157
        }
158
        
159
        if (!$this->isJson($json)) {
160
            $msg = 'DB data is munged.';
161
            throw new \JSONText\Exceptions\JSONTextException($msg);
162
        }
163
164
        if (!$this->data) {
165
            $this->data = new \RecursiveIteratorIterator(
166
                new \RecursiveArrayIterator(json_decode($json, true)),
167
                \RecursiveIteratorIterator::SELF_FIRST
168
            );
169
        }
170
        
171
        return $this->data;
172
173
    }
174
175
    /**
176
     * Utility method to determine whether the data is really JSON or not.
177
     * 
178
     * @param string $value
179
     * @return boolean
180
     */
181
    public function isJson($value)
182
    {
183
        return !is_null(json_decode($value, true));
184
    }
185
186
    /**
187
     * @param array $value
188
     * @return mixed null|string
189
     */
190
    public function toJson($value)
191
    {
192
        if (!is_array($value)) {
193
            $value = (array) $value;
194
        }
195
        
196
        $opts = (
197
            JSON_UNESCAPED_SLASHES
198
        );
199
        
200
        return json_encode($value, $opts);
201
    }
202
203
    /**
204
     * @param mixed $value
205
     * @return array
206
     * @throws \JSONText\Exceptions\JSONTextException
207
     */
208
    public function toArray($value)
209
    {
210
        $decode = json_decode($value, true);
211
        
212
        if (is_null($decode)) {
213
            $msg = 'Decoded JSON is invalid.';
214
            throw new JSONTextException($msg);
215
        }
216
        
217
        return $decode;
218
    }
219
    
220
    /**
221
     * Return an array of the JSON key + value represented as first (top-level) JSON node. 
222
     *
223
     * @return array
224
     */
225
    public function first()
226
    {
227
        $data = $this->getValueAsIterable();
228
        
229
        if (!$data) {
230
            return $this->returnAsType([]);
231
        }
232
233
        $flattened = iterator_to_array($data, true);
234
        return $this->returnAsType([
235
                array_keys($flattened)[0] => array_values($flattened)[0]
236
            ]);
237
    }
238
239
    /**
240
     * Return an array of the JSON key + value represented as last JSON node.
241
     *
242
     * @return array
243
     */
244
    public function last()
245
    {
246
        $data = $this->getValueAsIterable();
247
248
        if (!$data) {
249
            return $this->returnAsType([]);
250
        }
251
252
        $flattened = iterator_to_array($data, true);
253
        return $this->returnAsType([
254
                array_keys($flattened)[count($flattened) -1] => array_values($flattened)[count($flattened) -1]
255
            ]);
256
    }
257
258
    /**
259
     * Return an array of the JSON key + value represented as the $n'th JSON node.
260
     *
261
     * @param int $n
262
     * @return mixed array
263
     * @throws \JSONText\Exceptions\JSONTextException
264
     */
265
    public function nth($n)
266
    {
267
        $data = $this->getValueAsIterable();
268
269
        if (!$data) {
270
            return $this->returnAsType([]);
271
        }
272
        
273
        if (!is_int($n)) {
274
            $msg = 'Argument passed to ' . __FUNCTION__ . ' must be an integer.';
275
            throw new \JSONText\Exceptions\JSONTextException($msg);
276
        }
277
278
        $i = 0;
279
        foreach ($data as $key => $val) {
280
            if ($i === $n) {
281
                return $this->returnAsType([$key => $val]);
282
            }
283
            $i++;
284
        }
285
        
286
        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...
287
    }
288
289
    /**
290
     * Return an array of the JSON key(s) + value(s) represented by $operator extracting relevant result in a JSON 
291
     * node's value.
292
     *
293
     * @param string $operator
294
     * @param string $operand
295
     * @return mixed null|array
296
     * @throws \JSONText\Exceptions\JSONTextException
297
     * @todo How to increment an interator for each depth using $data->getDepth() and $i ??
298
     */
299
    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...
300
    {
301
        $data = $this->getValueAsIterable();
302
        
303
        if (!$data) {
304
            return $this->returnAsType([]);
305
        }
306
        
307
        if (!$this->isValidOperator($operator)) {
308
            $msg = 'JSON operator: ' . $operator . ' is invalid.';
309
            throw new \JSONText\Exceptions\JSONTextException($msg);
310
        }
311
        
312
        $i = 0;
313
        foreach ($data as $key => $val) {
314
            if ($marshalled = $this->marshallQuery($key, $val, $i, func_get_args())) {
315
                return $this->returnAsType($marshalled);
316
            }
317
            
318
            $i++;
319
        }
320
321
        return $this->returnAsType([]);
322
    }
323
324
    /**
325
     * Alias of self::query().
326
     * 
327
     * @param string $operator
328
     * @return mixed string|array
329
     * @throws \JSONText\Exceptions\JSONTextException
330
     */
331
    public function extract($operator)
332
    {
333
        return $this->extract($operator);
334
    }
335
336
    /**
337
     * @param mixed $key
338
     * @param mixed $val
339
     * @param int $idx
340
     * @param array $args
341
     * @return array
342
     * @throws \JSONText\Exceptions\JSONTextException
343
     */
344
    private function marshallQuery($key, $val, $idx, $args)
345
    {
346
        $backend = $this->config()->backend;
347
        $operator = $args[0];
348
        $operand = $args[1];
349
        $operators = $this->config()->allowed_operators[$backend];
350
        
351
        if (!in_array($operator, $operators)) {
352
            $msg = 'Invalid ' . $backend . ' operator: ' . $operator . ', used for JSON query.';
353
            throw new \JSONText\Exceptions\JSONTextException($msg);
354
        }
355
        
356
        foreach ($operators as $routine => $backendOperator) {
357
            $backendDBApiInst = \Injector::inst()->createWithArgs(
358
                '\JSONText\Backends\JSONBackend', [
359
                    $key, 
360
                    $val, 
361
                    $idx,
362
                    $backendOperator,
363
                    $operand,
364
                    $this
365
                ]);
366
            
367
            if ($operator === $backendOperator && $result = $backendDBApiInst->$routine()) {
368
                return $result;
369
            }
370
        }
371
        
372
        return [];
373
    }
374
375
    /**
376
     * @param array $data
377
     * @return mixed
378
     */
379
    private function returnAsType(array $data)
380
    {
381
        if (($this->getReturnType() === 'array')) {
382
            return $data;
383
        }
384
385
        if (($this->getReturnType() === 'json')) {
386
            return $this->toJson($data);
387
        }
388
    }
389
390
    /**
391
     * Is the passed JSON operator valid?
392
     *
393
     * @param string $operator
394
     * @return boolean
395
     */
396
    private function isValidOperator($operator)
397
    {
398
        $backend = $this->config()->backend;
399
400
        return $operator && in_array($operator, $this->config()->allowed_operators[$backend], true);
401
    }
402
403
}
404
405
/**
406
 * @package silverstripe-advancedcontent
407
 * @author Russell Michell 2016 <[email protected]>
408
 */
409
410
namespace JSONText\Exceptions;
411
412
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...
413
{
414
}
415