Completed
Push — master ( c1f2b6...2afc86 )
by Russell
02:31
created

JSONText::query()   C

Complexity

Conditions 7
Paths 21

Size

Total Lines 26
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

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