Completed
Push — master ( 471861...67ffab )
by Russell
02:40
created

JSONText::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 4
rs 10
cc 1
eloc 2
nc 1
nop 3
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
 */
28
29
namespace JSONText\Fields;
30
31
use JSONText\Exceptions\JSONTextException;
32
33
class JSONText extends \StringField
34
{
35
    /**
36
     * Which RDBMS backend are we using? The value set here changes the actual operators and operator-routines for the
37
     * given backend.
38
     * 
39
     * @var string
40
     * @config
41
     */
42
    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...
43
    
44
    /**
45
     * @var array
46
     * @config
47
     * 
48
     * [<backend>] => [
49
     *  [<method> => <operator>]
50
     * ]; // For use in query() method.
51
     */
52
    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...
53
        'postgres' => [
54
            'matchIfKeyIsInt'   => '->',
55
            'matchIfKeyIsStr'   => '->>',
56
            'matchOnPath'       => '#>'
57
        ]
58
    ];
59
60
    /**
61
     * @var string
62
     */
63
    protected $returnType = 'json';
64
65
    /**
66
     * Object cache for performance improvements.
67
     * 
68
     * @var \RecursiveIteratorIterator
69
     */
70
    protected $data;
71
72
    /**
73
     * @var array
74
     */
75
    protected $cache = [];
76
77
    /**
78
     * @param string $val
79
     * @return void
80
     */
81
    public function updateCache($val) {
82
        $this->cache[] = $val;
83
    }
84
85
    /**
86
     * Taken from {@link TextField}.
87
     * 
88
     * @see DBField::requireField()
89
     * @return void
90
     */
91
    public function requireField()
92
    {
93
        $parts = [
94
            'datatype'      => 'mediumtext',
95
            'character set' => 'utf8',
96
            'collate'       => 'utf8_general_ci',
97
            'arrayValue'    => $this->arrayValue
98
        ];
99
100
        $values = [
101
            'type'  => 'text',
102
            'parts' => $parts
103
        ];
104
105
        DB::require_field($this->tableName, $this->name, $values);
106
    }
107
108
    /**
109
     * @param string $title
110
     * @return HiddenField
111
     */
112
    public function scaffoldSearchField($title = null)
113
    {
114
        return HiddenField::create($this->getName());
115
    }
116
117
    /**
118
     * @param string $title
119
     * @return HiddenField
120
     */
121
    public function scaffoldFormField($title = null)
122
    {
123
        return HiddenField::create($this->getName());
124
    }
125
126
    /**
127
     * Tell all class methods to return data as JSON or an array.
128
     * 
129
     * @param string $type
130
     * @return \JSONText
131
     * @throws \JSONText\Exceptions\JSONTextException
132
     */
133
    public function setReturnType($type)
134
    {
135
        if (!in_array($type, ['json', 'array'])) {
136
            $msg = 'Bad type: ' . $type . ' passed to ' . __FUNCTION__;
137
            throw new JSONTextException($msg);
138
        }
139
        
140
        $this->returnType = $type;
141
    }
142
143
    /**
144
     * @return string
145
     */
146
    public function getReturnType()
147
    {
148
        return $this->returnType;
149
    }
150
151
    /**
152
     * Returns the value of this field as an iterable.
153
     * 
154
     * @return mixed
155
     * @throws \JSONText\Exceptions\JSONTextException
156
     */
157
    public function getValueAsIterable()
158
    {
159
        if (!$json = $this->getValue()) {
160
            return [];
161
        }
162
        
163
        if (!$this->isJson($json)) {
164
            $msg = 'DB data is munged.';
165
            throw new JSONTextException($msg);
166
        }
167
168
        if (!$this->data) {
169
            $this->data = new \RecursiveIteratorIterator(
170
                new \RecursiveArrayIterator(json_decode($json, true)),
171
                \RecursiveIteratorIterator::SELF_FIRST
172
            );
173
        }
174
        
175
        return $this->data;
176
    }
177
178
    /**
179
     * Returns the value of this field as a flattened array
180
     *
181
     * @return array
182
     */
183
    public function getValueAsArray()
184
    {
185
        return iterator_to_array($this->getValueAsIterable());
186
    }
187
188
    /**
189
     * Utility method to determine whether the data is really JSON or not.
190
     * 
191
     * @param string $value
192
     * @return boolean
193
     */
194
    public function isJson($value)
195
    {
196
        return !is_null(json_decode($value, true));
197
    }
198
199
    /**
200
     * @param array $value
201
     * @return mixed null|string
202
     */
203
    public function toJson($value)
204
    {
205
        if (!is_array($value)) {
206
            $value = (array) $value;
207
        }
208
        
209
        $opts = (
210
            JSON_UNESCAPED_SLASHES
211
        );
212
        
213
        return json_encode($value, $opts);
214
    }
215
216
    /**
217
     * @param mixed $value
218
     * @return array
219
     * @throws \JSONText\Exceptions\JSONTextException
220
     */
221
    public function toArray($value)
222
    {
223
        $decode = json_decode($value, true);
224
        
225
        if (is_null($decode)) {
226
            $msg = 'Decoded JSON is invalid.';
227
            throw new JSONTextException($msg);
228
        }
229
        
230
        return $decode;
231
    }
232
    
233
    /**
234
     * Return an array of the JSON key + value represented as first (top-level) JSON node. 
235
     *
236
     * @return array
237
     */
238
    public function first()
