Completed
Push — master ( 927b36...8c492d )
by Riikka
02:45
created

AbstractJsonEncoder::processKey()   B

Complexity

Conditions 5
Paths 5

Size

Total Lines 20
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 5

Importance

Changes 0
Metric Value
dl 0
loc 20
ccs 13
cts 13
cp 1
rs 8.8571
c 0
b 0
f 0
cc 5
eloc 11
nc 5
nop 1
crap 5
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 \Generator[] 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 bool Whether the next value is the first value in an array or an object */
21
    private $first;
22
23
    /** @var int The JSON encoding options */
24
    private $options;
25
26
    /** @var bool Whether next token should be preceded by new line or not */
27
    private $newLine;
28
29
    /** @var string Indent to use for indenting JSON output */
30
    private $indent;
31
32
    /** @var string[] Errors that occurred in encoding */
33
    private $errors;
34
35
    /** @var int Number of the current line in output */
36
    private $line;
37
38
    /** @var int Number of the current column in output */
39
    private $column;
40
41
    /** @var mixed The initial value to encode as JSON */
42
    private $initialValue;
43
44
    /** @var int|null The current step of the encoder */
45
    private $step;
46
47
    /**
48
     * AbstractJsonEncoder constructor.
49
     * @param mixed $value The value to encode as JSON
50
     */
51 108
    public function __construct($value)
52
    {
53 108
        $this->initialValue = $value;
54 108
        $this->options = 0;
55 108
        $this->errors = [];
56 108
        $this->indent = '    ';
57 108
        $this->step = null;
58 36
    }
59
60
    /**
61
     * Sets the JSON encoding options.
62
     * @param int $options The JSON encoding options that are used by json_encode
63
     * @return $this Returns self for call chaining
64
     */
65 69
    public function setOptions($options)
66
    {
67 69
        if ($this->step !== null) {
68 3
            throw new \RuntimeException('Cannot change encoding options during encoding');
69
        }
70
71 66
        $this->options = (int) $options;
72 66
        return $this;
73
    }
74
75
    /**
76
     * Sets the indent for the JSON output.
77
     * @param string|int $indent A string to use as indent or the number of spaces
78
     * @return $this Returns self for call chaining
79
     */
80 39
    public function setIndent($indent)
81
    {
82 39
        if ($this->step !== null) {
83 3
            throw new \RuntimeException('Cannot change indent during encoding');
84
        }
85
86 36
        $this->indent = is_int($indent) ? str_repeat(' ', $indent) : (string) $indent;
87 36
        return $this;
88
    }
89
90
    /**
91
     * Returns the list of errors that occurred during the last encoding process.
92
     * @return string[] List of errors that occurred during encoding
93
     */
94 6
    public function getErrors()
95
    {
96 6
        return $this->errors;
97
    }
98
99
    /**
100
     * Initializes the iterator if it has not been initialized yet.
101
     */
102 81
    private function initialize()
103
    {
104 81
        if (!isset($this->stack)) {
105 3
            $this->rewind();
106 1
        }
107 27
    }
108
109
    /**
110
     * Returns the current number of step in the encoder.
111
     * @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...
112
     */
113 6
    public function key()
114
    {
115 6
        $this->initialize();
116
117 6
        return $this->step;
118
    }
119
120
    /**
121
     * Tells if the encoder has a valid current state.
122
     * @return bool True if the iterator has a valid state, false if not
123
     */
124 78
    public function valid()
125
    {
126 78
        $this->initialize();
127
128 78
        return $this->step !== null;
129
    }
130
131
    /**
132
     * Returns the current value or state from the encoder.
133
     * @return mixed The current value or state from the encoder
134
     */
135
    abstract public function current();
136
137
    /**
138
     * Returns the JSON encoding to the beginning.
139
     */
140 108
    public function rewind()
141
    {
142 108
        if ($this->step === 0) {
143 3
            return;
144
        }
145
146 108
        $this->stack = [];
147 108
        $this->stackType = [];
148 108
        $this->errors = [];
149 108
        $this->newLine = false;
150 108
        $this->first = true;
151 108
        $this->line = 1;
152 108
        $this->column = 1;
153 108
        $this->step = 0;
154
155 108
        $this->processValue($this->initialValue);
156 35
    }
