Completed
Push — master ( 2afc86...6ca8f1 )
by Russell
02:59
created

JSONText::isValidJson()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 9
rs 9.6666
cc 2
eloc 5
nc 2
nop 1
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\Fields\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
 */
27
28
namespace JSONText\Fields;
29
30
use JSONText\Exceptions\JSONTextException;
31
use JSONText\Backends;
32
use Peekmo\JsonPath\JsonStore;
33
34
class JSONText extends \StringField
35
{
36
    /**
37
     * @var int
38
     */
39
    const JSONTEXT_QUERY_OPERATOR = 1;
40
41
    /**
42
     * @var int
43
     */
44
    const JSONTEXT_QUERY_JSONPATH = 2;
45
    
46
    /**
47
     * Which RDBMS backend are we using? The value set here changes the actual operators and operator-routines for the
48
     * given backend.
49
     * 
50
     * @var string
51
     * @config
52
     */
53
    private static $backend = 'postgres';
54
    
55
    /**
56
     * @var array
57
     * @config
58
     * 
59
     * [<backend>] => [
60
     *  [<method> => <operator>]
61
     * ]; // For use in query() method.
62
     */
63
    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...
64
        'postgres' => [
65
            'matchOnInt'    => '->',
66
            'matchOnStr'    => '->>',
67
            'matchOnPath'   => '#>'
68
        ]
69
    ];
70
71
    /**
72
     * Legitimate query return types.
73
     * 
74
     * @var array
75
     */
76
    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...
77
        'json', 'array', 'silverstripe'
78
    ];
79
80
    /**
81
     * @var string
82
     */
83
    protected $returnType = 'json';
84
85
    /**
86
     * @var \Peekmo\JsonPath\JsonStore
87
     */
88
    protected $jsonStore;
89
90
    /**
91
     * Taken from {@link Text}.
92
     * 
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
        // SS <= v3.1
111
        if (!method_exists('DB', 'require_field')) {
112
            \DB::requireField($this->tableName, $this->name, $values);
0 ignored issues
show
Deprecated Code introduced by
The method DB::requireField() has been deprecated with message: since version 4.0 Use DB::require_field instead

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
113
        // SS > v3.1
114
        } else {
115
            \DB::require_field($this->tableName, $this->name, $values);
0 ignored issues
show
Documentation introduced by
$values is of type array<string,string|arra...arrayValue\":\"?\"}>"}>, but the function expects a string.

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...
116
        }
117
    }
118
119
    /**
120
     * @param string $title
121
     * @return \HiddenField
122
     */
123
    public function scaffoldSearchField($title = null)
124
    {
125
        return \HiddenField::create($this->getName());
0 ignored issues
show
Bug Best Practice introduced by
The return type of return \HiddenField::create($this->getName()); (HiddenField) is incompatible with the return type of the parent method DBField::scaffoldSearchField of type TextField.

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...
126
    }
127
128
    /**
129
     * @param string $title
130
     * @return \HiddenField
131
     */
132
    public function scaffoldFormField($title = null)
133
    {
134
        return \HiddenField::create($this->getName());
0 ignored issues
show
Bug Best Practice introduced by
The return type of return \HiddenField::create($this->getName()); (HiddenField) is incompatible with the return type of the parent method DBField::scaffoldFormField of type TextField.

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...
135
    }
136
137
    /**
138
     * Tell all class methods to return data as JSON , an array or an array of SilverStripe DBField subtypes.
139
     * 
140
     * @param string $type
141
     * @return JSONText
142
     * @throws \JSONText\Exceptions\JSONTextException
143
     */
144
    public function setReturnType($type)
145
    {
146
        if (!in_array($type, $this->config()->return_types)) {
147
            $msg = 'Bad type: ' . $type . ' passed to ' . __FUNCTION__ . '()';
148
            throw new JSONTextException($msg);
149
        }
150
        
151
        $this->returnType = $type;
152
        
153
        return $this;
154
    }
155
156
    /**
157
     * Returns the value of this field as an iterable.
158
     * 
159
     * @return \Peekmo\JsonPath\JsonStore
160
     * @throws \JSONText\Exceptions\JSONTextException
161
     */
162
    public function getJSONStore()
