Completed
Push — master ( 7b46e5...af643a )
by Russell
02:45
created

JSONText::createBackendInst()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
c 1
b 0
f 1
dl 0
loc 11
rs 9.4285
cc 1
eloc 7
nc 1
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'
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, it marshalls the correct backend matcher method into account.
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
        $operatorParamIsValid = $type === self::JSONTEXT_QUERY_OPERATOR;
391
        $expressionParamIsValid = $type === self::JSONTEXT_QUERY_JSONPATH;
392
        
393
        if ($operatorParamIsValid) {
394
            $dbBackendInst = $this->createBackendInst($operand);
395
            foreach ($operators as $routine => $backendOperator) {
396
                if ($operator === $backendOperator && $result = $dbBackendInst->$routine()) {
397
                    return $result;
398
                }
399
            }
400
        } else if($expressionParamIsValid) {
401
            $dbBackendInst = $this->createBackendInst($expression);
402
            if ($result = $dbBackendInst->matchOnExpr()) {
403
                return $result;
404
            }
405
        }
406
        
407
        return [];
408
    }
409
410
    /**
411
     * Same as standard setValue() method except we can also accept a JSONPath expression. This expression will
412
     * conditionally update the parts of the field's source JSON referenced by $expr with $value
413
     * then re-set the entire JSON string as the field's new value.
414
     * 
415
     * Note: The $expr parameter can only accept JSONPath expressions. Using Postgres operators will not work and will
416
     * throw an instance of JSONTextException.
417
     *
418
     * @param mixed $value
419
     * @param array $record
420
     * @param string $expr  A valid JSONPath expression.
421
     * @return JSONText
422
     * @throws JSONTextException
423
     */
424
    public function setValue($value, $record = null, $expr = null)
425
    {
426
        // Deal with standard SS behaviour
427
        parent::setValue($value, $record);
428
        
429
        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...
430
            $this->value = $value;
431
        } else {
432 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...
433
                $msg = 'Invalid JSONPath expression: ' . $expr . ' passed to ' . __FUNCTION__;
434
                throw new JSONTextException($msg);
435
            }
436
            
437
            if (!$this->jsonStore->set($expr, $value)) {
438
                $msg = 'Failed to properly set custom data to the JSONStore in ' . __FUNCTION__;
439
                throw new JSONTextException($msg);
440
            }
441
442
            $this->value = $this->jsonStore->toString();
443
        }
444
        
445
        return $this;
446
    }
447
448
    /**
449
     * Determine the desired userland format to return all query API method results in.
450
     * 
451
     * @param mixed
452
     * @return mixed
453
     * @throws \JSONText\Exceptions\JSONTextException
454
     */
455
    private function returnAsType($data)
456
    {
457
        $data = (array) $data;
458
        $type = $this->getReturnType();
459
        if ($type === 'array') {
460
            if (!count($data)) {
461
                return [];
462
            }
463
            
464
            return $data;
465
        }
466
467
        if ($type === 'json') {
468
            if (!count($data)) {
469
                return '[]';
470
            }
471
            
472
            return $this->toJson($data);
473
        }
474
475
        if ($type === 'silverstripe') {
476
            if (!count($data)) {
477
                return null;
478
            }
479
            
480
            return $this->toSSTypes($data);
481
        }
482
        
483
        $msg = 'Bad argument passed to ' . __FUNCTION__;
484
        throw new JSONTextException($msg);
485
    }
486
    
487
    /**
488
     * Create an instance of {@link JSONBackend} according to the value of JSONText::backend defined by SS config.
489
     * 
490
     * @param string operand
491
     * @return JSONBackend
492
     */
493
    protected function createBackendInst($operand)
494
    {
495
        $backend = $this->config()->backend;
496
        $dbBackendClass = ucfirst($backend) . 'JSONBackend';
497
        
498
        return \Injector::inst()->createWithArgs(
499
            '\JSONText\Backends\\' . $dbBackendClass, [
500
            $operand,
501
            $this
502
        ]);
503
    }
504
505
    /**
506
     * Is the passed JSON operator valid?
507
     *
508
     * @param string $operator
509
     * @return boolean
510
     */
511
    private function isValidOperator($operator)
512
    {
513
        $backend = $this->config()->backend;
514
515
        return $operator && in_array(
516
            $operator, 
517
            $this->config()->allowed_operators[$backend],
518
            true
519
        );
520
    }
521
522
    /**
523
     * @param string $arg
524
     * @return bool
525
     */
526
    private function isExpression($arg)
527
    {
528
        return (bool) preg_match("#^\\$\.#", $arg);
529
    }
530
531
    /**
532
     * @param string $arg
533
     * @return bool
534
     */
535
    public function isOperator($arg)
536
    {
537
        return !$this->isExpression($arg);
538
    }
539
    
540
    /**
541
     * Is the passed JSON expression valid?
542
     *
543
     * @param string $expr
544
     * @return boolean
545
     */
546
    public function isValidExpression($expr)
547
    {
548
        return (bool) preg_match("#^\\$\.#", $expr);
549
    }
550
    
551
    /**
552
     * Casts a value to a {@link DBField} subclass.
553
     * 
554
     * @param mixed $val
555
     * @return mixed DBField|array
556
     */
557
    private function castToDBField($val)
558
    {
559
        if (is_float($val)) {
560
            return \DBField::create_field('Float', $val);
561
        } else if (is_bool($val)) {
562
            $value = ($val === true ? 1 : 0); // *mutter....*
563
            return \DBField::create_field('Boolean', $value);
564
        } else if (is_int($val)) {
565
            return \DBField::create_field('Int', $val);
566
        } else if (is_string($val)) {
567
            return \DBField::create_field('Varchar', $val);
568
        } else {
569
            // Default to just returning empty val (castToDBField() is used exclusively from within a loop)
570
            return $val;
571
        }
572
    }
573
574
}
575