Skip to content

Commit 5bf1b6c

Browse files
committed
Improve JsonStream efficiency
1 parent 44b103b commit 5bf1b6c

File tree

3 files changed

+210
-22
lines changed

3 files changed

+210
-22
lines changed

src/AbstractJsonEncoder.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
namespace Violet\StreamingJsonEncoder;
44

5-
use SebastianBergmann\CodeCoverage\RuntimeException;
6-
75
/**
86
* AbstractJsonEncoder.
97
*
@@ -58,19 +56,21 @@ public function __construct($value)
5856
public function setOptions($options)
5957
{
6058
if ($this->step !== null) {
61-
throw new RuntimeException('Cannot change encoding options during encoding');
59+
throw new \RuntimeException('Cannot change encoding options during encoding');
6260
}
6361

6462
$this->options = (int) $options;
63+
return $this;
6564
}
6665

6766
public function setIndent($indent)
6867
{
6968
if ($this->step !== null) {
70-
throw new RuntimeException('Cannot change indent during encoding');
69+
throw new \RuntimeException('Cannot change indent during encoding');
7170
}
7271

7372
$this->indent = is_int($indent) ? str_repeat(' ', $indent) : (string) $indent;
73+
return $this;
7474
}
7575

7676
public function getErrors()

src/JsonStream.php

Lines changed: 175 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,37 +3,62 @@
33
namespace Violet\StreamingJsonEncoder;
44

55
use Psr\Http\Message\StreamInterface;
6-
use SebastianBergmann\CodeCoverage\RuntimeException;
76

87
/**
9-
* JsonStream.
8+
* Provides a http stream interface for encoding JSON.
109
*
1110
* @author Riikka Kalliomäki <[email protected]>
1211
* @copyright Copyright (c) 2016, Riikka Kalliomäki
1312
* @license http://opensource.org/licenses/mit-license.php MIT License
1413
*/
1514
class JsonStream implements StreamInterface
1615
{
16+
/** @var BufferJsonEncoder The encoder used to produce the JSON stream */
1717
private $encoder;
18+
19+
/** @var int The current position of the cursor in the JSON stream */
1820
private $cursor;
21+
22+
/** @var string Buffered output from encoding the value as JSON */
1923
private $buffer;
2024

21-
public function __construct($value, $options = 0)
25+
/**
26+
* JsonStream constructor.
27+
* @param BufferJsonEncoder|mixed $value A JSON encoder to use or a value to encode
28+
*/
29+
public function __construct($value)
2230
{
23-
$this->encoder = new BufferJsonEncoder($value);
24-
$this->encoder->setOptions($options);
31+
if (!$value instanceof BufferJsonEncoder) {
32+
$value = new BufferJsonEncoder($value);
33+
}
34+
35+
$this->encoder = $value;
2536
$this->rewind();
2637
}
2738

39+
/**
40+
* Returns the JSON encoder used for the JSON stream.
41+
* @return BufferJsonEncoder The currently used JSON encoder
42+
* @throws \RuntimeException If the stream has been closed
43+
*/
2844
private function getEncoder()
2945
{
3046
if (!$this->encoder instanceof BufferJsonEncoder) {
31-
throw new RuntimeException('Cannot operate on a closed JSON stream');
47+
throw new \RuntimeException('Cannot operate on a closed JSON stream');
3248
}
3349

3450
return $this->encoder;
3551
}
3652

53+
/**
54+
* Returns the entire JSON stream as a string.
55+
*
56+
* Note that this operation performs rewind operation on the JSON encoder. Whether
57+
* this works or not is dependant on the underlying value being encoded. An empty
58+
* string is returned if the value cannot be encoded.
59+
*
60+
* @return string The entire JSON stream as a string
61+
*/
3762
public function __toString()
3863
{
3964
try {
@@ -44,81 +69,195 @@ public function __toString()
4469
}
4570
}
4671

72+
/**
73+
* Frees the JSON encoder from memory and prevents further reading from the JSON stream.
74+
*/
4775
public function close()
4876
{
4977
$this->encoder = null;
5078
}
5179

80+
/**
81+
* Detaches the underlying PHP stream and returns it.
82+
* @return null Always returns null as no underlying PHP stream exists
83+
*/
5284
public function detach()
5385
{
5486
return null;
5587
}
5688

89+
/**
90+
* Returns the total size of the JSON stream.
91+
* @return null Always returns null as the total size cannot be determined
92+
*/
5793
public function getSize()
5894
{
5995
return null;
6096
}
6197

98+
/**
99+
* Returns the current position of the cursor in the JSON stream.
100+
* @return int Current position of the cursor
101+
*/
62102
public function tell()
63103
{
64104
$this->getEncoder();
65105
return $this->cursor;
66106
}
67107

108+
/**
109+
* Tells if there are no more bytes to read from the JSON stream.
110+
* @return bool True if there are no more bytes to read, false if there are
111+
*/
68112
public function eof()
69113
{
70114
return $this->buffer === null;
71115
}
72116

117+
/**
118+
* Tells if the JSON stream is seekable or not.
119+
* @return bool Always returns true as JSON streams as always seekable
120+
*/
73121
public function isSeekable()
74122
{
75123
return true;
76124
}
77125

126+
/**
127+
* Seeks the given cursor position in the JSON stream.
128+
*
129+
* If the provided seek position is less than the current cursor position, a rewind
130+
* operation is performed on the underlying JSON encoder. Whether this works or not
131+
* depends on whether the encoded value supports rewinding.
132+
*
133+
* Note that since it's not possible to determine the end of the JSON stream without
134+
* encoding the entire value, it's not possible to set the cursor using SEEK_END
135+
* constant and doing so will result in an exception.
136+
*
137+
* @param int $offset The offset for the cursor.
138+
* @param int $whence Either SEEK_CUR or SEEK_SET to determine new cursor position
139+
*/
78140
public function seek($offset, $whence = SEEK_SET)
79141
{
80-
if ($whence === SEEK_CUR) {
81-
$position = max(0, $this->cursor + (int) $offset);
82-
} elseif ($whence === SEEK_END) {
83-
throw new \RuntimeException('Cannot set cursor position from the end of a JSON stream');
84-
} else {
85-
$position = max(0, (int) $offset);
86-
}
142+
$position = $this->calculatePosition($offset, $whence);
87143

88144
if (!isset($this->cursor) || $position < $this->cursor) {
89145
$this->getEncoder()->rewind();
90146
$this->buffer = '';
91147
$this->cursor = 0;
92148
}
93149

94-
while ($this->cursor < $position && !$this->eof()) {
95-
$this->read(min($position - $this->cursor, 8 * 1024));
150+
$this->forward($position);
151+
}
152+
153+
/**
154+
* Calculates new position for the cursor based on offset and whence.
155+
* @param int $offset The cursor offset
156+
* @param int $whence One of the SEEK_* constants
157+
* @return int The new cursor position
158+
*/
159+
private function calculatePosition($offset, $whence)
160+
{
161+
if ($whence === SEEK_CUR) {
162+
return max(0, $this->cursor + (int) $offset);
163+
} elseif ($whence === SEEK_SET) {
164+
return max(0, (int) $offset);
165+
} elseif ($whence === SEEK_END) {
166+
throw new \RuntimeException('Cannot set cursor position from the end of a JSON stream');
167+
}
168+
169+
throw new \InvalidArgumentException("Invalid cursor relative position '$whence'");
170+
}
171+
172+
/**
173+
* Forwards the JSON stream reading cursor to the given position or to the end of stream.
174+
* @param int $position The new position of the cursor.
175+
*/
176+
private function forward($position)
177+
{
178+
$encoder = $this->getEncoder();
179+
180+
while ($this->cursor < $position) {
181+
$length = strlen($this->buffer);
182+
183+
if ($this->cursor + $length > $position) {
184+
$this->buffer = substr($this->buffer, $position - $this->cursor);
185+
$this->cursor = $position;
186+
break;
187+
}
188+
189+
$this->cursor += $length;
190+
$this->buffer = '';
191+
192+
if (!$encoder->valid()) {
193+
$this->buffer = null;
194+
break;
195+
}
196+
197+
$this->buffer = $encoder->current();
198+
$encoder->next();
96199
}
97200
}
98201

202+
/**
203+
* Seeks the beginning of the JSON stream.
204+
*
205+
* If the encoding has already been started, rewinding the encoder may not work,
206+
* if the underlying value being encoded does not support rewinding.
207+
*/
99208
public function rewind()
100209
{
101210
$this->seek(0);
102211
}
103212

213+
/**
214+
* Tells if the JSON stream is writable or not.
215+
* @return bool Always returns false as JSON streams are never writable
216+
*/
104217
public function isWritable()
105218
{
106219
return false;
107220
}
108221

222+
/**
223+
* Writes the given bytes to the JSON stream.
224+
*
225+
* As the JSON stream does not represent a writable stream, this method will
226+
* always throw a runtime exception.
227+
*
228+
* @param string $string The bytes to write
229+
* @return int The number of bytes written
230+
* @throws \RuntimeException Always throws a runtime exception
231+
*/
109232
public function write($string)
110233
{
111234
throw new \RuntimeException('Cannot write to a JSON stream');
112235
}
113236

237+
/**
238+
* Tells if the JSON stream is readable or not.
239+
* @return bool Always returns true as JSON streams are always readable
240+
*/
114241
public function isReadable()
115242
{
116243
return true;
117244
}
118245

246+
/**
247+
* Returns the given number of bytes from the JSON stream.
248+
*
249+
* The underlying value is encoded into JSON until enough bytes have been
250+
* generated to fulfill the requested number of bytes. The extraneous bytes are
251+
* then buffered for the next read from the JSON stream. The stream may return
252+
* fewer number of bytes if the entire value has been encoded and there are no
253+
* more bytes to return.
254+
*
255+
* @param int $length The number of bytes to return
256+
* @return string The bytes read from the JSON stream
257+
*/
119258
public function read($length)
120259
{
121-
$length = (int) $length;
260+
$length = max(0, (int) $length);
122261
$encoder = $this->getEncoder();
123262

124263
while (strlen($this->buffer) < $length && $encoder->valid()) {
@@ -139,17 +278,35 @@ public function read($length)
139278
return $output;
140279
}
141280

281+
/**
282+
* Returns the remaining bytes from the JSON stream.
283+
* @return string The remaining bytes from JSON stream
284+
*/
142285
public function getContents()
143286
{
287+
$encoder = $this->getEncoder();
144288
$output = '';
145289

146-
while (!$this->eof()) {
147-
$output .= $this->read(8 * 1024);
290+
while ($encoder->valid()) {
291+
$output .= $encoder->current();
292+
$encoder->next();
148293
}
149294

295+
$this->cursor += strlen($output);
296+
$this->buffer = null;
297+
150298
return $output;
151299
}
152300

301+
/**
302+
* Returns the metadata from the underlying PHP stream.
303+
*
304+
* As no underlying PHP stream exists for the JSON stream, this method will
305+
* always return an empty array or a null.
306+
*
307+
* @param string|null The key of the value to return
308+
* @return array|null Always returns an empty array or a null
309+
*/
153310
public function getMetadata($key = null)
154311
{
155312
return $key === null ? [] : null;

tests/tests/JsonStreamTest.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@ public function testSeek()
3939
$stream->seek(-6, SEEK_CUR);
4040
$this->assertSame('"', $stream->read(1));
4141
$this->assertSame(8, $stream->tell());
42+
43+
$stream->seek(100);
44+
$this->assertSame(15, $stream->tell());
45+
$this->assertTrue($stream->eof());
46+
$this->assertSame('', $stream->read(1));
47+
48+
$stream->seek(9);
49+
$this->assertFalse($stream->eof());
50+
$this->assertSame(9, $stream->tell());
51+
$this->assertSame('a', $stream->read(1));
52+
$stream->seek(11);
53+
$this->assertSame(11, $stream->tell());
54+
$this->assertSame('u', $stream->read(1));
4255
}
4356

4457
public function testReadAfterClose()
@@ -54,6 +67,7 @@ public function testToString()
5467
{
5568
$stream = new JsonStream(['key' => 'value']);
5669
$this->assertSame('{"key":"value"}', (string) $stream);
70+
$this->assertTrue($stream->eof());
5771
}
5872

5973
public function testToStringAfterClose()
@@ -91,4 +105,21 @@ public function testSeekFromEnd()
91105
$this->expectException(\RuntimeException::class);
92106
$stream->seek(2, SEEK_END);
93107
}
108+
109+
public function testInvalidSeekWhence()
110+
{
111+
$stream = new JsonStream('value');
112+
113+
$this->expectException(\InvalidArgumentException::class);
114+
$stream->seek(2, 'invalid');
115+
}
116+
117+
public function testPrettyPrintStream()
118+
{
119+
$encoder = (new BufferJsonEncoder(['value']))
120+
->setOptions(JSON_PRETTY_PRINT);
121+
122+
$stream = new JsonStream($encoder);
123+
$this->assertSame("[\n \"value\"\n]", $stream->getContents());
124+
}
94125
}

0 commit comments

Comments
 (0)