157
158
    /**
159
     * Iterates the next token or tokens to the output stream.
160
     */
161 78
    public function next()
162
    {
163 78
        $this->initialize();
164
165 78
        if (!empty($this->stack)) {
166 57
            $this->step++;
167 57
            $generator = end($this->stack);
168
169 57
            if ($generator->valid()) {
170 51
                $this->processStack($generator, end($this->stackType));
0 ignored issues
show
Security Bug introduced by
It seems like $generator defined by end($this->stack) on line 167 can also be of type false; however, Violet\StreamingJsonEnco...Encoder::processStack() does only seem to accept object<Generator>, 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...
171 51
                $generator->next();
172 17
            } else {
173 55
                $this->popStack();
174
            }
175 19
        } else {
176 75
            $this->step = null;
177
        }
178 52
    }
179
180
    /**
181
     * Handles the next value from the generator to be encoded as JSON.
182
     * @param \Generator $generator The generator used to generate the next value
183
     * @param bool $isObject True if the generator is being handled as an object, false if not
184
     */
185 51
    private function processStack(\Generator $generator, $isObject)
186
    {
187 51
        if ($isObject) {
188 30
            if (!$this->processKey($generator->key())) {
189 12
                return;
190
            }
191 35
        } elseif (!$this->first) {
192 18
            $this->outputLine(',', JsonToken::T_COMMA);
193 6
        }
194
195 51
        $this->first = false;
196 51
        $this->processValue($generator->current());
197 17
    }
198
199
    /**
200
     * Handles the given value key into JSON.
201
     * @param mixed $key The key to process
202
     * @return bool True if the key is valid, false if not
203
     */
204 30
    private function processKey($key)
205
    {
206 30
        if (!is_int($key) && !is_string($key)) {
207 3
            $this->addError('Only string or integer keys are supported');
208 3
            return false;
209
        }
210
211 30
        if (!$this->first) {
212 18
            $this->outputLine(',', JsonToken::T_COMMA);
213 6
        }
214
215 30
        $this->outputJson((string) $key, JsonToken::T_NAME);
216 30
        $this->output(':', JsonToken::T_COLON);
217
218 30
        if ($this->options & JSON_PRETTY_PRINT) {
219 6
            $this->output(' ', JsonToken::T_WHITESPACE);
220 2
        }
221
222 30
        return true;
223
    }
224
225
    /**
226
     * Handles the given JSON value appropriately depending on it's type.
227
     * @param mixed $value The value that should be encoded as JSON
228
     */
229 108
    private function processValue($value)
230
    {
231 108
        while ($this->isResolvable($value)) {
232 6
            if ($value instanceof \JsonSerializable) {
233 3
                $value = $value->jsonSerialize();
234 4
            } elseif ($value instanceof \Closure) {
235 3
                $value = $value();
236 1
            }
237 2
        }
238
239 108
        if (is_array($value) || is_object($value)) {
240 57
            $this->pushStack($value);
241 19
        } else {
242 102
            $this->outputJson($value, JsonToken::T_VALUE);
243
        }
244 70
    }
245
246
    /**
247
     * Tells if the given value should be resolved prior to encoding.
248
     * @param mixed $value The value to test
249
     * @return bool true if the value is resolvable, false if not
250
     */
251 108
    private function isResolvable($value)
252
    {
253 108
        return $value instanceof \JsonSerializable || $value instanceof \Closure;
254
    }
255
256
    /**
257
     * Adds an JSON encoding error to the list of errors.
258
     * @param string $message The error message to add
259
     * @throws EncodingException If the encoding should not continue due to the error
260
     */
261 9
    private function addError($message)
262
    {
263 9
        $errorMessage = sprintf('Line %d, column %d: %s', $this->line, $this->column, $message);
264 9
        $this->errors[] = $errorMessage;
265
266 9
        if ($this->options & JSON_PARTIAL_OUTPUT_ON_ERROR) {
267 3
            return;
268
        }
269
270 6
        $this->stack = [];
271 6
        $this->step = null;
272
273 6
        throw new EncodingException($errorMessage);
274
    }
275
276
    /**
277
     * Pushes the given iterable to the value stack.
278
     * @param object|array $iterable The iterable value to push to the stack
279
     */