163
    {
164
        if (!$value = $this->getValue()) {
165
            return new JsonStore('[]');
166
        }
167
        
168
        if (!$this->isValidJson($value)) {
169
            $msg = 'DB data is munged.';
170
            throw new JSONTextException($msg);
171
        }
172
        
173
        $this->jsonStore = new JsonStore($value);
174
        
175
        return $this->jsonStore;
176
    }
177
178
    /**
179
     * Returns the JSON value of this field as an array.
180
     *
181
     * @return array
182
     */
183
    public function getStoreAsArray()
184
    {
185
        $store = $this->getJSONStore();
186
        if (!is_array($store)) {
187
            return $store->toArray();
188
        }
189
        
190
        return $store;
191
    }
192
193
    /**
194
     * Convert an array to JSON via json_encode().
195
     * 
196
     * @param array $value
197
     * @return string null|string
198
     */
199
    public function toJson(array $value)
200
    {
201
        if (!is_array($value)) {
202
            $value = (array) $value;
203
        }
204
        
205
        return json_encode($value, JSON_UNESCAPED_SLASHES);
206
    }
207
    
208
    /**
209
     * Convert an array's values into an array of SilverStripe DBField subtypes ala:
210
     * 
211
     * - {@link Int}
212
     * - {@link Float}
213
     * - {@link Boolean}
214
     * - {@link Varchar}
215
     * 
216
     * @param array $data
217
     * @return array
218
     */
219
    public function toSSTypes(array $data)
220
    {
221
        $newList = [];
222
        foreach ($data as $key => $val) {
223
            if (is_array($val)) {
224
                $newList[$key] = $this->toSSTypes($val);
225
            } else {
226
                $newList[$key] = $this->castToDBField($val);
227
            }
228
        }
229
        
230
        return $newList;
231
    }
232
233
    /**
234
     * @param mixed $value
235
     * @return array
236
     * @throws \JSONText\Exceptions\JSONTextException
237
     */
238
    public function toArray($value)
239
    {
240
        $decode = json_decode($value, true);
241
        
242
        if (is_null($decode)) {
243
            $msg = 'Decoded JSON is invalid.';
244
            throw new JSONTextException($msg);
245
        }
246
        
247
        return $decode;
248
    }
249
    
250
    /**
251
     * Return an array of the JSON key + value represented as first (top-level) JSON node. 
252
     *
253
     * @return array
254
     */
255
    public function first()
256
    {
257
        $data = $this->getStoreAsArray();
258
        
259
        if (empty($data)) {
260
            return $this->returnAsType([]);
261
        }
262
263
        $key = array_keys($data)[0];
264
        $val = array_values($data)[0];
265
266
        return $this->returnAsType([$key => $val]);
267
    }
268
269
    /**
270
     * Return an array of the JSON key + value represented as last JSON node.
271
     *
272
     * @return array
273
     */
274
    public function last()
275
    {
276
        $data = $this->getStoreAsArray();
277
278
        if (empty($data)) {
279
            return $this->returnAsType([]);
280
        }
281
282
        $count = count($data) -1;
283
        $key = array_keys($data)[$count];
284
        $val = array_values($data)[$count];
285
286
        return $this->returnAsType([$key => $val]);
287
    }
288
289
    /**
290
     * Return an array of the JSON key + value represented as the $n'th JSON node.
291
     *
292
     * @param int $n
293
     * @return mixed array
294
     * @throws \JSONText\Exceptions\JSONTextException
295
     */
296
    public function nth($n)
297
    {
298
        $data = $this->getStoreAsArray();
299
300
        if (empty($data)) {
301
            return $this->returnAsType([]);
302
        }
303
        
304
        if (!is_int($n)) {
305
            $msg = 'Argument passed to ' . __FUNCTION__ . '() must be an integer.';
306
            throw new JSONTextException($msg);
307
        }
308
309
        $i = 0;
310
        foreach ($data as $key => $val) {
311
            if ($i === $n) {
312
                return $this->returnAsType([$key => $val]);
313
            }
314
            $i++;
315
        }
316
        
317
        return $this->returnAsType($data);
318
    }
