Completed
Push — master ( 301d75...7b46e5 )
by Russell
02:43
created

JSONText::marshallQuery()   C

Complexity

Conditions 8
Paths 10

Size

Total Lines 36
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

Changes 7
Bugs 1 Features 4
Metric Value
c 7
b 1
f 4
dl 0
loc 36
rs 5.3846
cc 8
eloc 24
nc 10
nop 2
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
 * See the README for example queries.
22
 * 
23
 * @package silverstripe-jsontext
24
 * @subpackage fields
25
 * @author Russell Michell <[email protected]>
26
 * @todo Rename query() to getValue() that accepts optional param $expr (for JSONPath queries)
27
 */
28
29
namespace JSONText\Fields;
30
31
use JSONText\Exceptions\JSONTextException;
32
use JSONText\Backends;
33
use Peekmo\JsonPath\JsonStore;
34
35
class JSONText extends \StringField
36
{
37
    /**
38
     * @var int
39
     */
40
    const JSONTEXT_QUERY_OPERATOR = 1;
41
42
    /**
43
     * @var int
44
     */
45
    const JSONTEXT_QUERY_JSONPATH = 2;
46
    
47
    /**
48
     * Which RDBMS backend are we using? The value set here changes the actual operators and operator-routines for the
49
     * given backend.
50
     * 
51
     * @var string
52
     * @config
53
     */
54
    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...
55
    
56
    /**
57
     * @var array
58
     * @config
59
     * 
60
     * [<backend>] => [
61
     *  [<method> => <operator>]
62
     * ]; // For use in query() method.
63
     */
64
    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...
65
        'postgres' => [
66
            'matchOnInt'    => '->',
67
            'matchOnStr'    => '->>',
68
            'matchOnPath'   => '#>'
69
        ]
70
    ];
71
72
    /**
73
     * Legitimate query return types.
74
     * 
75
     * @var array
76
     */
77
    private static $return_types = [
0 ignored issues
show
Unused Code introduced by
The property $return_types 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...
78
        'json', 'array', 'silverstripe'
79
    ];
80
81
    /**
82
     * @var string
83
     */
84
    protected $returnType = 'json';
85
86
    /**
87
     * @var \Peekmo\JsonPath\JsonStore
88
     */
89
    protected $jsonStore;
90
91
    /**
92
     * Taken from {@link TextField}.
93
     * 
94
     * @see DBField::requireField()
95
     * @return void
96
     */
97
    public function requireField()
98
    {
99
        $parts = [
100
            'datatype'      => 'mediumtext',
101
            'character set' => 'utf8',
102
            'collate'       => 'utf8_general_ci',
103
            'arrayValue'    => $this->arrayValue
104
        ];
105
106
        $values = [
107
            'type'  => 'text',
108
            'parts' => $parts
109
        ];
110
111
        DB::require_field($this->tableName, $this->name, $values);
112
    }
113
114
    /**
115
     * @param string $title
116
     * @return HiddenField
117
     */
118
    public function scaffoldSearchField($title = null)
119
    {
120
        return HiddenField::create($this->getName());
121
    }
122
123
    /**
124
     * @param string $title
125
     * @return HiddenField
126
     */
127
    public function scaffoldFormField($title = null)
128
    {
129
        return HiddenField::create($this->getName());
130
    }
131
132
    /**
133
     * Tell all class methods to return data as JSON , an array or an array of SilverStripe DBField subtypes.
134
     * 
135
     * @param string $type
136
     * @return \JSONText
137
     * @throws \JSONText\Exceptions\JSONTextException
138
     */
139
    public function setReturnType($type)
140
    {
141
        if (!in_array($type, $this->config()->return_types)) {
142
            $msg = 'Bad type: ' . $type . ' passed to ' . __FUNCTION__;
143
            throw new JSONTextException($msg);
144
        }
145
        
146
        $this->returnType = $type;
147
        
148
        return $this;
149
    }
150
151
    /**
152
     * @return string
153
     */
154
    public function getReturnType()
155
    {
156
        return $this->returnType;
157
    }
