Completed
Push — master ( f8ca6a...ed49a1 )
by Riikka
09:43
created

AbstractJsonEncoder::processValue()   B

Complexity

Conditions 6
Paths 8

Size

Total Lines 16
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 6

Importance

Changes 0
Metric Value
dl 0
loc 16
ccs 9
cts 9
cp 1
rs 8.8571
c 0
b 0
f 0
cc 6
eloc 10
nc 8
nop 1
crap 6
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 68
    public function __construct($value)
52
    {
53 68
        $this->initialValue = $value;
54 68
        $this->options = 0;
55 68
        $this->errors = [];
56 68
        $this->indent = '    ';
57 68
        $this->step = null;
58 34
    }
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 46
    public function setOptions($options)
66
    {
67 46
        if ($this->step !== null) {
68 2
            throw new \RuntimeException('Cannot change encoding options during encoding');
69
        }
70
71 44
        $this->options = (int) $options;
72 44
        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 26
    public function setIndent($indent)
81
    {
82 26
        if ($this->step !== null) {
83 2
            throw new \RuntimeException('Cannot change indent during encoding');
84
        }
85
86 24
        $this->indent = is_int($indent) ? str_repeat(' ', $indent) : (string) $indent;
87 24
        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 4
    public function getErrors()
95
    {
96 4
        return $this->errors;
97
    }
98
99
    /**
100
     * Initializes the iterator if it has not been initialized yet.
101
     */
102 50
    private function initialize()
103
    {
104 50
        if (!isset($this->stack)) {
105 2
            $this->rewind();
106 1
        }
107 25
    }
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 4
    public function key()
114
    {
115 4
        $this->initialize();
116
117 4
        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 48
    public function valid()
125
    {
126 48
        $this->initialize();
127
128 48
        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 68
    public function rewind()
141
    {
142 68
        if ($this->step === 0) {
143 2
            return;
144
        }
145
146 68
        $this->stack = [];
147 68
        $this->stackType = [];
148 68
        $this->errors = [];
149 68
        $this->newLine = false;
150 68
        $this->first = true;
151 68
        $this->line = 1;
152 68
        $this->column = 1;
153 68
        $this->step = 0;
154
155 68
        $this->processValue($this->initialValue);
156 33
    }
157
158
    /**
159
     * Iterates the next token or tokens to the output stream.
160
     */
161 48
    public function next()
162
    {
163 48
        $this->initialize();
164
165 48
        if (!empty($this->stack)) {
166 34
            $this->step++;
167 34
            $generator = end($this->stack);
168
169 34
            if ($generator->valid()) {
170 30
                $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 30
                $generator->next();
172 15
            } else {
173 34
                $this->popStack();
174
            }
175 17
        } else {
176 48
            $this->step = null;
177
        }
178 24
    }
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 30
    private function processStack(\Generator $generator, $isObject)
186
    {
187 30
        if ($isObject) {
188 20
            $key = $generator->key();
189
190 20
            if (!is_int($key) && !is_string($key)) {
191 2
                $this->addError('Only string or integer keys are supported');
192 2
                return;
193
            }
194
195 20
            if (!$this->first) {
196 12
                $this->outputLine(',', JsonToken::T_COMMA);
197 6
            }
198
199 20
            $this->outputJson((string) $key, JsonToken::T_NAME);
200 20
            $this->output(':', JsonToken::T_COLON);
201
202 20
            if ($this->options & JSON_PRETTY_PRINT) {
203 12
                $this->output(' ', JsonToken::T_WHITESPACE);
204 2
            }
205 22
        } elseif (!$this->first) {
206 8
            $this->outputLine(',', JsonToken::T_COMMA);
207 4
        }
208
209 30
        $this->first = false;
210 30
        $this->processValue($generator->current());
211 15
    }
212
213
    /**
214
     * Handles the given JSON value appropriately depending on it's type.
215
     * @param mixed $value The value that should be encoded as JSON
216
     */
217 68
    private function processValue($value)
218
    {
219 68
        while ($this->isResolvable($value)) {
220 2
            if ($value instanceof \JsonSerializable) {
221 1
                $value = $value->jsonSerialize();
222
            } elseif ($value instanceof \Closure) {
223 68
                $value = $value();
224 34
            }
225 17
        }
226 64
227
        if (is_array($value) || is_object($value)) {
228 33
            $this->pushStack($value);
229
        } else {
230
            $this->outputJson($value, JsonToken::T_VALUE);
231
        }
232
    }
233
234
    /**
235 4
     * Tells if the given value should be resolved prior to encoding
236
     * @param mixed $value The value to test
237 4
     * @return bool true if the value is resolvable, false if not
238 4
     */
239
    private function isResolvable($value)
240 4
    {
241 2
        return $value instanceof \JsonSerializable || $value instanceof \Closure;
242
    }
243
244 2
    /**
245
     * Adds an JSON encoding error to the list of errors.
246
     * @param string $message The error message to add
247
     * @throws EncodingException If the encoding should not continue due to the error
248
     */
249
    private function addError($message)
250
    {
251 34
        $errorMessage = sprintf('Line %d, column %d: %s', $this->line, $this->column, $message);
252
        $this->errors[] = $errorMessage;
253 34
254 34
        if ($this->options & JSON_PARTIAL_OUTPUT_ON_ERROR) {
255
            return;
256 34
        }
257 20
258 10
        $this->stack = [];
259 18
        $this->step = null;
260
261
        throw new EncodingException($errorMessage);
262 34
    }
263 34
264 34
    /**
265 17
     * Pushes the given iterable to the value stack.
266
     * @param object|array $iterable The iterable value to push to the stack
267
     */
268
    private function pushStack($iterable)
269
    {
270
        $generator = $this->getIterator($iterable);
271
        $isObject = $this->isObject($iterable, $generator);
272 34
273
        if ($isObject) {
274 34
            $this->outputLine('{', JsonToken::T_LEFT_BRACE);
275 32
        } else {
276 17
            $this->outputLine('[', JsonToken::T_LEFT_BRACKET);
277 17
        }
278
279
        $this->first = true;
280
        $this->stack[] = $generator;
281
        $this->stackType[] = $isObject;
282
    }
283
284
    /**
285 34
     * Creates a generator from the given iterable using a foreach loop.
286
     * @param object|array $iterable The iterable value to iterate
287 34
     * @return \Generator The generator using the given iterable
288 2
     */
289 32
    private function getIterator($iterable)
290 28
    {
291
        foreach ($iterable as $key => $value) {
292
            yield $key => $value;
293 6
        }
294
    }
295
296
    /**
297
     * Tells if the given iterable should be handled as a JSON object or not.
298
     * @param object|array $iterable The iterable value to test
299 34
     * @param \Generator $generator Generator created from the iterable value
300
     * @return bool True if the given iterable should be treated as object, false if not
301 34
     */
302 30
    private function isObject($iterable, \Generator $generator)
303 15
    {
304
        if ($this->options & JSON_FORCE_OBJECT) {
305 34
            return true;
306 34
        } elseif (is_array($iterable)) {
307
            return $iterable !== [] && array_keys($iterable) !== range(0, count($iterable) - 1);
308 34
        }
309 20
310 10
        return $generator->valid() && $generator->key() !== 0;
311 18
    }
312
313 17
    /**
314
     * Removes the top element of the value stack.
315
     */
316
    private function popStack()
317
    {
318
        if (!$this->first) {
319
            $this->newLine = true;
320 64
        }
321
322 64
        $this->first = false;
323
        array_pop($this->stack);
324 64
325 4
        if (array_pop($this->stackType)) {
326 1
            $this->output('}', JsonToken::T_RIGHT_BRACE);
327
        } else {
328 62
            $this->output(']', JsonToken::T_RIGHT_BRACKET);
329 31
        }
330
    }
331
332
    /**
333
     * Encodes the given value as JSON and passes it to output stream.
334
     * @param mixed $value The value to output as JSON
335
     * @param int $token The token type of the value
336 34
     */
337
    private function outputJson($value, $token)
338 34
    {
339 34
        $encoded = json_encode($value, $this->options);
340 17
341
        if (json_last_error() !== JSON_ERROR_NONE) {
342
            $this->addError(json_last_error_msg());
343
        }
344
345
        $this->output($encoded, $token);
346
    }
347 66
348
    /**
349 66
     * Passes the given token to the output stream and ensures the next token is preceded by a newline.
350 10
     * @param string $string The token to write to the output stream
351 10
     * @param int $token The type of the token
352
     */
353 10
    private function outputLine($string, $token)
354 10
    {
355 5
        $this->output($string, $token);
356
        $this->newLine = true;
357 10
    }
358 10
359 5
    /**
360
     * Passes the given token to the output stream.
361 66
     * @param string $string The token to write to the output stream
362 66
     * @param int $token The type of the token
363 66
     */
364 33
    private function output($string, $token)
365
    {
366
        if ($this->newLine && $this->options & JSON_PRETTY_PRINT) {
367
            $indent = str_repeat($this->indent, count($this->stack));
368
            $this->write("\n", JsonToken::T_WHITESPACE);
369
370
            if ($indent !== '') {
371
                $this->write($indent, JsonToken::T_WHITESPACE);
372
            }
373
374
            $this->line += 1;
375
            $this->column = strlen($indent) + 1;
376
        }
377
378
        $this->newLine = false;
379
        $this->write($string, $token);
380
        $this->column += strlen($string);
381
    }
382
383
    /**
384
     * Actually handles the writing of the given token to the output stream.
385
     * @param string $string The given token to write
386
     * @param int $token The type of the token
387
     * @return void
388
     */
389
    abstract protected function write($string, $token);
390
}
391