Completed
Push — master ( 9d18b4...301d75 )
by Russell
03:01
created

JSONText::getStoreAsArray()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
c 1
b 0
f 1
dl 0
loc 9
rs 9.6666
cc 2
eloc 5
nc 2
nop 0
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 Make the current default of "strict mode" into ss config and default to strict.
27
 * @todo Rename query() to getValue() that accepts optional param $expr (for JSONPath queries)
28
 * @todo Add tests for "minimal" yet valid JSON types e.g. `true`
29
 * @todo Incorporate Currency class in castToDBField()
30
 */
31
32
namespace JSONText\Fields;
33
34
use JSONText\Exceptions\JSONTextException;
35
use JSONText\Backends;
36
use Peekmo\JsonPath\JsonStore;
37
38
class JSONText extends \StringField
39
{
40
    /**
41
     * Which RDBMS backend are we using? The value set here changes the actual operators and operator-routines for the
42
     * given backend.
43
     * 
44
     * @var string
45
     * @config
46
     */
47
    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...
48
    
49
    /**
50
     * @var array
51
     * @config
52
     * 
53
     * [<backend>] => [
54
     *  [<method> => <operator>]
55
     * ]; // For use in query() method.
56
     */
57
    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...
58
        'postgres' => [
59
            'matchOnInt'    => '->',
60
            'matchOnStr'    => '->>',
61
            'matchOnPath'   => '#>'
62
        ]
63
    ];
64
65
    /**
66
     * Legitimate query return types
67
     * 
68
     * @var array
69
     */
70
    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...
71
        'json', 'array', 'silverstripe'
72
    ];
73
74
    /**
75
     * @var string
76
     */
77
    protected $returnType = 'json';
78
79
    /**
80
     * @var \Peekmo\JsonPath\JsonStore
81
     */
82
    protected $jsonStore;
83
84
    /**
85
     * Taken from {@link TextField}.
86
     * 
87
     * @see DBField::requireField()
88
     * @return void
89
     */
90
    public function requireField()
91
    {
92
        $parts = [
93
            'datatype'      => 'mediumtext',
94
            'character set' => 'utf8',
95
            'collate'       => 'utf8_general_ci',
96
            'arrayValue'    => $this->arrayValue
97
        ];
98
99
        $values = [
100
            'type'  => 'text',
101
            'parts' => $parts
102
        ];
103
104
        DB::require_field($this->tableName, $this->name, $values);
105
    }
106
107
    /**
108
     * @param string $title
109
     * @return HiddenField
110
     */
111
    public function scaffoldSearchField($title = null)
112
    {
113
        return HiddenField::create($this->getName());
114
    }
115
116
    /**
117
     * @param string $title
118
     * @return HiddenField
119
     */
120
    public function scaffoldFormField($title = null)
121
    {
122
        return HiddenField::create($this->getName());
123
    }
124
125
    /**
126
     * Tell all class methods to return data as JSON , an array or an array of SilverStripe DBField subtypes.
127
     * 
128
     * @param string $type
129
     * @return \JSONText
130
     * @throws \JSONText\Exceptions\JSONTextException
131
     */
132
    public function setReturnType($type)
133
    {
134
        if (!in_array($type, $this->config()->return_types)) {
135
            $msg = 'Bad type: ' . $type . ' passed to ' . __FUNCTION__;
136
            throw new JSONTextException($msg);
137
        }
138
        
139
        $this->returnType = $type;
140
        
141
        return $this;
142
    }
143
144
    /**
145
     * @return string
146
     */
147
    public function getReturnType()
148
    {
149
        return $this->returnType;
150
    }
151
152
    /**
153
     * Returns the value of this field as an iterable.
154
     * 
155
     * @return \Peekmo\JsonPath\JsonStore
156
     * @throws \JSONText\Exceptions\JSONTextException
157
     */
158
    public function getJSONStore()
159
    {
160
        if (!$json = $this->getValue()) {
161
            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...
162
        }
163
        
164
        if (!$this->isJson($json)) {
165
            $msg = 'DB data is munged.';
166
            throw new JSONTextException($msg);
167
        }
168
169
        $this->jsonStore = new \Peekmo\JsonPath\JsonStore($json);
170
        
171
        return $this->jsonStore;
172
    }
