Completed
Push — master ( 58159e...d66afb )
by Riikka
02:03
created

AbstractJsonEncoder::valid()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
dl 0
loc 6
ccs 3
cts 3
cp 1
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 3
nc 1
nop 0
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 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));
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 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
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...
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