319
320
    /**
321
     * Return the key(s) + value(s) represented by $operator extracting relevant result from the source JSON's structure.
322
     * N.b when using the path match operator '#>' with duplicate keys, an indexed array of results is returned.
323
     *
324
     * @param string $operator One of the legitimate operators for the current backend or a valid JSONPath expression.
325
     * @param string $operand
326
     * @return mixed null|array
327
     * @throws \JSONText\Exceptions\JSONTextException
328
     */
329
    public function query($operator, $operand = null)
330
    {
331
        $data = $this->getStoreAsArray();
332
333
        if (empty($data)) {
334
            return $this->returnAsType([]);
335
        }
336
337
        $isOperator = !is_null($operand) && $this->isValidOperator($operator);
338
        $isExpresssion = is_null($operand) && $this->isValidExpression($operator);
339
        
340
        if ($isOperator) {
341
            $type = self::JSONTEXT_QUERY_OPERATOR;
342
        } else if ($isExpresssion) {
343
            $type = self::JSONTEXT_QUERY_JSONPATH;
344
        } else {
345
            $msg = 'JSON expression: ' . $operator . ' is invalid.';
346
            throw new JSONTextException($msg);
347
        }
348
        
349
        if ($marshalled = $this->marshallQuery(func_get_args(), $type)) {
350
            return $this->returnAsType($marshalled);
351
        }
352
353
        return $this->returnAsType([]);
354
    }
355
356
    /**
357
     * Based on the passed operator or expression, it marshalls the correct backend matcher method into account.
358
     *
359
     * @param array $args
360
     * @param integer $type
361
     * @return array
362
     * @throws \JSONText\Exceptions\JSONTextException
363
     */
364
    private function marshallQuery($args, $type = 1)
365
    {
366
        $backend = $this->config()->backend;
367
        $operator = $expression = $args[0];
368
        $operand = isset($args[1]) ? $args[1] : null;
369
        $operators = $this->config()->allowed_operators[$backend];
370
        $operatorParamIsValid = $type === self::JSONTEXT_QUERY_OPERATOR;
371
        $expressionParamIsValid = $type === self::JSONTEXT_QUERY_JSONPATH;
372
        
373
        if ($operatorParamIsValid) {
374
            $dbBackendInst = $this->createBackendInst($operand);
375
            foreach ($operators as $routine => $backendOperator) {
376
                if ($operator === $backendOperator && $result = $dbBackendInst->$routine()) {
377
                    return $result;
378
                }
379
            }
380
        } else if($expressionParamIsValid) {
381
            $dbBackendInst = $this->createBackendInst($expression);
382
            if ($result = $dbBackendInst->matchOnExpr()) {
383
                return $result;
384
            }
385
        }
386
        
387
        return [];
388
    }
389
390
    /**
391
     * Same as standard setValue() method except we can also accept a JSONPath expression. This expression will
392
     * conditionally update the parts of the field's source JSON referenced by $expr with $value
393
     * then re-set the entire JSON string as the field's new value.
394
     * 
395
     * Note: The $expr parameter can only accept JSONPath expressions. Using Postgres operators will not work and will
396
     * throw an instance of JSONTextException.
397
     *
398
     * @param mixed $value
399
     * @param array $record
400
     * @param string $expr  A valid JSONPath expression.
401
     * @return JSONText
402
     * @throws JSONTextException
403
     */
404
    public function setValue($value, $record = null, $expr = '')
405
    {
406
        if (empty($expr)) {
407
            if (!$this->isValidDBValue($value)) {
408
                $msg = 'Invalid data passed to ' . __FUNCTION__ . '()';
409
                throw new JSONTextException($msg);
410
            }
411
            
412
            $this->value = $value;
413
        } else {
414
            if (!$this->isValidExpression($expr)) {
415
                $msg = 'Invalid JSONPath expression: ' . $expr . ' passed to ' . __FUNCTION__ . '()';
416
                throw new JSONTextException($msg);
417
            }
418
            
419
            if (!$this->getJSONStore()->set($expr, $value)) {
420
                $msg = 'Failed to properly set custom data to the JSONStore in ' . __FUNCTION__ . '()';
421
                throw new JSONTextException($msg);
422
            }
423
424
            $this->value = $this->jsonStore->toString();
425
        }
426
        
427
        parent::setValue($this->value, $record);
428
        
429
        return $this;
430
    }
