AbstractJsonEncoder::rewind()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 18
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 12
c 1
b 0
f 0
nc 2
nop 0
dl 0
loc 18
rs 9.8666
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-2020 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
    public function __construct($value)
55
    {
56
        $this->initialValue = $value;
57
        $this->options = 0;
58
        $this->errors = [];
59
        $this->indent = '    ';
60
    }
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
    public function setOptions($options)
69
    {
70
        if ($this->step !== null) {
71
            throw new \RuntimeException('Cannot change encoding options during encoding');
72
        }
73
74
        $this->options = (int) $options;
75
        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
    public function setIndent($indent)
85
    {
86
        if ($this->step !== null) {
87
            throw new \RuntimeException('Cannot change indent during encoding');
88
        }
89
90
        $this->indent = is_int($indent) ? str_repeat(' ', $indent) : (string) $indent;
91
        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
    public function getErrors()
99
    {
100
        return $this->errors;
101
    }
102
103
    /**
104
     * Returns the current encoding value stack.
105
     * @return array The current encoding value stack
106
     */
107
    protected function getValueStack()
108
    {
109
        return $this->valueStack;
110
    }
111
112
    /**
113
     * Initializes the iterator if it has not been initialized yet.
114
     */
115
    private function initialize()
116
    {
117
        if (!isset($this->stack)) {
118
            $this->rewind();
119
        }
120
    }
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
    #[\ReturnTypeWillChange]
127
    public function key()
128
    {
129
        $this->initialize();
130
131
        return $this->step;
132
    }
133
134
    /**
135
     * Tells if the encoder has a valid current state.
136
     * @return bool True if the iterator has a valid state, false if not
137
     */
138
    #[\ReturnTypeWillChange]
139
    public function valid()
140
    {
141
        $this->initialize();
142
143
        return $this->step !== null;
144
    }
145
146
    /**
147
     * Returns the current value or state from the encoder.
148
     * @return mixed The current value or state from the encoder
149
     */
150
    #[\ReturnTypeWillChange]
151
    abstract public function current();
152
153
    /**
154
     * Returns the JSON encoding to the beginning.
155
     */
156
    #[\ReturnTypeWillChange]
157
    public function rewind()
158
    {
159
        if ($this->step === 0) {
160
            return;
161
        }
162
163
        $this->stack = [];
164
        $this->stackType = [];
165
        $this->valueStack = [];
166
        $this->errors = [];
167
        $this->newLine = false;
168
        $this->first = true;
169
        $this->line = 1;
170
        $this->column = 1;
171
        $this->step = 0;
172
173
        $this->processValue($this->initialValue);
174
    }
175
176
    /**
177
     * Iterates the next token or tokens to the output stream.
178
     */
179
    #[\ReturnTypeWillChange]
180
    public function next()
181
    {
182
        $this->initialize();
183
184
        if (!empty($this->stack)) {
185
            $this->step++;
186
            $iterator = end($this->stack);
187
188
            if ($iterator->valid()) {
189
                $this->processStack($iterator, end($this->stackType));
190
                $iterator->next();
191
            } else {
192
                $this->popStack();
193
            }
194
        } else {
195
            $this->step = null;
196
        }
197
    }
198
199
    /**
200
     * Handles the next value from the iterator to be encoded as JSON.
201
     * @param \Iterator $iterator The iterator used to generate the next value
202
     * @param bool $isObject True if the iterator is being handled as an object, false if not
203
     */
204
    private function processStack(\Iterator $iterator, $isObject)
205
    {
206
        if ($isObject) {
207
            if (!$this->processKey($iterator->key())) {
208
                return;
209
            }
210
        } elseif (!$this->first) {
211
            $this->outputLine(',', JsonToken::T_COMMA);
212
        }
213
214
        $this->first = false;
215
        $this->processValue($iterator->current());
216
    }
217
218
    /**
219
     * Handles the given value key into JSON.
220
     * @param mixed $key The key to process
221
     * @return bool True if the key is valid, false if not
222
     */
223
    private function processKey($key)
224
    {
225
        if (!is_int($key) && !is_string($key)) {
226
            $this->addError('Only string or integer keys are supported');
227
            return false;
228
        }
229
230
        if (!$this->first) {
231
            $this->outputLine(',', JsonToken::T_COMMA);
232
        }
233
234
        $this->outputJson((string) $key, JsonToken::T_NAME);
235
        $this->output(':', JsonToken::T_COLON);
236
237
        if ($this->options & JSON_PRETTY_PRINT) {
238
            $this->output(' ', JsonToken::T_WHITESPACE);
239
        }
240
241
        return true;
242
    }
243
244
    /**
245
     * Handles the given JSON value appropriately depending on it's type.
246
     * @param mixed $value The value that should be encoded as JSON
247
     */
248
    private function processValue($value)
249
    {
250
        $this->valueStack[] = $value;
251
        $value = $this->resolveValue($value);
252
253
        if (is_array($value) || is_object($value)) {
254
            $this->pushStack($value);
255
        } else {
256
            $this->outputJson($value, JsonToken::T_VALUE);
257
            array_pop($this->valueStack);
258
        }
259
    }
260
261
    /**
262
     * Resolves the actual value of any given value that is about to be processed.
263
     * @param mixed $value The value to resolve
264
     * @return mixed The resolved value
265
     */
266
    protected function resolveValue($value)
267
    {
268
        do {
269
            if ($value instanceof \JsonSerializable) {
270
                $value = $value->jsonSerialize();
271
            } elseif ($value instanceof \Closure) {
272
                $value = $value();
273
            } else {
274
                break;
275
            }
276
        } while (true);
277
278
        return $value;
279
    }
280
281
    /**
282
     * Adds an JSON encoding error to the list of errors.
283
     * @param string $message The error message to add
284
     * @throws EncodingException If the encoding should not continue due to the error
285
     */
286
    private function addError($message)
287
    {
288
        $errorMessage = sprintf('Line %d, column %d: %s', $this->line, $this->column, $message);
289
        $this->errors[] = $errorMessage;
290
291
        if ($this->options & JSON_PARTIAL_OUTPUT_ON_ERROR) {
292
            return;
293
        }
294
295
        $this->stack = [];
296
        $this->step = null;
297
298
        throw new EncodingException($errorMessage);
299
    }
300
301
    /**
302
     * Pushes the given iterable to the value stack.
303
     * @param object|array $iterable The iterable value to push to the stack
304
     */
305
    private function pushStack($iterable)
306
    {
307
        $iterator = $this->getIterator($iterable);
308
        $isObject = $this->isObject($iterable, $iterator);
309
310
        if ($isObject) {
311
            $this->outputLine('{', JsonToken::T_LEFT_BRACE);
312
        } else {
313
            $this->outputLine('[', JsonToken::T_LEFT_BRACKET);
314
        }
315
316
        $this->first = true;
317
        $this->stack[] = $iterator;
318
        $this->stackType[] = $isObject;
319
    }
320
321
    /**
322
     * Creates a generator from the given iterable using a foreach loop.
323
     * @param object|array $iterable The iterable value to iterate
324
     * @return \Generator The generator using the given iterable
325
     */
326
    private function getIterator($iterable)
327
    {
328
        foreach ($iterable as $key => $value) {
329
            yield $key => $value;
330
        }
331
    }
332
333
    /**
334
     * Tells if the given iterable should be handled as a JSON object or not.
335
     * @param object|array $iterable The iterable value to test
336
     * @param \Iterator $iterator An Iterator created from the iterable value
337
     * @return bool True if the given iterable should be treated as object, false if not
338
     */
339
    private function isObject($iterable, \Iterator $iterator)
340
    {
341
        if ($this->options & JSON_FORCE_OBJECT) {
342
            return true;
343
        }
344
345
        if ($iterable instanceof \Traversable) {
346
            return $iterator->valid() && $iterator->key() !== 0;
347
        }
348
349
        return is_object($iterable) || $this->isAssociative($iterable);
350
    }
351
352
    /**
353
     * Tells if the given array is an associative array.
354
     * @param array $array The array to test
355
     * @return bool True if the array is associative, false if not
356
     */
357
    private function isAssociative(array $array)
358
    {
359
        if ($array === []) {
360
            return false;
361
        }
362
363
        $expected = 0;
364
365
        foreach ($array as $key => $_) {
366
            if ($key !== $expected++) {
367
                return true;
368
            }
369
        }
370
371
        return false;
372
    }
373
374
    /**
375
     * Removes the top element of the value stack.
376
     */
377
    private function popStack()
378
    {
379
        if (!$this->first) {
380
            $this->newLine = true;
381
        }
382
383
        $this->first = false;
384
        array_pop($this->stack);
385
386
        if (array_pop($this->stackType)) {
387
            $this->output('}', JsonToken::T_RIGHT_BRACE);
388
        } else {
389
            $this->output(']', JsonToken::T_RIGHT_BRACKET);
390
        }
391
392
        array_pop($this->valueStack);
393
    }
394
395
    /**
396
     * Encodes the given value as JSON and passes it to output stream.
397
     * @param mixed $value The value to output as JSON
398
     * @param int $token The token type of the value
399
     */
400
    private function outputJson($value, $token)
401
    {
402
        $encoded = json_encode($value, $this->options);
403
        $error = json_last_error();
404
405
        if ($error !== JSON_ERROR_NONE) {
406
            $this->addError(sprintf('%s (%s)', json_last_error_msg(), $this->getJsonErrorName($error)));
407
        }
408
409
        $this->output($encoded, $token);
410
    }
411
412
    /**
413
     * Returns the name of the JSON error constant.
414
     * @param int $error The error code to find
415
     * @return string The name for the error code
416
     */
417
    private function getJsonErrorName($error)
418
    {
419
        $matches = array_keys(get_defined_constants(), $error, true);
420
        $prefix = 'JSON_ERROR_';
421
        $prefixLength = strlen($prefix);
422
        $name = 'UNKNOWN_ERROR';
423
424
        foreach ($matches as $match) {
425
            if (is_string($match) && strncmp($match, $prefix, $prefixLength) === 0) {
426
                $name = $match;
427
                break;
428
            }
429
        }
430
431
        return $name;
432
    }
433
434
    /**
435
     * Passes the given token to the output stream and ensures the next token is preceded by a newline.
436
     * @param string $string The token to write to the output stream
437
     * @param int $token The type of the token
438
     */
439
    private function outputLine($string, $token)
440
    {
441
        $this->output($string, $token);
442
        $this->newLine = true;
443
    }
444
445
    /**
446
     * Passes the given token to the output stream.
447
     * @param string $string The token to write to the output stream
448
     * @param int $token The type of the token
449
     */
450
    private function output($string, $token)
451
    {
452
        if ($this->newLine && $this->options & JSON_PRETTY_PRINT) {
453
            $indent = str_repeat($this->indent, count($this->stack));
454
            $this->write("\n", JsonToken::T_WHITESPACE);
455
456
            if ($indent !== '') {
457
                $this->write($indent, JsonToken::T_WHITESPACE);
458
            }
459
460
            $this->line++;
461
            $this->column = strlen($indent) + 1;
462
        }
463
464
        $this->newLine = false;
465
        $this->write($string, $token);
466
        $this->column += strlen($string);
467
    }
468
469
    /**
470
     * Actually handles the writing of the given token to the output stream.
471
     * @param string $string The given token to write
472
     * @param int $token The type of the token
473
     * @return void
474
     */
475
    abstract protected function write($string, $token);
476
}
477