Completed
Push — master ( 8c492d...2143ab )
by Riikka
10:20
created

AbstractJsonEncoder::isAssociative()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 16
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 4

Importance

Changes 0
Metric Value
dl 0
loc 16
ccs 7
cts 7
cp 1
rs 9.2
c 0
b 0
f 0
cc 4
eloc 8
nc 4
nop 1
crap 4
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 108
     * AbstractJsonEncoder constructor.
52
     * @param mixed $value The value to encode as JSON
53 108
     */
54 108
    public function __construct($value)
55 108
    {
56 108
        $this->initialValue = $value;
57 108
        $this->options = 0;
58 36
        $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 69
     * @return $this Returns self for call chaining
66
     * @throws \RuntimeException If changing encoding options during encoding operation
67 69
     */
68 3
    public function setOptions($options)
69
    {
70
        if ($this->step !== null) {
71 66
            throw new \RuntimeException('Cannot change encoding options during encoding');
72 66
        }
73
74
        $this->options = (int) $options;
75
        return $this;
76
    }
77
78
    /**
79
     * Sets the indent for the JSON output.
80 39
     * @param string|int $indent A string to use as indent or the number of spaces
81
     * @return $this Returns self for call chaining
82 39
     * @throws \RuntimeException If changing indent during encoding operation
83 3
     */
84
    public function setIndent($indent)
85
    {
86 36
        if ($this->step !== null) {
87 36
            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 6
    /**
95
     * Returns the list of errors that occurred during the last encoding process.
96 6
     * @return string[] List of errors that occurred during encoding
97
     */
98
    public function getErrors()
99
    {
100
        return $this->errors;
101
    }
102 81
103
    /**
104 81
     * Returns the current encoding value stack.
105 3
     * @return array The current encoding value stack
106 1
     */
107 27
    protected function getValueStack()
108
    {
109
        return $this->valueStack;
110
    }
111
112
    /**
113 6
     * Initializes the iterator if it has not been initialized yet.
114
     */
115 6
    private function initialize()
116
    {
117 6
        if (!isset($this->stack)) {
118
            $this->rewind();
119
        }
120
    }
121
122
    /**
123
     * Returns the current number of step in the encoder.
124 78
     * @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 78
    public function key()
127
    {
128 78
        $this->initialize();
129
130
        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
    public function valid()
138
    {
139
        $this->initialize();
140 108
141
        return $this->step !== null;
142 108
    }
143 3
144
    /**
145
     * Returns the current value or state from the encoder.
146 108
     * @return mixed The current value or state from the encoder
147 108
     */
148 108
    abstract public function current();
149 108
150 108
    /**
151 108
     * Returns the JSON encoding to the beginning.
152 108
     */
153 108
    public function rewind()
154
    {
155 108
        if ($this->step === 0) {
156 35
            return;
157
        }
158
159
        $this->stack = [];
160
        $this->stackType = [];
161 78
        $this->valueStack = [];
162
        $this->errors = [];
163 78
        $this->newLine = false;
164
        $this->first = true;
165 78
        $this->line = 1;
166 57
        $this->column = 1;
167 57
        $this->step = 0;
168
169 57
        $this->processValue($this->initialValue);
170 51
    }
171 51
172 17
    /**
173 55
     * Iterates the next token or tokens to the output stream.
174
     */
175 19
    public function next()
176 75
    {
177
        $this->initialize();
178 52
179
        if (!empty($this->stack)) {
180
            $this->step++;
181
            $iterator = end($this->stack);
182
183
            if ($iterator->valid()) {
184
                $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 51
                $iterator->next();
186
            } else {
187 51
                $this->popStack();
188 30
            }
189 12
        } else {
190
            $this->step = null;
191 35
        }
192 18
    }
193 6
194
    /**
195 51
     * Handles the next value from the iterator to be encoded as JSON.
196 51
     * @param \Iterator $iterator The iterator used to generate the next value
197 17
     * @param bool $isObject True if the iterator is being handled as an object, false if not
198
     */
199
    private function processStack(\Iterator $iterator, $isObject)
200
    {
201
        if ($isObject) {
202
            if (!$this->processKey($iterator->key())) {
203
                return;
204 30
            }
205
        } elseif (!$this->first) {
206 30
            $this->outputLine(',', JsonToken::T_COMMA);
207 3
        }
208 3
209
        $this->first = false;
210
        $this->processValue($iterator->current());
211 30
    }
212 18
213 6
    /**
214
     * Handles the given value key into JSON.
215 30
     * @param mixed $key The key to process
216 30
     * @return bool True if the key is valid, false if not
217
     */
218 30
    private function processKey($key)
219 6
    {
220 2
        if (!is_int($key) && !is_string($key)) {
221
            $this->addError('Only string or integer keys are supported');
222 30
            return false;
223
        }
224
225
        if (!$this->first) {
226
            $this->outputLine(',', JsonToken::T_COMMA);
227
        }
228
229 108
        $this->outputJson((string) $key, JsonToken::T_NAME);
230
        $this->output(':', JsonToken::T_COLON);
231 108
232 6
        if ($this->options & JSON_PRETTY_PRINT) {
233 3
            $this->output(' ', JsonToken::T_WHITESPACE);
234 4
        }
235 3
236 1
        return true;
237 2
    }
238
239 108
    /**
240 57
     * Handles the given JSON value appropriately depending on it's type.
241 19
     * @param mixed $value The value that should be encoded as JSON
242 102
     */
243
    private function processValue($value)
244 70
    {
245
        $this->valueStack[] = $value;
246
        $value = $this->resolveValue($value);
247
248
        if (is_array($value) || is_object($value)) {
249
            $this->pushStack($value);
250
        } else {
251 108
            $this->outputJson($value, JsonToken::T_VALUE);
252
            array_pop($this->valueStack);
253 108
        }
254
    }
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 9
    protected function resolveValue($value)
262
    {
263 9
        do {
264 9
            if ($value instanceof \JsonSerializable) {
265
                $value = $value->jsonSerialize();
266 9
            } elseif ($value instanceof \Closure) {
267 3
                $value = $value();
268
            } else {
269
                return $value;
270 6
            }
271 6
        } while (true);
272
    }
273 6
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
    private function addError($message)
280 57
    {
281
        $errorMessage = sprintf('Line %d, column %d: %s', $this->line, $this->column, $message);
282 57
        $this->errors[] = $errorMessage;
283 57
284
        if ($this->options & JSON_PARTIAL_OUTPUT_ON_ERROR) {
285 57
            return;
286 30
        }
287 10
288 33
        $this->stack = [];
289
        $this->step = null;
290
291 57
        throw new EncodingException($errorMessage);
292 57
    }
293 57
294 19
    /**
295
     * Pushes the given iterable to the value stack.
296
     * @param object|array $iterable The iterable value to push to the stack
297
     */
298
    private function pushStack($iterable)
299
    {
300
        $iterator = $this->getIterator($iterable);
301 57
        $isObject = $this->isObject($iterable, $iterator);
302
303 57
        if ($isObject) {
304 53
            $this->outputLine('{', JsonToken::T_LEFT_BRACE);
305 19
        } else {
306 18
            $this->outputLine('[', JsonToken::T_LEFT_BRACKET);
307
        }
308
309
        $this->first = true;
310
        $this->stack[] = $iterator;
311
        $this->stackType[] = $isObject;
312
    }
313
314 57
    /**
315
     * Creates a generator from the given iterable using a foreach loop.
316 57
     * @param object|array $iterable The iterable value to iterate
317 3
     * @return \Generator The generator using the given iterable
318 54
     */
319 45
    private function getIterator($iterable)
320
    {
321
        foreach ($iterable as $key => $value) {
322 12
            yield $key => $value;
323
        }
324
    }
325
326
    /**
327
     * Tells if the given iterable should be handled as a JSON object or not.
328 54
     * @param object|array $iterable The iterable value to test
329
     * @param \Iterator $iterator An Iterator created from the iterable value
330 54
     * @return bool True if the given iterable should be treated as object, false if not
331 48
     */
332 16
    private function isObject($iterable, \Iterator $iterator)
333
    {
334 54
        if ($this->options & JSON_FORCE_OBJECT) {
335 54
            return true;
336
        }
337 54
338 30
        if ($iterable instanceof \Traversable) {
339 10
            return $iterator->valid() && $iterator->key() !== 0;
340 30
        }
341
342 36
        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 102
     */
350
    private function isAssociative(array $array)
351 102
    {
352
        if ($array === []) {
353 102
            return false;
354 9
        }
355 1
356
        $expected = 0;
357 99
358 33
        foreach ($array as $key => $_) {
359
            if ($key !== $expected++) {
360
                return true;
361
            }
362
        }
363
364
        return false;
365 57
    }
366
367 57
    /**
368 57
     * Removes the top element of the value stack.
369 19
     */
370
    private function popStack()
371
    {
372
        if (!$this->first) {
373
            $this->newLine = true;
374
        }
375
376 105
        $this->first = false;
377
        array_pop($this->stack);
378 105
379 15
        if (array_pop($this->stackType)) {
380 15
            $this->output('}', JsonToken::T_RIGHT_BRACE);
381
        } else {
382 15
            $this->output(']', JsonToken::T_RIGHT_BRACKET);
383 15
        }
384 5
385
        array_pop($this->valueStack);
386 15
    }
387 15
388 5
    /**
389
     * Encodes the given value as JSON and passes it to output stream.
390 105
     * @param mixed $value The value to output as JSON
391 105
     * @param int $token The token type of the value
392 105
     */
393 35
    private function outputJson($value, $token)
394
    {
395
        $encoded = json_encode($value, $this->options);
396
        $error = json_last_error();
397
398
        if ($error !== JSON_ERROR_NONE) {
399
            $this->addError(sprintf('%s (%s)', json_last_error_msg(), $this->getJsonErrorName($error)));
400
        }
401
402
        $this->output($encoded, $token);
403
    }
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
    private function getJsonErrorName($error)
411
    {
412
        $matches = array_keys(get_defined_constants(), $error, true);
413
        $prefix = 'JSON_ERROR_';
414
        $prefixLength = strlen($prefix);
415
416
        foreach ($matches as $match) {
417
            if (strncmp($match, $prefix, $prefixLength) === 0) {
418
                return $match;
419
            }
420
        }
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
    private function outputLine($string, $token)
429
    {
430
        $this->output($string, $token);
431
        $this->newLine = true;
432
    }
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
    private function output($string, $token)
440
    {
441
        if ($this->newLine && $this->options & JSON_PRETTY_PRINT) {
442
            $indent = str_repeat($this->indent, count($this->stack));
443
            $this->write("\n", JsonToken::T_WHITESPACE);
444
445
            if ($indent !== '') {
446
                $this->write($indent, JsonToken::T_WHITESPACE);
447
            }
448
449
            $this->line++;
450
            $this->column = strlen($indent) + 1;
451
        }
452
453
        $this->newLine = false;
454
        $this->write($string, $token);
455
        $this->column += strlen($string);
456
    }
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