173
174
    /**
175
     * Returns the JSON value of this field as an array.
176
     *
177
     * @return array
178
     */
179
    public function getStoreAsArray()
180
    {
181
        $store = $this->getJSONStore();
182
        if (!is_array($store)) {
183
            return $store->toArray();
184
        }
185
        
186
        return $store;
187
    }
188
189
    /**
190
     * Utility method to determine whether the data is really JSON or not.
191
     * 
192
     * @param string $value
193
     * @return boolean
194
     */
195
    public function isJson($value)
196
    {
197
        return !is_null(json_decode($value, true));
198
    }
199
200
    /**
201
     * Convert an array to JSON via json_encode().
202
     * 
203
     * @param array $value
204
     * @return mixed null|string
205
     */
206
    public function toJson(array $value)
207
    {
208
        if (!is_array($value)) {
209
            $value = (array) $value;
210
        }
211
        
212
        $opts = (
213
            JSON_UNESCAPED_SLASHES
214
        );
215
        
216
        return json_encode($value, $opts);
217
    }
218
    
219
    /**
220
     * Convert an array's values into an array of SilverStripe DBField subtypes ala:
221
     * 
222
     * - {@link Int}
223
     * - {@link Float}
224
     * - {@link Boolean}
225
     * - {@link Varchar}
226
     * 
227
     * @param array $data
228
     * @return array
229
     */
230
    public function toSSTypes(array $data)
231
    {
232
        $newList = [];
233
        foreach ($data as $key => $val) {
234
            if (is_array($val)) {
235
                $newList[$key] = $this->toSSTypes($val);
236
            } else {
237
                $newList[$key] = $this->castToDBField($val);
238
            }
239
        }
240
        
241
        return $newList;
242
    }
243
244
    /**
245
     * @param mixed $value
246
     * @return array
247
     * @throws \JSONText\Exceptions\JSONTextException
248
     */
249
    public function toArray($value)
250
    {
251
        $decode = json_decode($value, true);
252
        
253
        if (is_null($decode)) {
254
            $msg = 'Decoded JSON is invalid.';
255
            throw new JSONTextException($msg);
256
        }
257
        
258
        return $decode;
259
    }
260
    
261
    /**
262
     * Return an array of the JSON key + value represented as first (top-level) JSON node. 
263
     *
264
     * @return array
265
     */
266
    public function first()
267
    {
268
        $data = $this->getStoreAsArray();
269
        
270
        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...
271
            return $this->returnAsType([]);
272
        }
273
274
        $key = array_keys($data)[0];
275
        $val = array_values($data)[0];
276
277
        return $this->returnAsType([$key => $val]);
278
    }
279
280
    /**
281
     * Return an array of the JSON key + value represented as last JSON node.
282
     *
283
     * @return array
284
     */
285
    public function last()
286
    {
287
        $data = $this->getStoreAsArray();
288
289
        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...
290
            return $this->returnAsType([]);
291
        }
292
293
        $count = count($data) -1;
294
        $key = array_keys($data)[$count];
295
        $val = array_values($data)[$count];
296
297
        return $this->returnAsType([$key => $val]);
298
    }
299
300
    /**
301
     * Return an array of the JSON key + value represented as the $n'th JSON node.
302
     *
303
     * @param int $n
304
     * @return mixed array
305
     * @throws \JSONText\Exceptions\JSONTextException
306
     */
307
    public function nth($n)
308
    {
309
        $data = $this->getStoreAsArray();
310
311
        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...
312
            return $this->returnAsType([]);
313
        }
314
        
315
        if (!is_int($n)) {
316
            $msg = 'Argument passed to ' . __FUNCTION__ . ' must be an integer.';
317
            throw new JSONTextException($msg);
318
        }
319
320
        $i = 0;
321
        foreach ($data as $key => $val) {
322
            if ($i === $n) {
323
                return $this->returnAsType([$key => $val]);
324
            }
325
            $i++;
326
        }
327
        
328
        return $this->returnAsType($data);
329
    }
330
331
    /**
332
     * Return the key(s) + value(s) represented by $operator extracting relevant result from the source JSON's structure.
333
     * N.b when using the path match operator '#>' with duplicate keys, an indexed array of results is returned.
334
     *
335
     * @param string $operator
336
     * @param string $operand
337
     * @return mixed null|array
338
     * @throws \JSONText\Exceptions\JSONTextException
339
     */