158
159
    /**
160
     * Returns the value of this field as an iterable.
161
     * 
162
     * @return \Peekmo\JsonPath\JsonStore
163
     * @throws \JSONText\Exceptions\JSONTextException
164
     */
165
    public function getJSONStore()
166
    {
167
        if (!$json = $this->getValue()) {
168
            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::getJSONStore of type Peekmo\JsonPath\JsonStore.

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...
169
        }
170
        
171
        if (!$this->isJson($json)) {
172
            $msg = 'DB data is munged.';
173
            throw new JSONTextException($msg);
174
        }
175
176
        $this->jsonStore = new \Peekmo\JsonPath\JsonStore($json);
177
        
178
        return $this->jsonStore;
179
    }
180
181
    /**
182
     * Returns the JSON value of this field as an array.
183
     *
184
     * @return array
185
     */
186
    public function getStoreAsArray()
187
    {
188
        $store = $this->getJSONStore();
189
        if (!is_array($store)) {
190
            return $store->toArray();
191
        }
192
        
193
        return $store;
194
    }
195
196
    /**
197
     * Utility method to determine whether the data is really JSON or not.
198
     * 
199
     * @param string $value
200
     * @return boolean
201
     */
202
    public function isJson($value)
203
    {
204
        return !is_null(json_decode($value, true));
205
    }
206
207
    /**
208
     * Convert an array to JSON via json_encode().
209
     * 
210
     * @param array $value
211
     * @return mixed null|string
212
     */
213
    public function toJson(array $value)
214
    {
215
        if (!is_array($value)) {
216
            $value = (array) $value;
217
        }
218
        
219
        $opts = (
220
            JSON_UNESCAPED_SLASHES
221
        );
222
        
223
        return json_encode($value, $opts);
224
    }
225
    
226
    /**
227
     * Convert an array's values into an array of SilverStripe DBField subtypes ala:
228
     * 
229
     * - {@link Int}
230
     * - {@link Float}
231
     * - {@link Boolean}
232
     * - {@link Varchar}
233
     * 
234
     * @param array $data
235
     * @return array
236
     */
237
    public function toSSTypes(array $data)
238
    {
239
        $newList = [];
240
        foreach ($data as $key => $val) {
241
            if (is_array($val)) {
242
                $newList[$key] = $this->toSSTypes($val);
243
            } else {
244
                $newList[$key] = $this->castToDBField($val);
245
            }
246
        }
247
        
248
        return $newList;
249
    }
250
251
    /**
252
     * @param mixed $value
253
     * @return array
254
     * @throws \JSONText\Exceptions\JSONTextException
255
     */
256
    public function toArray($value)
257
    {
258
        $decode = json_decode($value, true);
259
        
260
        if (is_null($decode)) {
261
            $msg = 'Decoded JSON is invalid.';
262
            throw new JSONTextException($msg);
263
        }
264
        
265
        return $decode;
266
    }
267
    
268
    /**
269
     * Return an array of the JSON key + value represented as first (top-level) JSON node. 
270
     *
271
     * @return array
272
     */
273
    public function first()
274
    {
275
        $data = $this->getStoreAsArray();
276
        
277
        if (!$data) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $data 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...
278
            return $this->returnAsType([]);
279
        }
280
281
        $key = array_keys($data)[0];
282
        $val = array_values($data)[0];
283
284
        return $this->returnAsType([$key => $val]);
285
    }
286
287
    /**
288
     * Return an array of the JSON key + value represented as last JSON node.
289
     *
290
     * @return array
291
     */
292
    public function last()
293
    {
294
        $data = $this->getStoreAsArray();
295
296
        if (!$data) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $data 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...
297
            return $this->returnAsType([]);
298
        }
299
300
        $count = count($data) -1;
301
        $key = array_keys($data)[$count];
302
        $val = array_values($data)[$count];
303
304
        return $this->returnAsType([$key => $val]);
305
    }
306
307
    /**
308
     * Return an array of the JSON key + value represented as the $n'th JSON node.
309
     *
310
     * @param int $n
311
     * @return mixed array
312
     * @throws \JSONText\Exceptions\JSONTextException
313
     */
314
    public function nth($n)
315
    {
316
        $data = $this->getStoreAsArray();
317
318
        if (!$data) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $data 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...
319
            return $this->returnAsType([]);
320
        }
