Completed
Push — master ( 2143ab...58159e )
by Riikka
02:19
created

AbstractJsonEncoder::outputLine()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 5
ccs 4
cts 4
cp 1
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 3
nc 1
nop 2
crap 1
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 mixed The current step number as integer or null if the current state is not valid
0 ignored issues
show
Documentation introduced by
Consider making the return type a bit more specific; maybe use integer|null.

This check looks for the generic type array as a return type and suggests a more specific type. This type is inferred from the actual code.

Loading history...
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));
0 ignored issues
show
Security Bug introduced by
It seems like $iterator defined by end($this->stack) on line 181 can also be of type false; however, Violet\StreamingJsonEnco...Encoder::processStack() does only seem to accept object<Iterator>, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

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 returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
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 117
                return $value;
270
            }
271 6
        } while (true);
272
    }
273
274
    /**
275
     * Adds an JSON encoding error to the list of errors.
276
     * @param string $message The error message to add
277
     * @throws EncodingException If the encoding should not continue due to the error
278
     */
279 9
    private function addError($message)
280
    {
281 9
        $errorMessage = sprintf('Line %d, column %d: %s', $this->line, $this->column, $message);
282 9
        $this->errors[] = $errorMessage;
283
284 9
        if ($this->options & JSON_PARTIAL_OUTPUT_ON_ERROR) {
285 3
            return;
286
        }
287
288 6
        $this->stack = [];
289 6
        $this->step = null;
290
291 6
        throw new EncodingException($errorMessage);
292
    }
293
294
    /**
295
     * Pushes the given iterable to the value stack.
296
     * @param object|array $iterable The iterable value to push to the stack
297
     */
298 66
    private function pushStack($iterable)
299
    {
300 66
        $iterator = $this->getIterator($iterable);
301 66
        $isObject = $this->isObject($iterable, $iterator);
302
303 66
        if ($isObject) {
304 36
            $this->outputLine('{', JsonToken::T_LEFT_BRACE);
305 12
        } else {
306 42
            $this->outputLine('[', JsonToken::T_LEFT_BRACKET);
307
        }
308
309 66
        $this->first = true;
310 66
        $this->stack[] = $iterator;
311 66
        $this->stackType[] = $isObject;
312 22
    }
313
314
    /**
315
     * Creates a generator from the given iterable using a foreach loop.
316
     * @param object|array $iterable The iterable value to iterate
317
     * @return \Generator The generator using the given iterable
318
     */
319 66
    private function getIterator($iterable)
320
    {
321 66
        foreach ($iterable as $key => $value) {
322 60
            yield $key => $value;
323 22
        }
324 21
    }
325
326
    /**
327
     * Tells if the given iterable should be handled as a JSON object or not.
328
     * @param object|array $iterable The iterable value to test
329
     * @param \Iterator $iterator An Iterator created from the iterable value
330
     * @return bool True if the given iterable should be treated as object, false if not
331
     */
332 66
    private function isObject($iterable, \Iterator $iterator)
333
    {
334 66
        if ($this->options & JSON_FORCE_OBJECT) {
335 3
            return true;
336
        }
337
338 63
        if ($iterable instanceof \Traversable) {
339 18
            return $iterator->valid() && $iterator->key() !== 0;
340
        }
341
342 54
        return is_object($iterable) || $this->isAssociative($iterable);
343
    }
344
345
    /**
346
     * Tells if the given array is an associative array.
347
     * @param array $array The array to test
348
     * @return bool True if the array is associative, false if not
349
     */
350 54
    private function isAssociative(array $array)
351
    {
352 54
        if ($array === []) {
353 9
            return false;
354
        }
355
356 48
        $expected = 0;
357
358 48
        foreach ($array as $key => $_) {
359 48
            if ($key !== $expected++) {
360 36
                return true;
361
            }
362 9
        }
363
364 27
        return false;
365
    }
366
367
    /**
368
     * Removes the top element of the value stack.
369
     */
370 63
    private function popStack()
371
    {
372 63
        if (!$this->first) {
373 54
            $this->newLine = true;
374 18
        }
375
376 63
        $this->first = false;
377 63
        array_pop($this->stack);
378
379 63
        if (array_pop($this->stackType)) {
380 36
            $this->output('}', JsonToken::T_RIGHT_BRACE);
381 12
        } else {
382 39
            $this->output(']', JsonToken::T_RIGHT_BRACKET);
383
        }
384
385 63
        array_pop($this->valueStack);
386 21
    }
387
388
    /**
389
     * Encodes the given value as JSON and passes it to output stream.
390
     * @param mixed $value The value to output as JSON
391
     * @param int $token The token type of the value
392
     */
393 108
    private function outputJson($value, $token)
394
    {
395 108
        $encoded = json_encode($value, $this->options);
396 108
        $error = json_last_error();
397
398 108
        if ($error !== JSON_ERROR_NONE) {
399 9
            $this->addError(sprintf('%s (%s)', json_last_error_msg(), $this->getJsonErrorName($error)));
400 1
        }
401
402 105
        $this->output($encoded, $token);
403 35
    }
404
405
    /**
406
     * Returns the name of the JSON error constant.
407
     * @param int $error The error code to find
408
     * @return string The name for the error code
0 ignored issues
show
Documentation introduced by
Should the return type not be integer|string|null?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
409
     */
410 9
    private function getJsonErrorName($error)
411
    {
412 9
        $matches = array_keys(get_defined_constants(), $error, true);
413 9
        $prefix = 'JSON_ERROR_';
414 9
        $prefixLength = strlen($prefix);
415
416 9
        foreach ($matches as $match) {
417 9
            if (strncmp($match, $prefix, $prefixLength) === 0) {
418 9
                return $match;
419
            }
420 3
        }
421
    }
422
423
    /**
424
     * Passes the given token to the output stream and ensures the next token is preceded by a newline.
425
     * @param string $string The token to write to the output stream
426
     * @param int $token The type of the token
427
     */
428 66
    private function outputLine($string, $token)
429
    {
430 66
        $this->output($string, $token);
431 66
        $this->newLine = true;
432 22
    }
433
434
    /**
435
     * Passes the given token to the output stream.
436
     * @param string $string The token to write to the output stream
437
     * @param int $token The type of the token
438
     */
439 114
    private function output($string, $token)
440
    {
441 114
        if ($this->newLine && $this->options & JSON_PRETTY_PRINT) {
442 15
            $indent = str_repeat($this->indent, count($this->stack));
443 15
            $this->write("\n", JsonToken::T_WHITESPACE);
444
445 15
            if ($indent !== '') {
446 15
                $this->write($indent, JsonToken::T_WHITESPACE);
447 5
            }
448
449 15
            $this->line++;
450 15
            $this->column = strlen($indent) + 1;
451 5
        }
452
453 114
        $this->newLine = false;
454 114
        $this->write($string, $token);
455 114
        $this->column += strlen($string);
456 38
    }
457
458
    /**
459
     * Actually handles the writing of the given token to the output stream.
460
     * @param string $string The given token to write
461
     * @param int $token The type of the token
462
     * @return void
463
     */
464
    abstract protected function write($string, $token);
465
}
466