1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
namespace Violet\StreamingJsonEncoder; |
4
|
|
|
|
5
|
|
|
/** |
6
|
|
|
* Abstract encoder for encoding JSON iteratively. |
7
|
|
|
* |
8
|
|
|
* @author Riikka Kalliomäki <[email protected]> |
9
|
|
|
* @copyright Copyright (c) 2016, Riikka Kalliomäki |
10
|
|
|
* @license http://opensource.org/licenses/mit-license.php MIT License |
11
|
|
|
*/ |
12
|
|
|
abstract class AbstractJsonEncoder implements \Iterator |
13
|
|
|
{ |
14
|
|
|
/** @var \Iterator[] Current value stack in encoding */ |
15
|
|
|
private $stack; |
16
|
|
|
|
17
|
|
|
/** @var bool[] True for every object in the stack, false for an array */ |
18
|
|
|
private $stackType; |
19
|
|
|
|
20
|
|
|
/** @var array Stack of values being encoded */ |
21
|
|
|
private $valueStack; |
22
|
|
|
|
23
|
|
|
/** @var bool Whether the next value is the first value in an array or an object */ |
24
|
|
|
private $first; |
25
|
|
|
|
26
|
|
|
/** @var int The JSON encoding options */ |
27
|
|
|
private $options; |
28
|
|
|
|
29
|
|
|
/** @var bool Whether next token should be preceded by new line or not */ |
30
|
|
|
private $newLine; |
31
|
|
|
|
32
|
|
|
/** @var string Indent to use for indenting JSON output */ |
33
|
|
|
private $indent; |
34
|
|
|
|
35
|
|
|
/** @var string[] Errors that occurred in encoding */ |
36
|
|
|
private $errors; |
37
|
|
|
|
38
|
|
|
/** @var int Number of the current line in output */ |
39
|
|
|
private $line; |
40
|
|
|
|
41
|
|
|
/** @var int Number of the current column in output */ |
42
|
|
|
private $column; |
43
|
|
|
|
44
|
|
|
/** @var mixed The initial value to encode as JSON */ |
45
|
|
|
private $initialValue; |
46
|
|
|
|
47
|
|
|
/** @var int|null The current step of the encoder */ |
48
|
|
|
private $step; |
49
|
|
|
|
50
|
|
|
/** |
51
|
|
|
* AbstractJsonEncoder constructor. |
52
|
|
|
* @param mixed $value The value to encode as JSON |
53
|
|
|
*/ |
54
|
117 |
|
public function __construct($value) |
55
|
|
|
{ |
56
|
117 |
|
$this->initialValue = $value; |
57
|
117 |
|
$this->options = 0; |
58
|
117 |
|
$this->errors = []; |
59
|
117 |
|
$this->indent = ' '; |
60
|
39 |
|
} |
61
|
|
|
|
62
|
|
|
/** |
63
|
|
|
* Sets the JSON encoding options. |
64
|
|
|
* @param int $options The JSON encoding options that are used by json_encode |
65
|
|
|
* @return $this Returns self for call chaining |
66
|
|
|
* @throws \RuntimeException If changing encoding options during encoding operation |
67
|
|
|
*/ |
68
|
75 |
|
public function setOptions($options) |
69
|
|
|
{ |
70
|
75 |
|
if ($this->step !== null) { |
71
|
3 |
|
throw new \RuntimeException('Cannot change encoding options during encoding'); |
72
|
|
|
} |
73
|
|
|
|
74
|
72 |
|
$this->options = (int) $options; |
75
|
72 |
|
return $this; |
76
|
|
|
} |
77
|
|
|
|
78
|
|
|
/** |
79
|
|
|
* Sets the indent for the JSON output. |
80
|
|
|
* @param string|int $indent A string to use as indent or the number of spaces |
81
|
|
|
* @return $this Returns self for call chaining |
82
|
|
|
* @throws \RuntimeException If changing indent during encoding operation |
83
|
|
|
*/ |
84
|
39 |
|
public function setIndent($indent) |
85
|
|
|
{ |
86
|
39 |
|
if ($this->step !== null) { |
87
|
3 |
|
throw new \RuntimeException('Cannot change indent during encoding'); |
88
|
|
|
} |
89
|
|
|
|
90
|
36 |
|
$this->indent = is_int($indent) ? str_repeat(' ', $indent) : (string) $indent; |
91
|
36 |
|
return $this; |
92
|
|
|
} |
93
|
|
|
|
94
|
|
|
/** |
95
|
|
|
* Returns the list of errors that occurred during the last encoding process. |
96
|
|
|
* @return string[] List of errors that occurred during encoding |
97
|
|
|
*/ |
98
|
6 |
|
public function getErrors() |
99
|
|
|
{ |
100
|
6 |
|
return $this->errors; |
101
|
|
|
} |
102
|
|
|
|
103
|
|
|
/** |
104
|
|
|
* Returns the current encoding value stack. |
105
|
|
|
* @return array The current encoding value stack |
106
|
|
|
*/ |
107
|
3 |
|
protected function getValueStack() |
108
|
|
|
{ |
109
|
3 |
|
return $this->valueStack; |
110
|
|
|
} |
111
|
|
|
|
112
|
|
|
/** |
113
|
|
|
* Initializes the iterator if it has not been initialized yet. |
114
|
|
|
*/ |
115
|
90 |
|
private function initialize() |
116
|
|
|
{ |
117
|
90 |
|
if (!isset($this->stack)) { |
118
|
3 |
|
$this->rewind(); |
119
|
1 |
|
} |
120
|
30 |
|
} |
121
|
|
|
|
122
|
|
|
/** |
123
|
|
|
* Returns the current number of step in the encoder. |
124
|
|
|
* @return int|null The current step number as integer or null if the current state is not valid |
125
|
|
|
*/ |
126
|
6 |
|
public function key() |
127
|
|
|
{ |
128
|
6 |
|
$this->initialize(); |
129
|
|
|
|
130
|
6 |
|
return $this->step; |
131
|
|
|
} |
132
|
|
|
|
133
|
|
|
/** |
134
|
|
|
* Tells if the encoder has a valid current state. |
135
|
|
|
* @return bool True if the iterator has a valid state, false if not |
136
|
|
|
*/ |
137
|
87 |
|
public function valid() |
138
|
|
|
{ |
139
|
87 |
|
$this->initialize(); |
140
|
|
|
|
141
|
87 |
|
return $this->step !== null; |
142
|
|
|
} |
143
|
|
|
|
144
|
|
|
/** |
145
|
|
|
* Returns the current value or state from the encoder. |
146
|
|
|
* @return mixed The current value or state from the encoder |
147
|
|
|
*/ |
148
|
|
|
abstract public function current(); |
149
|
|
|
|
150
|
|
|
/** |
151
|
|
|
* Returns the JSON encoding to the beginning. |
152
|
|
|
*/ |
153
|
117 |
|
public function rewind() |
154
|
|
|
{ |
155
|
117 |
|
if ($this->step === 0) { |
156
|
3 |
|
return; |
157
|
|
|
} |
158
|
|
|
|
159
|
117 |
|
$this->stack = []; |
160
|
117 |
|
$this->stackType = []; |
161
|
117 |
|
$this->valueStack = []; |
162
|
117 |
|
$this->errors = []; |
163
|
117 |
|
$this->newLine = false; |
164
|
117 |
|
$this->first = true; |
165
|
117 |
|
$this->line = 1; |
166
|
117 |
|
$this->column = 1; |
167
|
117 |
|
$this->step = 0; |
168
|
|
|
|
169
|
117 |
|
$this->processValue($this->initialValue); |
170
|
38 |
|
} |
171
|
|
|
|
172
|
|
|
/** |
173
|
|
|
* Iterates the next token or tokens to the output stream. |
174
|
|
|
*/ |
175
|
87 |
|
public function next() |
176
|
|
|
{ |
177
|
87 |
|
$this->initialize(); |
178
|
|
|
|
179
|
87 |
|
if (!empty($this->stack)) { |
180
|
66 |
|
$this->step++; |
181
|
66 |
|
$iterator = end($this->stack); |
182
|
|
|
|
183
|
66 |
|
if ($iterator->valid()) { |
184
|
57 |
|
$this->processStack($iterator, end($this->stackType)); |
|
|
|
|
185
|
57 |
|
$iterator->next(); |
186
|
19 |
|
} else { |
187
|
64 |
|
$this->popStack(); |
188
|
|
|
} |
189
|
22 |
|
} else { |
190
|
84 |
|
$this->step = null; |
191
|
|
|
} |
192
|
58 |
|
} |
193
|
|
|
|
194
|
|
|
/** |
195
|
|
|
* Handles the next value from the iterator to be encoded as JSON. |
196
|
|
|
* @param \Iterator $iterator The iterator used to generate the next value |
197
|
|
|
* @param bool $isObject True if the iterator is being handled as an object, false if not |
198
|
|
|
*/ |
199
|
57 |
|
private function processStack(\Iterator $iterator, $isObject) |
200
|
|
|
{ |
201
|
57 |
|
if ($isObject) { |
202
|
33 |
|
if (!$this->processKey($iterator->key())) { |
203
|
13 |
|
return; |
204
|
|
|
} |
205
|
41 |
|
} elseif (!$this->first) { |
206
|
18 |
|
$this->outputLine(',', JsonToken::T_COMMA); |
207
|
6 |
|
} |
208
|
|
|
|
209
|
57 |
|
$this->first = false; |
210
|
57 |
|
$this->processValue($iterator->current()); |
211
|
19 |
|
} |
212
|
|
|
|
213
|
|
|
/** |
214
|
|
|
* Handles the given value key into JSON. |
215
|
|
|
* @param mixed $key The key to process |
216
|
|
|
* @return bool True if the key is valid, false if not |
217
|
|
|
*/ |
218
|
33 |
|
private function processKey($key) |
219
|
|
|
{ |
220
|
33 |
|
if (!is_int($key) && !is_string($key)) { |
221
|
3 |
|
$this->addError('Only string or integer keys are supported'); |
222
|
3 |
|
return false; |
223
|
|
|
} |
224
|
|
|
|
225
|
33 |
|
if (!$this->first) { |
226
|
18 |
|
$this->outputLine(',', JsonToken::T_COMMA); |
227
|
6 |
|
} |
228
|
|
|
|
229
|
33 |
|
$this->outputJson((string) $key, JsonToken::T_NAME); |
230
|
33 |
|
$this->output(':', JsonToken::T_COLON); |
231
|
|
|
|
232
|
33 |
|
if ($this->options & JSON_PRETTY_PRINT) { |
233
|
6 |
|
$this->output(' ', JsonToken::T_WHITESPACE); |
234
|
2 |
|
} |
235
|
|
|
|
236
|
33 |
|
return true; |
237
|
|
|
} |
238
|
|
|
|
239
|
|
|
/** |
240
|
|
|
* Handles the given JSON value appropriately depending on it's type. |
241
|
|
|
* @param mixed $value The value that should be encoded as JSON |
242
|
|
|
*/ |
243
|
117 |
|
private function processValue($value) |
244
|
|
|
{ |
245
|
117 |
|
$this->valueStack[] = $value; |
246
|
117 |
|
$value = $this->resolveValue($value); |
247
|
|
|
|
248
|
117 |
|
if (is_array($value) || is_object($value)) { |
249
|
66 |
|
$this->pushStack($value); |
250
|
22 |
|
} else { |
251
|
108 |
|
$this->outputJson($value, JsonToken::T_VALUE); |
252
|
105 |
|
array_pop($this->valueStack); |
253
|
|
|
} |
254
|
76 |
|
} |
255
|
|
|
|
256
|
|
|
/** |
257
|
|
|
* Resolves the actual value of any given value that is about to be processed. |
258
|
|
|
* @param mixed $value The value to resolve |
259
|
|
|
* @return mixed The resolved value |
260
|
|
|
*/ |
261
|
117 |
|
protected function resolveValue($value) |
262
|
|
|
{ |
263
|
|
|
do { |
264
|
117 |
|
if ($value instanceof \JsonSerializable) { |
265
|
3 |
|
$value = $value->jsonSerialize(); |
266
|
117 |
|
} elseif ($value instanceof \Closure) { |
267
|
6 |
|
$value = $value(); |
268
|
2 |
|
} else { |
269
|
78 |
|
break; |
270
|
|
|
} |
271
|
6 |
|
} while (true); |
272
|
|
|
|
273
|
117 |
|
return $value; |
274
|
|
|
} |
275
|
|
|
|
276
|
|
|
/** |
277
|
|
|
* Adds an JSON encoding error to the list of errors. |
278
|
|
|
* @param string $message The error message to add |
279
|
|
|
* @throws EncodingException If the encoding should not continue due to the error |
280
|
|
|
*/ |
281
|
9 |
|
private function addError($message) |
282
|
|
|
{ |
283
|
9 |
|
$errorMessage = sprintf('Line %d, column %d: %s', $this->line, $this->column, $message); |
284
|
9 |
|
$this->errors[] = $errorMessage; |
285
|
|
|
|
286
|
9 |
|
if ($this->options & JSON_PARTIAL_OUTPUT_ON_ERROR) { |
287
|
3 |
|
return; |
288
|
|
|
} |
289
|
|
|
|
290
|
6 |
|
$this->stack = []; |
291
|
6 |
|
$this->step = null; |
292
|
|
|
|
293
|
6 |
|
throw new EncodingException($errorMessage); |
294
|
|
|
} |
295
|
|
|
|
296
|
|
|
/** |
297
|
|
|
* Pushes the given iterable to the value stack. |
298
|
|
|
* @param object|array $iterable The iterable value to push to the stack |
299
|
|
|
*/ |
300
|
66 |
|
private function pushStack($iterable) |
301
|
|
|
{ |
302
|
66 |
|
$iterator = $this->getIterator($iterable); |
303
|
66 |
|
$isObject = $this->isObject($iterable, $iterator); |
304
|
|
|
|
305
|
66 |
|
if ($isObject) { |
306
|
36 |
|
$this->outputLine('{', JsonToken::T_LEFT_BRACE); |
307
|
12 |
|
} else { |
308
|
42 |
|
$this->outputLine('[', JsonToken::T_LEFT_BRACKET); |
309
|
|
|
} |
310
|
|
|
|
311
|
66 |
|
$this->first = true; |
312
|
66 |
|
$this->stack[] = $iterator; |
313
|
66 |
|
$this->stackType[] = $isObject; |
314
|
22 |
|
} |
315
|
|
|
|
316
|
|
|
/** |
317
|
|
|
* Creates a generator from the given iterable using a foreach loop. |
318
|
|
|
* @param object|array $iterable The iterable value to iterate |
319
|
|
|
* @return \Generator The generator using the given iterable |
320
|
|
|
*/ |
321
|
66 |
|
private function getIterator($iterable) |
322
|
|
|
{ |
323
|
66 |
|
foreach ($iterable as $key => $value) { |
324
|
60 |
|
yield $key => $value; |
325
|
22 |
|
} |
326
|
21 |
|
} |
327
|
|
|
|
328
|
|
|
/** |
329
|
|
|
* Tells if the given iterable should be handled as a JSON object or not. |
330
|
|
|
* @param object|array $iterable The iterable value to test |
331
|
|
|
* @param \Iterator $iterator An Iterator created from the iterable value |
332
|
|
|
* @return bool True if the given iterable should be treated as object, false if not |
333
|
|
|
*/ |
334
|
66 |
|
private function isObject($iterable, \Iterator $iterator) |
335
|
|
|
{ |
336
|
66 |
|
if ($this->options & JSON_FORCE_OBJECT) { |
337
|
3 |
|
return true; |
338
|
|
|
} |
339
|
|
|
|
340
|
63 |
|
if ($iterable instanceof \Traversable) { |
341
|
18 |
|
return $iterator->valid() && $iterator->key() !== 0; |
342
|
|
|
} |
343
|
|
|
|
344
|
54 |
|
return is_object($iterable) || $this->isAssociative($iterable); |
345
|
|
|
} |
346
|
|
|
|
347
|
|
|
/** |
348
|
|
|
* Tells if the given array is an associative array. |
349
|
|
|
* @param array $array The array to test |
350
|
|
|
* @return bool True if the array is associative, false if not |
351
|
|
|
*/ |
352
|
54 |
|
private function isAssociative(array $array) |
353
|
|
|
{ |
354
|
54 |
|
if ($array === []) { |
355
|
9 |
|
return false; |
356
|
|
|
} |
357
|
|
|
|
358
|
48 |
|
$expected = 0; |
359
|
|
|
|
360
|
48 |
|
foreach ($array as $key => $_) { |
361
|
48 |
|
if ($key !== $expected++) { |
362
|
36 |
|
return true; |
363
|
|
|
} |
364
|
9 |
|
} |
365
|
|
|
|
366
|
27 |
|
return false; |
367
|
|
|
} |
368
|
|
|
|
369
|
|
|
/** |
370
|
|
|
* Removes the top element of the value stack. |
371
|
|
|
*/ |
372
|
63 |
|
private function popStack() |
373
|
|
|
{ |
374
|
63 |
|
if (!$this->first) { |
375
|
54 |
|
$this->newLine = true; |
376
|
18 |
|
} |
377
|
|
|
|
378
|
63 |
|
$this->first = false; |
379
|
63 |
|
array_pop($this->stack); |
380
|
|
|
|
381
|
63 |
|
if (array_pop($this->stackType)) { |
382
|
36 |
|
$this->output('}', JsonToken::T_RIGHT_BRACE); |
383
|
12 |
|
} else { |
384
|
39 |
|
$this->output(']', JsonToken::T_RIGHT_BRACKET); |
385
|
|
|
} |
386
|
|
|
|
387
|
63 |
|
array_pop($this->valueStack); |
388
|
21 |
|
} |
389
|
|
|
|
390
|
|
|
/** |
391
|
|
|
* Encodes the given value as JSON and passes it to output stream. |
392
|
|
|
* @param mixed $value The value to output as JSON |
393
|
|
|
* @param int $token The token type of the value |
394
|
|
|
*/ |
395
|
108 |
|
private function outputJson($value, $token) |
396
|
|
|
{ |
397
|
108 |
|
$encoded = json_encode($value, $this->options); |
398
|
108 |
|
$error = json_last_error(); |
399
|
|
|
|
400
|
108 |
|
if ($error !== JSON_ERROR_NONE) { |
401
|
9 |
|
$this->addError(sprintf('%s (%s)', json_last_error_msg(), $this->getJsonErrorName($error))); |
402
|
1 |
|
} |
403
|
|
|
|
404
|
105 |
|
$this->output($encoded, $token); |
405
|
35 |
|
} |
406
|
|
|
|
407
|
|
|
/** |
408
|
|
|
* Returns the name of the JSON error constant. |
409
|
|
|
* @param int $error The error code to find |
410
|
|
|
* @return string The name for the error code |
|
|
|
|
411
|
|
|
*/ |
412
|
9 |
|
private function getJsonErrorName($error) |
413
|
|
|
{ |
414
|
9 |
|
$matches = array_keys(get_defined_constants(), $error, true); |
415
|
9 |
|
$prefix = 'JSON_ERROR_'; |
416
|
9 |
|
$prefixLength = strlen($prefix); |
417
|
|
|
|
418
|
9 |
|
foreach ($matches as $match) { |
419
|
9 |
|
if (strncmp($match, $prefix, $prefixLength) === 0) { |
420
|
9 |
|
return $match; |
421
|
|
|
} |
422
|
3 |
|
} |
423
|
|
|
} |
424
|
|
|
|
425
|
|
|
/** |
426
|
|
|
* Passes the given token to the output stream and ensures the next token is preceded by a newline. |
427
|
|
|
* @param string $string The token to write to the output stream |
428
|
|
|
* @param int $token The type of the token |
429
|
|
|
*/ |
430
|
66 |
|
private function outputLine($string, $token) |
431
|
|
|
{ |
432
|
66 |
|
$this->output($string, $token); |
433
|
66 |
|
$this->newLine = true; |
434
|
22 |
|
} |
435
|
|
|
|
436
|
|
|
/** |
437
|
|
|
* Passes the given token to the output stream. |
438
|
|
|
* @param string $string The token to write to the output stream |
439
|
|
|
* @param int $token The type of the token |
440
|
|
|
*/ |
441
|
114 |
|
private function output($string, $token) |
442
|
|
|
{ |
443
|
114 |
|
if ($this->newLine && $this->options & JSON_PRETTY_PRINT) { |
444
|
15 |
|
$indent = str_repeat($this->indent, count($this->stack)); |
445
|
15 |
|
$this->write("\n", JsonToken::T_WHITESPACE); |
446
|
|
|
|
447
|
15 |
|
if ($indent !== '') { |
448
|
15 |
|
$this->write($indent, JsonToken::T_WHITESPACE); |
449
|
5 |
|
} |
450
|
|
|
|
451
|
15 |
|
$this->line++; |
452
|
15 |
|
$this->column = strlen($indent) + 1; |
453
|
5 |
|
} |
454
|
|
|
|
455
|
114 |
|
$this->newLine = false; |
456
|
114 |
|
$this->write($string, $token); |
457
|
114 |
|
$this->column += strlen($string); |
458
|
38 |
|
} |
459
|
|
|
|
460
|
|
|
/** |
461
|
|
|
* Actually handles the writing of the given token to the output stream. |
462
|
|
|
* @param string $string The given token to write |
463
|
|
|
* @param int $token The type of the token |
464
|
|
|
* @return void |
465
|
|
|
*/ |
466
|
|
|
abstract protected function write($string, $token); |
467
|
|
|
} |
468
|
|
|
|
This check looks for type mismatches where the missing type is
false
. This is usually indicative of an error condtion.Consider the follow example
This function either returns a new
DateTime
object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returnedfalse
before passing on the value to another function or method that may not be able to handle afalse
.