321
        
322
        if (!is_int($n)) {
323
            $msg = 'Argument passed to ' . __FUNCTION__ . ' must be an integer.';
324
            throw new JSONTextException($msg);
325
        }
326
327
        $i = 0;
328
        foreach ($data as $key => $val) {
329
            if ($i === $n) {
330
                return $this->returnAsType([$key => $val]);
331
            }
332
            $i++;
333
        }
334
        
335
        return $this->returnAsType($data);
336
    }
337
338
    /**
339
     * Return the key(s) + value(s) represented by $operator extracting relevant result from the source JSON's structure.
340
     * N.b when using the path match operator '#>' with duplicate keys, an indexed array of results is returned.
341
     *
342
     * @param string $operator One of the legitimate operators for the current backend or a valid JSONPath expression.
343
     * @param string $operand
344
     * @return mixed null|array
345
     * @throws \JSONText\Exceptions\JSONTextException
346
     */
347
    public function query($operator, $operand = null)
348
    {
349
        $data = $this->getStoreAsArray();
350
        
351
        if (!$data) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $data 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...
352
            return $this->returnAsType([]);
353
        }
354
355
        $isOp = ($operand && $this->isOperator($operator));
0 ignored issues
show
Bug Best Practice introduced by
The expression $operand of type string|null 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...
356
        $isEx = (is_null($operand) && $this->isExpression($operator));
357
        
358
        if ($isOp && !$this->isValidOperator($operator)) {
359
            $msg = 'JSON operator: ' . $operator . ' is invalid.';
360
            throw new JSONTextException($msg);
361
        }
362
363 View Code Duplication
        if ($isEx && !$this->isValidExpression($operator)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
364
            $msg = 'JSON expression: ' . $operator . ' is invalid.';
365
            throw new JSONTextException($msg);
366
        }
367
368
        $validType = ($isEx ? self::JSONTEXT_QUERY_JSONPATH : self::JSONTEXT_QUERY_OPERATOR);
369
        if ($marshalled = $this->marshallQuery(func_get_args(), $validType)) {
370
            return $this->returnAsType($marshalled);
371
        }
372
373
        return $this->returnAsType([]);
374
    }
375
376
    /**
377
     * Based on the passed operator or expression, ensure the correct backend matcher method is called.
378
     *
379
     * @param array $args
380
     * @param integer $type
381
     * @return array
382
     * @throws \JSONText\Exceptions\JSONTextException
383
     */
384
    private function marshallQuery($args, $type = 1)
385
    {
386
        $backend = $this->config()->backend;
387
        $operator = $expression = $args[0];
388
        $operand = isset($args[1]) ? $args[1] : null;
389
        $operators = $this->config()->allowed_operators[$backend];
390
        $dbBackend = ucfirst($backend) . 'JSONBackend';
391
        $operatorParamIsValid = $type === self::JSONTEXT_QUERY_OPERATOR;
392
        $expressionParamIsValid = $type === self::JSONTEXT_QUERY_JSONPATH;
393
        
394
        if ($operatorParamIsValid) {
395
            foreach ($operators as $routine => $backendOperator) {
396
                $backendDBApiInst = \Injector::inst()->createWithArgs(
397
                    '\JSONText\Backends\\' . $dbBackend, [
398
                    $operand,
399
                    $this
400
                ]);
401
402
                if ($operator === $backendOperator && $result = $backendDBApiInst->$routine()) {
403
                    return $result;
404
                }
405
            }
406
        } else if($expressionParamIsValid) {
407
            $backendDBApiInst = \Injector::inst()->createWithArgs(
408
                '\JSONText\Backends\\' . $dbBackend, [
409
                $expression,
410
                $this
411
            ]);
412
            
413
            if ($result = $backendDBApiInst->matchOnExpr()) {
414
                return $result;
415
            }
416
        }
417
        
418
        return [];
419
    }
420
421
    /**
422
     * Same as standard setValue() method except we can also accept a JSONPath expression. This expression will
423
     * conditionally update the parts of the field's source JSON referenced by $expr with $value
424
     * then re-set the entire JSON string as the field's new value.
425
     *
426
     * @param mixed $value
427
     * @param array $record
428
     * @param string $expr  A valid JSONPath expression.
429
     * @return JSONText
430
     * @throws JSONTextException
431
     */