340
    public function query($operator, $operand)
0 ignored issues
show
Unused Code introduced by
The parameter $operand is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
341
    {
342
        $data = $this->getStoreAsArray();
343
        
344
        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...
345
            return $this->returnAsType([]);
346
        }
347
        
348
        if (!$this->isValidOperator($operator)) {
349
            $msg = 'JSON operator: ' . $operator . ' is invalid.';
350
            throw new JSONTextException($msg);
351
        }
352
353
        if ($marshalled = $this->marshallQuery(func_get_args())) {
354
            return $this->returnAsType($marshalled);
355
        }
356
357
        return $this->returnAsType([]);
358
    }
359
360
    /**
361
     * Based on the passed operator, ensure the correct backend matcher method is called.
362
     *
363
     * @param array $args
364
     * @return array
365
     * @throws \JSONText\Exceptions\JSONTextException
366
     */
367
    private function marshallQuery($args)
368
    {
369
        $backend = $this->config()->backend;
370
        $operator = $args[0];
371
        $operand = $args[1];
372
        $operators = $this->config()->allowed_operators[$backend];
373
        $dbBackend = ucfirst($backend) . 'JSONBackend';
374
        
375
        if (!in_array($operator, $operators)) {
376
            $msg = 'Invalid ' . $backend . ' operator: ' . $operator . ', used for JSON query.';
377
            throw new JSONTextException($msg);
378
        }
379
        
380
        foreach ($operators as $routine => $backendOperator) {
381
            $backendDBApiInst = \Injector::inst()->createWithArgs(
382
                '\JSONText\Backends\\' . $dbBackend, [
383
                    $operand,
384
                    $this
385
                ]);
386
            
387
            if ($operator === $backendOperator && $result = $backendDBApiInst->$routine()) {
388
               return $result;
389
            }
390
        }
391
        
392
        return [];
393
    }
394
395
    /**
396
     * Determine the desired userland format to return all query API method results in.
397
     * 
398
     * @param mixed
399
     * @return mixed
400
     * @throws \JSONText\Exceptions\JSONTextException
401
     */
402
    private function returnAsType($data)
403
    {
404
        $data = (array) $data;
405
        $type = $this->getReturnType();
406
        if ($type === 'array') {
407
            if (!count($data)) {
408
                return [];
409
            }
410
            
411
            return $data;
412
        }
413
414
        if ($type === 'json') {
415
            if (!count($data)) {
416
                return '[]';
417
            }
418
            
419
            return $this->toJson($data);
420
        }
421
422
        if ($type === 'silverstripe') {
423
            if (!count($data)) {
424
                return null;
425
            }
426
            
427
            return $this->toSSTypes($data);
428
        }
429
        
430
        $msg = 'Bad argument passed to ' . __FUNCTION__;
431
        throw new JSONTextException($msg);
432
    }
433
434
    /**
435
     * Is the passed JSON operator valid?
436
     *
437
     * @param string $operator
438
     * @return boolean
439
     */
440
    private function isValidOperator($operator)
441
    {
442
        $backend = $this->config()->backend;
443
444
        return $operator && in_array($operator, $this->config()->allowed_operators[$backend], true);
445
    }
446
    
447
    /**
448
     * Casts a value to a {@link DBField} subclass.
449
     * 
450
     * @param mixed $val
451
     * @return mixed DBField|array
452
     */
453
    private function castToDBField($val)
454
    {
455
        if (is_float($val)) {
456
            return \DBField::create_field('Float', $val);
457
        } else if (is_bool($val)) {
458
            $value = ($val === true ? 1 : 0); // *mutter....*
459
            return \DBField::create_field('Boolean', $value);
460
        } else if (is_int($val)) {
461
            return \DBField::create_field('Int', $val);
462
        } else if (is_string($val)) {
463
            return \DBField::create_field('Varchar', $val);
464
        } else {
465
            // Default to just returnign empty val (castToDBField() is used exclusively from within a loop)
466
            return $val;
467
        }
468
    }
469
470
}
471
472
/**
473
 * @package silverstripe-jsontext
474
 * @author Russell Michell 2016 <[email protected]>
475
 */
476
477
namespace JSONText\Exceptions;
478
479
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...
480
{
481
}
482