Skip to content

Commit fa4957f

Browse files
authored
Merge pull request #360 from thekid/feature/blob-api
Add a Blob class as a bridge between string, bytes and streams
2 parents dc7ea53 + 4093c97 commit fa4957f

File tree

5 files changed

+415
-2
lines changed

5 files changed

+415
-2
lines changed

src/main/php/io/Blob.class.php

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<?php namespace io;
2+
3+
use IteratorAggregate, Traversable;
4+
use io\streams\{InputStream, IterableInputStream, FilterInputStream, Streams};
5+
use lang\{Value, IllegalArgumentException};
6+
use util\{Bytes, Objects};
7+
8+
/** @test io.unittest.BlobTest */
9+
class Blob implements IteratorAggregate, Value {
10+
private $parts;
11+
private $iterator= null;
12+
public $meta= [];
13+
14+
/**
15+
* Creates a new blob from parts
16+
*
17+
* @param iterable|string|util.Bytes|io.streams.InputStream $parts
18+
* @param [:var] $meta
19+
* @throws lang.IllegalArgumentException
20+
*/
21+
public function __construct($parts= [], array $meta= []) {
22+
if ($parts instanceof InputStream) {
23+
$this->iterator= function() {
24+
static $started= false;
25+
26+
return (function() use(&$started) {
27+
$started ? Streams::seek($this->parts, 0) : $started= true;
28+
while ($this->parts->available()) {
29+
yield $this->parts->read();
30+
}
31+
})();
32+
};
33+
} else if ($parts instanceof Bytes || is_string($parts)) {
34+
$this->iterator= fn() => (function() { yield (string)$this->parts; })();
35+
} else if (is_iterable($parts)) {
36+
$this->iterator= fn() => (function() {
37+
foreach ($this->parts as $part) {
38+
yield (string)$part;
39+
}
40+
})();
41+
} else {
42+
throw new IllegalArgumentException(sprintf(
43+
'Expected iterable|string|util.Bytes|io.streams.InputStream, have %s',
44+
typeof($parts)
45+
));
46+
}
47+
48+
$this->parts= $parts;
49+
$this->meta= $meta;
50+
}
51+
52+
/** @return iterable */
53+
public function getIterator(): Traversable { return ($this->iterator)(); }
54+
55+
/** @return util.Bytes */
56+
public function bytes() {
57+
return $this->parts instanceof Bytes
58+
? $this->parts
59+
: new Bytes(...($this->iterator)())
60+
;
61+
}
62+
63+
/** @return io.streams.InputStream */
64+
public function stream() {
65+
return $this->parts instanceof InputStream
66+
? $this->parts
67+
: new IterableInputStream(($this->iterator)())
68+
;
69+
}
70+
71+
/** Creates a new blob with the given encoding applied */
72+
public function encoded(string $encoding, ?callable $filter= null): self {
73+
$meta= $this->meta;
74+
$meta['encoding']??= [];
75+
$meta['encoding'][]= $encoding;
76+
return new self(new FilterInputStream($this->stream(), $filter ?? $encoding), $meta);
77+
}
78+
79+
/** @return iterable */
80+
public function slices(int $size= 8192) {
81+
$it= ($this->iterator)();
82+
$it->rewind();
83+
while ($it->valid()) {
84+
$slice= $it->current();
85+
$length= strlen($slice);
86+
$offset= 0;
87+
88+
while ($length < $size) {
89+
$it->next();
90+
$slice.= $it->current();
91+
if (!$it->valid()) break;
92+
}
93+
94+
while ($length - $offset > $size) {
95+
yield substr($slice, $offset, $size);
96+
$offset+= $size;
97+
}
98+
99+
yield $offset ? substr($slice, $offset) : $slice;
100+
$it->next();
101+
}
102+
}
103+
104+
/** @return string */
105+
public function hashCode() { return 'B'.Objects::hashOf($this->parts); }
106+
107+
/** @return string */
108+
public function toString() { return nameof($this).'('.Objects::stringOf($this->parts).')'; }
109+
110+
/**
111+
* Comparison
112+
*
113+
* @param var $value
114+
* @return int
115+
*/
116+
public function compareTo($value) {
117+
return $value instanceof self
118+
? Objects::compare($this->parts, $value->parts)
119+
: 1
120+
;
121+
}
122+
123+
/** @return string */
124+
public function __toString() {
125+
$bytes= '';
126+
foreach (($this->iterator)() as $chunk) {
127+
$bytes.= $chunk;
128+
}
129+
return $bytes;
130+
}
131+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php namespace io\streams;
2+
3+
use Iterator, Closure, Traversable;
4+
use lang\IllegalArgumentException;
5+
6+
/** @test io.unittest.IterableInputStreamTest */
7+
class IterableInputStream implements InputStream {
8+
private $iterator;
9+
private $buffer= null;
10+
11+
/** @param iterable|function(): Iterator $input */
12+
public function __construct($input) {
13+
if ($input instanceof Iterator) {
14+
$this->iterator= $input;
15+
} else if ($input instanceof Closure) {
16+
$this->iterator= cast($input(), Iterator::class);
17+
} else if (is_iterable($input)) {
18+
$this->iterator= (function() use($input) { yield from $input; })();
19+
} else {
20+
throw new IllegalArgumentException('Expected iterable|function(): Iterator, have '.typeof($input));
21+
}
22+
$this->iterator->rewind();
23+
}
24+
25+
/** @return int */
26+
public function available() {
27+
if (null !== $this->buffer) {
28+
return strlen($this->buffer);
29+
} else if ($this->iterator->valid()) {
30+
$this->buffer= $this->iterator->current();
31+
$this->iterator->next();
32+
return strlen($this->buffer);
33+
} else {
34+
return 0;
35+
}
36+
}
37+
38+
/**
39+
* Reads up to a given limit
40+
*
41+
* @param int $limit
42+
* @return string
43+
*/
44+
public function read($limit= 8192) {
45+
if (null !== $this->buffer) {
46+
// Continue draining the buffer
47+
} else if ($this->iterator->valid()) {
48+
$this->buffer= $this->iterator->current();
49+
$this->iterator->next();
50+
} else {
51+
return '';
52+
}
53+
54+
$chunk= substr($this->buffer, 0, $limit);
55+
$this->buffer= $limit >= strlen($this->buffer) ? null : substr($this->buffer, $limit);
56+
return $chunk;
57+
}
58+
59+
/** @return void */
60+
public function close() {
61+
// NOOP
62+
}
63+
}

src/main/php/io/streams/Streams.class.php

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
<?php namespace io\streams;
22

3-
use io\FileNotFoundException;
4-
use io\IOException;
3+
use io\{FileNotFoundException, OperationNotSupportedException, IOException};
54

65
/**
76
* Wraps I/O streams into PHP streams
@@ -134,6 +133,23 @@ public static function readAll(InputStream $s) {
134133
return $r;
135134
}
136135

136+
/**
137+
* Read an IOElements' contents completely into a buffer in a single call.
138+
*
139+
* @param io.streams.InputStream $s
140+
* @param int $offset
141+
* @param int $whence default SEEK_SET (one of SEEK_[SET|CUR|END])
142+
* @return void
143+
* @throws io.IOException
144+
*/
145+
public static function seek($s, $offset, $whence= SEEK_SET) {
146+
if ($s instanceof Seekable) {
147+
$s->seek($offset, $whence);
148+
} else {
149+
throw new OperationNotSupportedException('Cannot seek instances of '.nameof($s));
150+
}
151+
}
152+
137153
/**
138154
* Callback for fopen
139155
*
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php namespace io\unittest;
2+
3+
use ArrayObject;
4+
use io\streams\{MemoryInputStream, InputStream};
5+
use io\{Blob, OperationNotSupportedException};
6+
use lang\IllegalArgumentException;
7+
use test\verify\Runtime;
8+
use test\{Assert, Expect, Test, Values};
9+
use util\Bytes;
10+
11+
class BlobTest {
12+
13+
/** @return iterable */
14+
private function cases() {
15+
yield [new Blob(), []];
16+
yield [new Blob('Test'), ['Test']];
17+
yield [new Blob(['Über']), ['Über']];
18+
yield [new Blob([new Blob(['Test']), 'ed']), ['Test', 'ed']];
19+
yield [new Blob(['Test', 'ed']), ['Test', 'ed']];
20+
yield [new Blob((function() { yield 'Test'; yield 'ed'; })()), ['Test', 'ed']];
21+
yield [new Blob(new ArrayObject(['Test', 'ed'])), ['Test', 'ed']];
22+
yield [new Blob(new Bytes('Test')), ['Test']];
23+
yield [new Blob(new MemoryInputStream('Test')), ['Test']];
24+
}
25+
26+
#[Test]
27+
public function can_create() {
28+
new Blob();
29+
}
30+
31+
#[Test, Expect(IllegalArgumentException::class)]
32+
public function not_from_null() {
33+
new Blob(null);
34+
}
35+
36+
#[Test]
37+
public function meta_empty_by_default() {
38+
Assert::equals([], (new Blob('Test'))->meta);
39+
}
40+
41+
#[Test]
42+
public function meta() {
43+
$meta= ['type' => 'text/plain'];
44+
Assert::equals($meta, (new Blob('Test', $meta))->meta);
45+
}
46+
47+
#[Test, Values(from: 'cases')]
48+
public function iteration($fixture, $expected) {
49+
Assert::equals($expected, iterator_to_array($fixture));
50+
}
51+
52+
#[Test, Values(from: 'cases')]
53+
public function bytes($fixture, $expected) {
54+
Assert::equals(new Bytes($expected), $fixture->bytes());
55+
}
56+
57+
#[Test, Values(from: 'cases')]
58+
public function stream($fixture, $expected) {
59+
$stream= $fixture->stream();
60+
$data= [];
61+
while ($stream->available()) {
62+
$data[]= $stream->read();
63+
}
64+
Assert::equals($expected, $data);
65+
}
66+
67+
#[Test, Values(from: 'cases')]
68+
public function string_cast($fixture, $expected) {
69+
Assert::equals(implode('', $expected), (string)$fixture);
70+
}
71+
72+
#[Test, Values([[1, ['T', 'e', 's', 't']], [2, ['Te', 'st']], [3, ['Tes', 't']], [4, ['Test']]])]
73+
public function slices($size, $expected) {
74+
Assert::equals($expected, iterator_to_array((new Blob('Test'))->slices($size)));
75+
}
76+
77+
#[Test]
78+
public function fill_slice() {
79+
Assert::equals(['Test'], iterator_to_array((new Blob(['Te', 'st']))->slices()));
80+
}
81+
82+
#[Test]
83+
public function fetch_slice_twice() {
84+
$fixture= new Blob('Test');
85+
86+
Assert::equals(['Test'], iterator_to_array($fixture->slices()));
87+
Assert::equals(['Test'], iterator_to_array($fixture->slices()));
88+
}
89+
90+
#[Test]
91+
public function cannot_fetch_slices_twice_from_non_seekable() {
92+
$fixture= new Blob(new class() implements InputStream {
93+
private $input= ['Test'];
94+
public function available() { return strlen(current($this->input)); }
95+
public function read($limit= 8192) { return array_shift($this->input); }
96+
public function close() { $this->input= []; }
97+
});
98+
iterator_to_array($fixture->slices());
99+
100+
Assert::throws(OperationNotSupportedException::class, fn() => iterator_to_array($fixture->slices()));
101+
}
102+
103+
/** @see https://bugs.php.net/bug.php?id=77069 */
104+
#[Test, Runtime(php: '>=7.4.14')]
105+
public function base64_encoded() {
106+
$base64= (new Blob('Test'))->encoded('convert.base64-encode');
107+
108+
Assert::equals(['convert.base64-encode'], $base64->meta['encoding']);
109+
Assert::equals('VGVzdA==', (string)$base64);
110+
}
111+
112+
#[Test]
113+
public function custom_encoding() {
114+
$base64= (new Blob('Test'))->encoded('uppercase', fn($chunk) => strtoupper($chunk));
115+
116+
Assert::equals(['uppercase'], $base64->meta['encoding']);
117+
Assert::equals('TEST', (string)$base64);
118+
}
119+
}

0 commit comments

Comments
 (0)