Skip to content

Commit af004ff

Browse files
committed
Merge remote-tracking branch 'upstream/main'
2 parents 08bf3f2 + b2ad31d commit af004ff

2 files changed

Lines changed: 297 additions & 0 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php namespace io\streams;
2+
3+
use io\IOException;
4+
use lang\Environment;
5+
6+
/**
7+
* Seekable input stream which spools to a temporary file
8+
*
9+
* @test io.unittest.SpooledInputStreamTest
10+
*/
11+
class SpooledInputStream implements InputStream, Seekable {
12+
const PREFIX= 'spooled-';
13+
14+
private $in, $buffer;
15+
private $end= null;
16+
17+
/**
18+
* Creates a new spooled input stream
19+
*
20+
* @param io.streams.InputStream $in
21+
* @param ?string|io.Path|io.Folder|io.File|io.streams.Buffer $temp
22+
*/
23+
public function __construct(InputStream $in, $temp= null) {
24+
$this->in= $in;
25+
$this->buffer= $temp instanceof Buffer ? $temp : new Buffer($temp ?? Environment::tempDir(), 0);
26+
}
27+
28+
/** @return int */
29+
public function available() {
30+
return null === $this->end
31+
? $this->in->available() + $this->buffer->size() - $this->buffer->tell()
32+
: $this->end - $this->buffer->tell()
33+
;
34+
}
35+
36+
/**
37+
* Reads, spooling the data to the file
38+
*
39+
* @param int $limit
40+
* @return string
41+
*/
42+
public function read($limit= 8192) {
43+
44+
// Read from the underlying stream if we're at the end of the file
45+
if (null === $this->end && $this->buffer->tell() >= $this->buffer->size()) {
46+
$chunk= $this->in->read($limit);
47+
if ('' === $chunk) {
48+
$this->end= $this->buffer->tell();
49+
} else {
50+
$this->buffer->write($chunk);
51+
}
52+
} else {
53+
$chunk= $this->buffer->read($limit);
54+
}
55+
56+
return $chunk;
57+
}
58+
59+
/** @return int */
60+
private function drain() {
61+
$this->buffer->seek(0, SEEK_END);
62+
while ('' !== ($chunk= $this->in->read())) {
63+
$this->buffer->write($chunk);
64+
}
65+
return $this->buffer->tell();
66+
}
67+
68+
/**
69+
* Seeks to a given offset
70+
*
71+
* @param int $offset
72+
* @param int $whence SEEK_SET, SEEK_CUR or SEEK_END
73+
* @return void
74+
* @throws io.IOException
75+
*/
76+
public function seek($offset, $whence= SEEK_SET) {
77+
switch ($whence) {
78+
case SEEK_SET: $position= $offset; break;
79+
case SEEK_CUR: $position= $this->buffer->tell() + $offset; break;
80+
case SEEK_END: $position= ($this->end??= $this->drain()) + $offset; break;
81+
default: $position= -1; break;
82+
}
83+
84+
if ($position < 0) {
85+
throw new IOException("Seek error, position {$offset} in mode {$whence}");
86+
}
87+
88+
// Read from underlying stream when seeking forward, clamping on EOF.
89+
if (null === $this->end && ($fill= ($position - $this->buffer->size())) > 0) {
90+
$this->buffer->seek(0, SEEK_END);
91+
while ($fill > 0 && $this->in->available()) {
92+
$chunk= $this->in->read($fill);
93+
$this->buffer->write($chunk);
94+
$fill-= strlen($chunk);
95+
}
96+
$fill && $this->end= $this->buffer->tell();
97+
}
98+
99+
$this->buffer->seek(min($this->end ?? $this->buffer->size(), $position), SEEK_SET);
100+
}
101+
102+
/** @return int */
103+
public function tell() { return $this->buffer->tell(); }
104+
105+
/** @return void */
106+
public function close() {
107+
$this->buffer->close();
108+
}
109+
110+
/** Ensures close() is called */
111+
public function __destruct() {
112+
$this->close();
113+
}
114+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
<?php namespace io\unittest;
2+
3+
use io\streams\{SpooledInputStream, MemoryInputStream, Streams};
4+
use io\{TempFile, Folder, Path, IOException};
5+
use lang\Environment;
6+
use test\{Assert, Expect, Test, Values};
7+
8+
class SpooledInputStreamTest {
9+
const BYTES= 'Test success';
10+
11+
/** @param ?string|io.Path|io.Folder|io.File $temp */
12+
private function newFixture($temp= null): SpooledInputStream {
13+
return new SpooledInputStream(new MemoryInputStream(self::BYTES), $temp);
14+
}
15+
16+
#[Test]
17+
public function can_create() {
18+
$this->newFixture();
19+
}
20+
21+
#[Test]
22+
public function reading() {
23+
$stream= $this->newFixture();
24+
25+
Assert::equals(0, $stream->tell());
26+
Assert::equals(strlen(self::BYTES), $stream->available());
27+
Assert::equals(self::BYTES, Streams::readAll($stream));
28+
}
29+
30+
#[Test]
31+
public function reading_after_seeking() {
32+
$stream= $this->newFixture();
33+
$stream->seek(5);
34+
$stream->seek(0);
35+
36+
Assert::equals(0, $stream->tell());
37+
Assert::equals(strlen(self::BYTES), $stream->available());
38+
Assert::equals(self::BYTES, Streams::readAll($stream));
39+
}
40+
41+
#[Test, Values([0, 1, 5])]
42+
public function seeking_forward($offset) {
43+
$stream= $this->newFixture();
44+
$stream->seek($offset, SEEK_SET);
45+
46+
Assert::equals($offset, $stream->tell());
47+
Assert::equals(strlen(self::BYTES) - $offset, $stream->available());
48+
Assert::equals(substr(self::BYTES, $offset), Streams::readAll($stream));
49+
}
50+
51+
#[Test, Values([0, -1, -5])]
52+
public function seeking_to_end($offset) {
53+
$stream= $this->newFixture();
54+
$stream->seek($offset, SEEK_END);
55+
56+
Assert::equals(strlen(self::BYTES) + $offset, $stream->tell());
57+
Assert::equals(-$offset, $stream->available());
58+
Assert::equals(substr(self::BYTES, strlen(self::BYTES) + $offset), Streams::readAll($stream));
59+
}
60+
61+
#[Test, Values([0, 1, -1])]
62+
public function seeking_relative($offset) {
63+
$stream= $this->newFixture();
64+
$stream->seek(5, SEEK_SET);
65+
$stream->seek($offset, SEEK_CUR);
66+
67+
Assert::equals(5 + $offset, $stream->tell());
68+
Assert::equals(strlen(self::BYTES) - 5 - $offset, $stream->available());
69+
Assert::equals(substr(self::BYTES, 5 + $offset), Streams::readAll($stream));
70+
}
71+
72+
#[Test, Values([0, 1, 5])]
73+
public function seeking_forward_set($offset) {
74+
$stream= $this->newFixture();
75+
$stream->seek($offset, SEEK_SET);
76+
77+
Assert::equals($offset, $stream->tell());
78+
Assert::equals(strlen(self::BYTES) - $offset, $stream->available());
79+
Assert::equals(substr(self::BYTES, $offset), Streams::readAll($stream));
80+
}
81+
82+
#[Test, Values([0, 1, 5])]
83+
public function seeking_forward_cur($offset) {
84+
$stream= $this->newFixture();
85+
$stream->seek(strlen(self::BYTES), SEEK_SET);
86+
$stream->seek(-$offset, SEEK_CUR);
87+
88+
Assert::equals(strlen(self::BYTES) - $offset, $stream->tell());
89+
Assert::equals($offset, $stream->available());
90+
Assert::equals(substr(self::BYTES, strlen(self::BYTES) - $offset), Streams::readAll($stream));
91+
}
92+
93+
#[Test, Values([0, 1, 5])]
94+
public function seeking_forward_triggers_read($offset) {
95+
$stream= $this->newFixture();
96+
$stream->seek(5, SEEK_SET);
97+
$stream->seek(strlen(self::BYTES) - $offset, SEEK_SET);
98+
99+
Assert::equals(strlen(self::BYTES) - $offset, $stream->tell());
100+
Assert::equals($offset, $stream->available());
101+
Assert::equals(substr(self::BYTES, strlen(self::BYTES) - $offset), Streams::readAll($stream));
102+
}
103+
104+
#[Test, Values([0, 1, 5])]
105+
public function seeking_back_to_offset_after_reading_until_end($offset) {
106+
$stream= $this->newFixture();
107+
while ($stream->available()) {
108+
$stream->read();
109+
}
110+
$stream->seek($offset, SEEK_SET);
111+
112+
Assert::equals($offset, $stream->tell());
113+
Assert::equals(strlen(self::BYTES) - $offset, $stream->available());
114+
Assert::equals(substr(self::BYTES, $offset), Streams::readAll($stream));
115+
}
116+
117+
#[Test, Values([0, -1, -5])]
118+
public function seeking_to_end_after_reading_until_end($offset) {
119+
$stream= $this->newFixture();
120+
while ($stream->available()) {
121+
$stream->read();
122+
}
123+
$stream->seek($offset, SEEK_END);
124+
125+
Assert::equals(strlen(self::BYTES) + $offset, $stream->tell());
126+
Assert::equals(-$offset, $stream->available());
127+
Assert::equals(substr(self::BYTES, strlen(self::BYTES) + $offset), Streams::readAll($stream));
128+
}
129+
130+
#[Test, Values([0, -1, -5])]
131+
public function seeking_to_end_after_seeking_relative($offset) {
132+
$stream= $this->newFixture();
133+
$stream->seek(5, SEEK_SET);
134+
$stream->seek($offset, SEEK_END);
135+
136+
Assert::equals(strlen(self::BYTES) + $offset, $stream->tell());
137+
Assert::equals(-$offset, $stream->available());
138+
Assert::equals(substr(self::BYTES, strlen(self::BYTES) + $offset), Streams::readAll($stream));
139+
}
140+
141+
#[Test]
142+
public function close_can_be_called_twice() {
143+
$stream= $this->newFixture();
144+
145+
$stream->close();
146+
$stream->close();
147+
}
148+
149+
#[Test, Expect(IOException::class), Values([SEEK_SET, SEEK_CUR])]
150+
public function cannot_seek_before_beginning_of_file($whence) {
151+
$this->newFixture()->seek(-1, $whence);
152+
}
153+
154+
#[Test, Expect(IOException::class)]
155+
public function cannot_seek_with_invalid_whence() {
156+
$this->newFixture()->seek(0, 6100);
157+
}
158+
159+
#[Test]
160+
public function position_after_seek_error() {
161+
$stream= $this->newFixture();
162+
$stream->read(4);
163+
164+
Assert::throws(IOException::class, fn() => $stream->seek(-1));
165+
Assert::equals(4, $stream->tell());
166+
}
167+
168+
#[Test]
169+
public function position_after_seek_set_past_end() {
170+
$stream= $this->newFixture();
171+
$stream->seek(strlen(self::BYTES) + 1, SEEK_SET);
172+
173+
Assert::equals(strlen(self::BYTES), $stream->tell());
174+
}
175+
176+
#[Test]
177+
public function position_after_seek_end_past_end() {
178+
$stream= $this->newFixture();
179+
$stream->seek(1, SEEK_END);
180+
181+
Assert::equals(strlen(self::BYTES), $stream->tell());
182+
}
183+
}

0 commit comments

Comments
 (0)