431
432
    /**
433
     * Determine the desired userland format to return all query API method results in.
434
     * 
435
     * @param mixed
436
     * @return mixed array|null
437
     * @throws \JSONText\Exceptions\JSONTextException
438
     */
439
    private function returnAsType($data)
440
    {
441
        $data = (array) $data;
442
        $type = $this->returnType;
443
        if ($type === 'array') {
444
            if (!count($data)) {
445
                return [];
446
            }
447
            
448
            return $data;
449
        }
450
451
        if ($type === 'json') {
452
            if (!count($data)) {
453
                return '[]';
454
            }
455
            
456
            return $this->toJson($data);
457
        }
458
459
        if ($type === 'silverstripe') {
460
            if (!count($data)) {
461
                return null;
462
            }
463
            
464
            return $this->toSSTypes($data);
465
        }
466
        
467
        $msg = 'Bad argument passed to ' . __FUNCTION__ . '()';
468
        throw new JSONTextException($msg);
469
    }
470
    
471
    /**
472
     * Create an instance of {@link JSONBackend} according to the value of JSONText::backend defined in SS config.
473
     * 
474
     * @param string operand
475
     * @return JSONBackend
476
     * @throws JSONTextException
477
     */
478
    protected function createBackendInst($operand)
479
    {
480
        $backend = $this->config()->backend;
481
        $dbBackendClass = '\JSONText\Backends\\' . ucfirst($backend) . 'JSONBackend';
482
        
483
        if (!class_exists($dbBackendClass)) {
484
            $msg = 'JSONText backend class ' . $dbBackendClass . ' not found.';
485
            throw new JSONTextException($msg);
486
        }
487
        
488
        return \Injector::inst()->createWithArgs(
489
            $dbBackendClass, [
490
            $operand,
491
            $this
492
        ]);
493
    }
494
495
    /**
496
     * Utility method to determine whether a value is really valid JSON or not.
497
     * The Peekmo JSONStore lib won't accept otherwise valid JSON values like `true`, `false` & `""` so these need
498
     * to be disallowed.
499
     *
500
     * @param string $value
501
     * @return boolean
502
     */
503
    public function isValidJson($value)
504
    {
505
        if (!isset($value)) {
506
            return false;
507
        }
508
509
        $value = trim($value);
510
        return !is_null(json_decode($value, true));
511
    }
512
513
    /**
514
     * @return boolean
515
     */
516
    public function isValidDBValue($value) {
517
        $value = trim($value);
518
        
519
        if (in_array($value, ['true', 'false'])) {
520
            return false;
521
        }
522
        
523
        if (is_string($value) && strlen($value) === 0) {
524
            return true;
525
        }
526
        
527
        return $this->isValidJson($value);
528
    }
529
530
    /**
531
     * Is the passed JSON operator valid?
532
     *
533
     * @param string $operator
534
     * @return boolean
535
     */
536
    public function isValidOperator($operator)
537
    {
538
        $backend = $this->config()->backend;
539
540
        return $operator && in_array(
541
            $operator, 
542
            $this->config()->allowed_operators[$backend],
543
            true
544
        );
545
    }
546
547
    /**
548
     * Is the passed JSPONPath expression valid?
549
     * 
550
     * @param string $expression
551
     * @return bool
552
     */
553
    public function isValidExpression($expression)
554
    {
555
        return (bool) preg_match("#^\\$\.#", $expression);
556
    }
557
    
558
    /**
559
     * Casts a value to a {@link DBField} subclass.
560
     * 
561
     * @param mixed $val
562
     * @return mixed DBField|array
563
     */
564
    private function castToDBField($val)
565
    {
566
        if (is_float($val)) {
567
            return \DBField::create_field('Float', $val);
568
        } else if (is_bool($val)) {
569
            $value = ($val === true ? 1 : 0); // *mutter....*
570
            return \DBField::create_field('Boolean', $value);
571
        } else if (is_int($val)) {
572
            return \DBField::create_field('Int', $val);
573
        } else if (is_string($val)) {
574
            return \DBField::create_field('Varchar', $val);
575
        } else {
576
            // Default to just returning empty val (castToDBField() is used exclusively from within a loop)
577
            return $val;
578
        }
579
    }
580
581
}
582