432
    public function setValue($value, $record = null, $expr = null)
433
    {
434
        // Deal with standard SS behaviour
435
        parent::setValue($value, $record);
436
        
437
        if (!$expr) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $expr of type string|null 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...
438
            $this->value = $value;
439
        } else {
440 View Code Duplication
            if (!$this->isValidExpression($expr)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
441
                $msg = 'Invalid JSONPath expression: ' . $expr . ' passed to ' . __FUNCTION__;
442
                throw new JSONTextException($msg);
443
            }
444
            
445
            if (!$this->jsonStore->set($expr, $value)) {
446
                $msg = 'Failed to properly set custom data to the JSONStore in ' . __FUNCTION__;
447
                throw new JSONTextException($msg);
448
            }
449
450
            $this->value = $this->jsonStore->toString();
451
        }
452
        
453
        return $this;
454
    }
455
456
    /**
457
     * Determine the desired userland format to return all query API method results in.
458
     * 
459
     * @param mixed
460
     * @return mixed
461
     * @throws \JSONText\Exceptions\JSONTextException
462
     */
463
    private function returnAsType($data)
464
    {
465
        $data = (array) $data;
466
        $type = $this->getReturnType();
467
        if ($type === 'array') {
468
            if (!count($data)) {
469
                return [];
470
            }
471
            
472
            return $data;
473
        }
474
475
        if ($type === 'json') {
476
            if (!count($data)) {
477
                return '[]';
478
            }
479
            
480
            return $this->toJson($data);
481
        }
482
483
        if ($type === 'silverstripe') {
484
            if (!count($data)) {
485
                return null;
486
            }
487
            
488
            return $this->toSSTypes($data);
489
        }
490
        
491
        $msg = 'Bad argument passed to ' . __FUNCTION__;
492
        throw new JSONTextException($msg);
493
    }
494
495
    /**
496
     * Is the passed JSON operator valid?
497
     *
498
     * @param string $operator
499
     * @return boolean
500
     */
501
    private function isValidOperator($operator)
502
    {
503
        $backend = $this->config()->backend;
504
505
        return $operator && in_array(
506
            $operator, 
507
            $this->config()->allowed_operators[$backend],
508
            true
509
        );
510
    }
511
512
    /**
513
     * @param string $arg
514
     * @return bool
515
     */
516
    private function isExpression($arg)
517
    {
518
        return (bool) preg_match("#^\\$\.#", $arg);
519
    }
520
521
    /**
522
     * @param string $arg
523
     * @return bool
524
     */
525
    public function isOperator($arg)
526
    {
527
        return !$this->isExpression($arg);
528
    }
529
    
530
    /**
531
     * Is the passed JSON expression valid?
532
     *
533
     * @param string $expr
534
     * @return boolean
535
     */
536
    public function isValidExpression($expr)
537
    {
538
        return (bool) preg_match("#^\\$\.#", $expr);
539
    }
540
    
541
    /**
542
     * Casts a value to a {@link DBField} subclass.
543
     * 
544
     * @param mixed $val
545
     * @return mixed DBField|array
546
     */
547
    private function castToDBField($val)
548
    {
549
        if (is_float($val)) {
550
            return \DBField::create_field('Float', $val);
551
        } else if (is_bool($val)) {
552
            $value = ($val === true ? 1 : 0); // *mutter....*
553
            return \DBField::create_field('Boolean', $value);
554
        } else if (is_int($val)) {
555
            return \DBField::create_field('Int', $val);
556
        } else if (is_string($val)) {
557
            return \DBField::create_field('Varchar', $val);
558
        } else {
559
            // Default to just returning empty val (castToDBField() is used exclusively from within a loop)
560
            return $val;
561
        }
562
    }
563
564
}
565
566
/**
567
 * @package silverstripe-jsontext
568
 * @author Russell Michell 2016 <[email protected]>
569
 */
570
571
namespace JSONText\Exceptions;
572
573
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...
574
{
575
}
576