280 57
    private function pushStack($iterable)
281
    {
282 57
        $generator = $this->getIterator($iterable);
283 57
        $isObject = $this->isObject($iterable, $generator);
284
285 57
        if ($isObject) {
286 30
            $this->outputLine('{', JsonToken::T_LEFT_BRACE);
287 10
        } else {
288 33
            $this->outputLine('[', JsonToken::T_LEFT_BRACKET);
289
        }
290
291 57
        $this->first = true;
292 57
        $this->stack[] = $generator;
293 57
        $this->stackType[] = $isObject;
294 19
    }
295
296
    /**
297
     * Creates a generator from the given iterable using a foreach loop.
298
     * @param object|array $iterable The iterable value to iterate
299
     * @return \Generator The generator using the given iterable
300
     */
301 57
    private function getIterator($iterable)
302
    {
303 57
        foreach ($iterable as $key => $value) {
304 53
            yield $key => $value;
305 19
        }
306 18
    }
307
308
    /**
309
     * Tells if the given iterable should be handled as a JSON object or not.
310
     * @param object|array $iterable The iterable value to test
311
     * @param \Generator $generator Generator created from the iterable value
312
     * @return bool True if the given iterable should be treated as object, false if not
313
     */
314 57
    private function isObject($iterable, \Generator $generator)
315
    {
316 57
        if ($this->options & JSON_FORCE_OBJECT) {
317 3
            return true;
318 54
        } elseif (is_array($iterable)) {
319 45
            return $iterable !== [] && array_keys($iterable) !== range(0, count($iterable) - 1);
320
        }
321
322 12
        return $generator->valid() && $generator->key() !== 0;
323
    }
324
325
    /**
326
     * Removes the top element of the value stack.
327
     */
328 54
    private function popStack()
329
    {
330 54
        if (!$this->first) {
331 48
            $this->newLine = true;
332 16
        }
333
334 54
        $this->first = false;
335 54
        array_pop($this->stack);
336
337 54
        if (array_pop($this->stackType)) {
338 30
            $this->output('}', JsonToken::T_RIGHT_BRACE);
339 10
        } else {
340 30
            $this->output(']', JsonToken::T_RIGHT_BRACKET);
341
        }
342 36
    }
343
344
    /**
345
     * Encodes the given value as JSON and passes it to output stream.
346
     * @param mixed $value The value to output as JSON
347
     * @param int $token The token type of the value
348
     */
349 102
    private function outputJson($value, $token)
350
    {
351 102
        $encoded = json_encode($value, $this->options);
352
353 102
        if (json_last_error() !== JSON_ERROR_NONE) {
354 9
            $this->addError(json_last_error_msg());
355 1
        }
356
357 99
        $this->output($encoded, $token);
358 33
    }
359
360
    /**
361
     * Passes the given token to the output stream and ensures the next token is preceded by a newline.
362
     * @param string $string The token to write to the output stream
363
     * @param int $token The type of the token
364
     */
365 57
    private function outputLine($string, $token)
366
    {
367 57
        $this->output($string, $token);
368 57
        $this->newLine = true;
369 19
    }
370
371
    /**
372
     * Passes the given token to the output stream.
373
     * @param string $string The token to write to the output stream
374
     * @param int $token The type of the token
375
     */
376 105
    private function output($string, $token)
377
    {
378 105
        if ($this->newLine && $this->options & JSON_PRETTY_PRINT) {
379 15
            $indent = str_repeat($this->indent, count($this->stack));
380 15
            $this->write("\n", JsonToken::T_WHITESPACE);
381
382 15
            if ($indent !== '') {
383 15
                $this->write($indent, JsonToken::T_WHITESPACE);
384 5
            }
385
386 15
            $this->line += 1;
387 15
            $this->column = strlen($indent) + 1;
388 5
        }
389
390 105
        $this->newLine = false;
391 105
        $this->write($string, $token);
392 105
        $this->column += strlen($string);
393 35
    }
394
395
    /**
396
     * Actually handles the writing of the given token to the output stream.
397
     * @param string $string The given token to write
398
     * @param int $token The type of the token
399
     * @return void
400
     */
401
    abstract protected function write($string, $token);
402
}
403