diff --git a/src/main/php/io/Blob.class.php b/src/main/php/io/Blob.class.php new file mode 100755 index 000000000..a353358fe --- /dev/null +++ b/src/main/php/io/Blob.class.php @@ -0,0 +1,131 @@ +iterator= function() { + static $started= false; + + return (function() use(&$started) { + $started ? Streams::seek($this->parts, 0) : $started= true; + while ($this->parts->available()) { + yield $this->parts->read(); + } + })(); + }; + } else if ($parts instanceof Bytes || is_string($parts)) { + $this->iterator= fn() => (function() { yield (string)$this->parts; })(); + } else if (is_iterable($parts)) { + $this->iterator= fn() => (function() { + foreach ($this->parts as $part) { + yield (string)$part; + } + })(); + } else { + throw new IllegalArgumentException(sprintf( + 'Expected iterable|string|util.Bytes|io.streams.InputStream, have %s', + typeof($parts) + )); + } + + $this->parts= $parts; + $this->meta= $meta; + } + + /** @return iterable */ + public function getIterator(): Traversable { return ($this->iterator)(); } + + /** @return util.Bytes */ + public function bytes() { + return $this->parts instanceof Bytes + ? $this->parts + : new Bytes(...($this->iterator)()) + ; + } + + /** @return io.streams.InputStream */ + public function stream() { + return $this->parts instanceof InputStream + ? $this->parts + : new IterableInputStream(($this->iterator)()) + ; + } + + /** Creates a new blob with the given encoding applied */ + public function encoded(string $encoding, ?callable $filter= null): self { + $meta= $this->meta; + $meta['encoding']??= []; + $meta['encoding'][]= $encoding; + return new self(new FilterInputStream($this->stream(), $filter ?? $encoding), $meta); + } + + /** @return iterable */ + public function slices(int $size= 8192) { + $it= ($this->iterator)(); + $it->rewind(); + while ($it->valid()) { + $slice= $it->current(); + $length= strlen($slice); + $offset= 0; + + while ($length < $size) { + $it->next(); + $slice.= $it->current(); + if (!$it->valid()) break; + } + + while ($length - $offset > $size) { + yield substr($slice, $offset, $size); + $offset+= $size; + } + + yield $offset ? substr($slice, $offset) : $slice; + $it->next(); + } + } + + /** @return string */ + public function hashCode() { return 'B'.Objects::hashOf($this->parts); } + + /** @return string */ + public function toString() { return nameof($this).'('.Objects::stringOf($this->parts).')'; } + + /** + * Comparison + * + * @param var $value + * @return int + */ + public function compareTo($value) { + return $value instanceof self + ? Objects::compare($this->parts, $value->parts) + : 1 + ; + } + + /** @return string */ + public function __toString() { + $bytes= ''; + foreach (($this->iterator)() as $chunk) { + $bytes.= $chunk; + } + return $bytes; + } +} \ No newline at end of file diff --git a/src/main/php/io/streams/IterableInputStream.class.php b/src/main/php/io/streams/IterableInputStream.class.php new file mode 100755 index 000000000..61c189fc9 --- /dev/null +++ b/src/main/php/io/streams/IterableInputStream.class.php @@ -0,0 +1,63 @@ +iterator= $input; + } else if ($input instanceof Closure) { + $this->iterator= cast($input(), Iterator::class); + } else if (is_iterable($input)) { + $this->iterator= (function() use($input) { yield from $input; })(); + } else { + throw new IllegalArgumentException('Expected iterable|function(): Iterator, have '.typeof($input)); + } + $this->iterator->rewind(); + } + + /** @return int */ + public function available() { + if (null !== $this->buffer) { + return strlen($this->buffer); + } else if ($this->iterator->valid()) { + $this->buffer= $this->iterator->current(); + $this->iterator->next(); + return strlen($this->buffer); + } else { + return 0; + } + } + + /** + * Reads up to a given limit + * + * @param int $limit + * @return string + */ + public function read($limit= 8192) { + if (null !== $this->buffer) { + // Continue draining the buffer + } else if ($this->iterator->valid()) { + $this->buffer= $this->iterator->current(); + $this->iterator->next(); + } else { + return ''; + } + + $chunk= substr($this->buffer, 0, $limit); + $this->buffer= $limit >= strlen($this->buffer) ? null : substr($this->buffer, $limit); + return $chunk; + } + + /** @return void */ + public function close() { + // NOOP + } +} \ No newline at end of file diff --git a/src/main/php/io/streams/Streams.class.php b/src/main/php/io/streams/Streams.class.php index c032521fa..93180c12d 100755 --- a/src/main/php/io/streams/Streams.class.php +++ b/src/main/php/io/streams/Streams.class.php @@ -1,7 +1,6 @@ seek($offset, $whence); + } else { + throw new OperationNotSupportedException('Cannot seek instances of '.nameof($s)); + } + } + /** * Callback for fopen * diff --git a/src/test/php/io/unittest/BlobTest.class.php b/src/test/php/io/unittest/BlobTest.class.php new file mode 100755 index 000000000..565d8787d --- /dev/null +++ b/src/test/php/io/unittest/BlobTest.class.php @@ -0,0 +1,119 @@ +meta); + } + + #[Test] + public function meta() { + $meta= ['type' => 'text/plain']; + Assert::equals($meta, (new Blob('Test', $meta))->meta); + } + + #[Test, Values(from: 'cases')] + public function iteration($fixture, $expected) { + Assert::equals($expected, iterator_to_array($fixture)); + } + + #[Test, Values(from: 'cases')] + public function bytes($fixture, $expected) { + Assert::equals(new Bytes($expected), $fixture->bytes()); + } + + #[Test, Values(from: 'cases')] + public function stream($fixture, $expected) { + $stream= $fixture->stream(); + $data= []; + while ($stream->available()) { + $data[]= $stream->read(); + } + Assert::equals($expected, $data); + } + + #[Test, Values(from: 'cases')] + public function string_cast($fixture, $expected) { + Assert::equals(implode('', $expected), (string)$fixture); + } + + #[Test, Values([[1, ['T', 'e', 's', 't']], [2, ['Te', 'st']], [3, ['Tes', 't']], [4, ['Test']]])] + public function slices($size, $expected) { + Assert::equals($expected, iterator_to_array((new Blob('Test'))->slices($size))); + } + + #[Test] + public function fill_slice() { + Assert::equals(['Test'], iterator_to_array((new Blob(['Te', 'st']))->slices())); + } + + #[Test] + public function fetch_slice_twice() { + $fixture= new Blob('Test'); + + Assert::equals(['Test'], iterator_to_array($fixture->slices())); + Assert::equals(['Test'], iterator_to_array($fixture->slices())); + } + + #[Test] + public function cannot_fetch_slices_twice_from_non_seekable() { + $fixture= new Blob(new class() implements InputStream { + private $input= ['Test']; + public function available() { return strlen(current($this->input)); } + public function read($limit= 8192) { return array_shift($this->input); } + public function close() { $this->input= []; } + }); + iterator_to_array($fixture->slices()); + + Assert::throws(OperationNotSupportedException::class, fn() => iterator_to_array($fixture->slices())); + } + + /** @see https://bugs.php.net/bug.php?id=77069 */ + #[Test, Runtime(php: '>=7.4.14')] + public function base64_encoded() { + $base64= (new Blob('Test'))->encoded('convert.base64-encode'); + + Assert::equals(['convert.base64-encode'], $base64->meta['encoding']); + Assert::equals('VGVzdA==', (string)$base64); + } + + #[Test] + public function custom_encoding() { + $base64= (new Blob('Test'))->encoded('uppercase', fn($chunk) => strtoupper($chunk)); + + Assert::equals(['uppercase'], $base64->meta['encoding']); + Assert::equals('TEST', (string)$base64); + } +} \ No newline at end of file diff --git a/src/test/php/io/unittest/IterableInputStreamTest.class.php b/src/test/php/io/unittest/IterableInputStreamTest.class.php new file mode 100755 index 000000000..8e0ff3d4d --- /dev/null +++ b/src/test/php/io/unittest/IterableInputStreamTest.class.php @@ -0,0 +1,84 @@ +available()) { + $data[]= $stream->read($limit); + } + return $data; + } + + #[Test, Expect(IllegalArgumentException::class)] + public function not_from_null() { + new IterableInputStream(null); + } + + #[Test] + public function read_empty() { + Assert::equals([], $this->read([])); + } + + #[Test] + public function read_array() { + Assert::equals(['Test', 'ed'], $this->read(['Test', 'ed'])); + } + + #[Test] + public function read_iterator() { + Assert::equals(['Test', 'ed'], $this->read(new ArrayIterator(['Test', 'ed']))); + } + + #[Test] + public function read_iterator_aggregate() { + Assert::equals(['Test', 'ed'], $this->read(new ArrayObject(['Test', 'ed']))); + } + + #[Test] + public function read_closure() { + Assert::equals(['Test', 'ed'], $this->read(function() { + yield 'Test'; + yield 'ed'; + })); + } + + #[Test] + public function one_chunk_under_limit() { + Assert::equals( + [str_repeat('*', 9)], + $this->read([str_repeat('*', 9)], 10) + ); + } + + #[Test] + public function one_chunk_when_limit_reached() { + Assert::equals( + [str_repeat('*', 10)], + $this->read([str_repeat('*', 10)], 10) + ); + } + + #[Test] + public function new_chunk_when_limit_exceeded() { + Assert::equals( + [str_repeat('*', 10), '*'], + $this->read([str_repeat('*', 11)], 10) + ); + } + + #[Test] + public function chunks_when_limit_exceeded() { + Assert::equals( + [str_repeat('*', 10), str_repeat('*', 10), '*'], + $this->read([str_repeat('*', 21)], 10) + ); + } +} \ No newline at end of file