239
    {
240
        $data = $this->getValueAsIterable();
241
        
242
        if (!$data) {
243
            return $this->returnAsType([]);
244
        }
245
246
        $flattened = iterator_to_array($data, true);
247
        return $this->returnAsType([
248
                array_keys($flattened)[0] => array_values($flattened)[0]
249
            ]);
250
    }
251
252
    /**
253
     * Return an array of the JSON key + value represented as last JSON node.
254
     *
255
     * @return array
256
     */
257
    public function last()
258
    {
259
        $data = $this->getValueAsIterable();
260
261
        if (!$data) {
262
            return $this->returnAsType([]);
263
        }
264
265
        $flattened = iterator_to_array($data, true);
266
        return $this->returnAsType([
267
                array_keys($flattened)[count($flattened) -1] => array_values($flattened)[count($flattened) -1]
268
            ]);
269
    }
270
271
    /**
272
     * Return an array of the JSON key + value represented as the $n'th JSON node.
273
     *
274
     * @param int $n
275
     * @return mixed array
276
     * @throws \JSONText\Exceptions\JSONTextException
277
     */
278
    public function nth($n)
279
    {
280
        $data = $this->getValueAsIterable();
281
282
        if (!$data) {
283
            return $this->returnAsType([]);
284
        }
285
        
286
        if (!is_int($n)) {
287
            $msg = 'Argument passed to ' . __FUNCTION__ . ' must be an integer.';
288
            throw new JSONTextException($msg);
289
        }
290
291
        $i = 0;
292
        foreach ($data as $key => $val) {
293
            if ($i === $n) {
294
                return $this->returnAsType([$key => $val]);
295
            }
296
            $i++;
297
        }
298
        
299
        return $this->returnAsType($data);
0 ignored issues
show
Bug introduced by
It seems like $data defined by $this->getValueAsIterable() on line 280 can also be of type object<RecursiveIteratorIterator>; however, JSONText\Fields\JSONText::returnAsType() does only seem to accept array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
300
    }
301
302
    /**
303
     * Return the key(s) + value(s) represented by $operator extracting relevant result from the source JSON's structure.
304
     * N.b when using the path match operator '#>' with duplicate keys, an indexed array of results is returned.
305
     *
306
     * @param string $operator
307
     * @param string $operand
308
     * @return mixed null|array
309
     * @throws \JSONText\Exceptions\JSONTextException
310
     */
311
    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...
312
    {
313
        $data = $this->getValueAsIterable();
314
        
315
        if (!$data) {
316
            return $this->returnAsType([]);
317
        }
318
        
319
        if (!$this->isValidOperator($operator)) {
320
            $msg = 'JSON operator: ' . $operator . ' is invalid.';
321
            throw new JSONTextException($msg);
322
        }
323
        
324
        $i = 0;
325
        foreach ($data as $key => $val) {
326
            if ($marshalled = $this->marshallQuery($key, $val, $i, func_get_args())) {
327
                return $this->returnAsType($marshalled);
328
            }
329
            
330
            $i++;
331
        }
332
333
        return $this->returnAsType([]);
334
    }
335
336
    /**
337
     * Alias of self::query().
338
     * 
339
     * @param string $operator
340
     * @return mixed string|array
341
     * @throws \JSONText\Exceptions\JSONTextException
342
     */
343
    public function extract($operator)
344
    {
345
        return $this->query($operator);
346
    }
347
348
    /**
349
     * Based on the passed operator, ensure the correct backend matcher method is called.
350
     * 
351
     * @param mixed $key
352
     * @param mixed $val
353
     * @param int $idx
354
     * @param array $args
355
     * @return array
356
     * @throws \JSONText\Exceptions\JSONTextException
357
     */
358
    private function marshallQuery($key, $val, $idx, $args)
359
    {
360
        $backend = $this->config()->backend;
361
        $operator = $args[0];
362
        $operand = $args[1];
363
        $operators = $this->config()->allowed_operators[$backend];
364
        
365
        if (!in_array($operator, $operators)) {
366
            $msg = 'Invalid ' . $backend . ' operator: ' . $operator . ', used for JSON query.';
367
            throw new JSONTextException($msg);
368
        }
369
        
370
        foreach ($operators as $routine => $backendOperator) {
371
            $backendDBApiInst = \Injector::inst()->createWithArgs(
372
                '\JSONText\Backends\JSONBackend', [
373
                    $key, 
374
                    $val, 
375
                    $idx,
376
                    $operand,
377
                    $this
378
                ]);
379
            
380
            if ($operator === $backendOperator && $result = $backendDBApiInst->$routine()) {
381
               return $result;
382
            }
383
        }
384
        
385
        return [];
386
    }
387
388
    /**
389
     * Determine the desired userland format to return all query API method results in.
390
     * 
391
     * @param array $data
392
     * @return mixed
393
     */
394
    private function returnAsType(array $data)
395
    {
396
        if (($this->getReturnType() === 'array')) {
397
            return $data;
398
        }
399
400
        if (($this->getReturnType() === 'json')) {
401
            return $this->toJson($data);
402
        }
403
    }
404
405
    /**
406
     * Is the passed JSON operator valid?
407
     *
408
     * @param string $operator
409
     * @return boolean
410
     */
411
    private function isValidOperator($operator)
412
    {
413
        $backend = $this->config()->backend;
414
415
        return $operator && in_array($operator, $this->config()->allowed_operators[$backend], true);
416
    }
417
418
}
419
420
/**
421
 * @package silverstripe-jsontext
422
 * @author Russell Michell 2016 <[email protected]>
423
 */
424
425
namespace JSONText\Exceptions;
426
427
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...
428
{
429
}
430