diff --git a/README.md b/README.md index f2e5392..a89bf0e 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,13 @@ Ideally, importing single sheets of csv or excel should be just a matter of chan OpenSpout: fast csv and excel import/export https://github.com/openspout/openspout -League CSV: very fast csv import/export +League CSV: very fast csv import/export. Can read streams. https://github.com/thephpleague/csv PhpSpreadsheet: slow excel (xls and xlsx) and csv import/export, but more features https://github.com/PHPOffice/PhpSpreadsheet -Native php: very fast csv import/export, but limited features +Native php: very fast csv import/export, but limited features. Can read streams. SimpleXLSX: very fast excel import/export https://github.com/shuchkin/simplexlsx @@ -47,7 +47,9 @@ foreach(SpreadCompat::read('myfile.csv') as $row) { } ``` -## Using named arguments +## Configure + +### Using named arguments This package accepts options using ...opts, this means you can freely use named arguments or pass an array. @@ -58,6 +60,16 @@ $data = iterator_to_array(SpreadCompat::read('myfile.csv', assoc: true)); $data = iterator_to_array(SpreadCompat::read('myfile.csv', ...$opts)); ``` +### Using options object + +You can also use the `Options` class that regroups all available options for all adapters. Unsupported options are ignored. + +```php +$options = new Options(); +$options->separator = ";"; +$data = iterator_to_array(SpreadCompat::read('myfile.csv', $options)); +``` + ## Worksheets This package supports only 1 worksheet, as it is meant to be able to replace csv by xlsx or vice versa diff --git a/src/Common/Configure.php b/src/Common/Configure.php new file mode 100644 index 0000000..2a6f390 --- /dev/null +++ b/src/Common/Configure.php @@ -0,0 +1,30 @@ + $v) { + // It's an Options class + if ($v instanceof Options) { + $this->configure(...get_object_vars($v)); + return; + } + // If you passed the array directly instead of ...$opts + if (is_numeric($k)) { + throw new Exception("Invalid key"); + } + // Ignore invalid properties for this adapter + if (!property_exists($this, $k)) { + continue; + } + $this->$k = $v; + } + } +} diff --git a/src/Common/Options.php b/src/Common/Options.php new file mode 100644 index 0000000..2ee021d --- /dev/null +++ b/src/Common/Options.php @@ -0,0 +1,38 @@ +configure(...$opts); + } + } +} diff --git a/src/Csv/CsvAdapter.php b/src/Csv/CsvAdapter.php index d876d66..6858852 100644 --- a/src/Csv/CsvAdapter.php +++ b/src/Csv/CsvAdapter.php @@ -4,11 +4,13 @@ namespace LeKoala\SpreadCompat\Csv; -use Exception; +use LeKoala\SpreadCompat\Common\Configure; use LeKoala\SpreadCompat\SpreadInterface; abstract class CsvAdapter implements SpreadInterface { + use Configure; + public string $separator = ","; public string $enclosure = "\""; public string $escape = "\\"; @@ -37,17 +39,4 @@ public function getOutputEncoding(): ?string } return null; } - - public function configure(...$opts): void - { - foreach ($opts as $k => $v) { - if (is_numeric($k)) { - throw new Exception("Invalid key"); - } - if (!property_exists($this, $k)) { - continue; - } - $this->$k = $v; - } - } } diff --git a/src/Csv/League.php b/src/Csv/League.php index 0959526..1a484b9 100644 --- a/src/Csv/League.php +++ b/src/Csv/League.php @@ -52,6 +52,13 @@ public function readString( yield from $this->read($csv); } + public function readStream($stream, ...$opts): Generator + { + $this->configure(...$opts); + $csv = Reader::createFromStream($stream); + yield from $this->read($csv); + } + public function readFile( string $filename, ...$opts diff --git a/src/Csv/Native.php b/src/Csv/Native.php index 960576d..c2e76b4 100644 --- a/src/Csv/Native.php +++ b/src/Csv/Native.php @@ -43,15 +43,12 @@ public function readString( } } - public function readFile( - string $filename, - ...$opts - ): Generator { + /** + * @param resource $stream + */ + public function readStream($stream, ...$opts): Generator + { $this->configure(...$opts); - $stream = fopen($filename, 'r'); - if (!$stream) { - throw new RuntimeException("Failed to read stream"); - } if (fgets($stream, 4) !== self::BOM) { // bom not found - rewind pointer to start of file. rewind($stream); @@ -73,6 +70,17 @@ public function readFile( } } + public function readFile( + string $filename, + ...$opts + ): Generator { + $stream = fopen($filename, 'r'); + if (!$stream) { + throw new RuntimeException("Failed to read stream"); + } + yield from $this->readStream($stream, ...$opts); + } + /** * @param resource $stream * @param iterable $data diff --git a/src/Csv/OpenSpout.php b/src/Csv/OpenSpout.php index c4dde0e..b1df259 100644 --- a/src/Csv/OpenSpout.php +++ b/src/Csv/OpenSpout.php @@ -4,6 +4,7 @@ namespace LeKoala\SpreadCompat\Csv; +use Exception; use Generator; use LeKoala\SpreadCompat\SpreadCompat; use RuntimeException; @@ -56,6 +57,12 @@ public function readFile( $reader->close(); } + public function readStream(): Generator + { + //@link https://github.com/openspout/openspout/issues/71 + throw new Exception("OpenSpout doesn't support streams"); + } + protected function getWriter(): Writer { $options = new \OpenSpout\Writer\CSV\Options(); diff --git a/src/Csv/PhpSpreadsheet.php b/src/Csv/PhpSpreadsheet.php index 80b8076..5dfa2de 100644 --- a/src/Csv/PhpSpreadsheet.php +++ b/src/Csv/PhpSpreadsheet.php @@ -5,7 +5,6 @@ namespace LeKoala\SpreadCompat\Csv; use Generator; -use RuntimeException; use LeKoala\SpreadCompat\SpreadCompat; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Reader\Csv as ReaderCsv; diff --git a/src/SpreadCompat.php b/src/SpreadCompat.php index aee6124..176fdc9 100644 --- a/src/SpreadCompat.php +++ b/src/SpreadCompat.php @@ -6,6 +6,7 @@ use Exception; use Generator; +use InvalidArgumentException; use RuntimeException; /** @@ -27,11 +28,8 @@ class SpreadCompat public static ?string $preferredCsvAdapter = null; public static ?string $preferredXslxAdapter = null; - public static function getAdapterName(string $filename, string $ext = null): string + public static function getAdapterName(string $ext): string { - if ($ext === null) { - $ext = pathinfo($filename, PATHINFO_EXTENSION); - } $ext = strtolower($ext); // Legacy xls is only supported by PhpSpreadsheet @@ -39,11 +37,10 @@ public static function getAdapterName(string $filename, string $ext = null): str if (class_exists(\PhpOffice\PhpSpreadsheet\Worksheet\Row::class)) { return self::PHP_SPREADSHEET; } - throw new Exception("No adapter found for xls"); } if ($ext === self::EXT_CSV) { - if (self::$preferredCsvAdapter) { + if (self::$preferredCsvAdapter !== null) { return self::$preferredCsvAdapter; } if (class_exists(\League\Csv\Reader::class)) { @@ -58,7 +55,7 @@ public static function getAdapterName(string $filename, string $ext = null): str } if ($ext === self::EXT_XLSX) { - if (self::$preferredXslxAdapter) { + if (self::$preferredXslxAdapter !== null) { return self::$preferredXslxAdapter; } if (class_exists(\Shuchkin\SimpleXLSX::class)) { @@ -75,20 +72,25 @@ public static function getAdapterName(string $filename, string $ext = null): str throw new Exception("No adapter found for $ext"); } - public static function getAdapter(string $filename, string $ext = null): SpreadInterface + public static function getAdapter(string $ext): SpreadInterface { - if ($ext === null) { - $ext = pathinfo($filename, PATHINFO_EXTENSION); - } $ext = ucfirst($ext); - $name = self::getAdapterName($filename, $ext); + $name = self::getAdapterName($ext); $class = 'LeKoala\\SpreadCompat\\' . $ext . '\\' . $name; if (!class_exists($class)) { - throw new Exception("Invalid adapter $class for $filename"); + throw new Exception("Invalid adapter $class"); } return new ($class); } + public static function getAdapterForFile(string $filename, string $ext = null): SpreadInterface + { + if ($ext === null) { + $ext = pathinfo($filename, PATHINFO_EXTENSION); + } + return self::getAdapter($ext); + } + public static function getTempFilename(): string { $file = tmpfile(); @@ -104,10 +106,25 @@ public static function read( ...$opts ): Generator { $ext = $opts['extension'] ?? null; - if ($ext) { - unset($opts['extension']); + return static::getAdapterForFile($filename, $ext)->readFile($filename, ...$opts); + } + + public static function readString( + string $contents, + string $ext = null, + ...$opts + ): Generator { + $ext = $opts['extension'] ?? $ext; + if ($ext === null) { + // Try to determine based on contents + // Expect csv to be all printable chars + if (ctype_print($contents)) { + $ext = self::EXT_CSV; + } else { + $ext = self::EXT_XLSX; + } } - return static::getAdapter($filename, $ext)->readFile($filename, ...$opts); + return static::getAdapter($ext)->readString($contents, ...$opts); } public static function write( @@ -116,10 +133,7 @@ public static function write( ...$opts ): bool { $ext = $opts['extension'] ?? null; - if ($ext) { - unset($opts['extension']); - } - return static::getAdapter($filename, $ext)->writeFile($data, $filename, ...$opts); + return static::getAdapterForFile($filename, $ext)->writeFile($data, $filename, ...$opts); } public static function output( @@ -128,9 +142,6 @@ public static function output( ...$opts ): void { $ext = $opts['extension'] ?? null; - if ($ext) { - unset($opts['extension']); - } - static::getAdapter($filename, $ext)->output($data, $filename, ...$opts); + static::getAdapterForFile($filename, $ext)->output($data, $filename, ...$opts); } } diff --git a/src/Xls/XlsAdapter.php b/src/Xls/XlsAdapter.php index 8d8bd77..2238744 100644 --- a/src/Xls/XlsAdapter.php +++ b/src/Xls/XlsAdapter.php @@ -4,26 +4,15 @@ namespace LeKoala\SpreadCompat\Xls; -use Exception; +use LeKoala\SpreadCompat\Common\Configure; use LeKoala\SpreadCompat\SpreadInterface; abstract class XlsAdapter implements SpreadInterface { + use Configure; + public bool $assoc = false; public ?string $creator = null; public ?string $autofilter = null; public ?string $freezePane = null; - - public function configure(...$opts): void - { - foreach ($opts as $k => $v) { - if (is_numeric($k)) { - throw new Exception("Invalid key"); - } - if (!property_exists($this, $k)) { - continue; - } - $this->$k = $v; - } - } } diff --git a/src/Xlsx/XlsxAdapter.php b/src/Xlsx/XlsxAdapter.php index c2ad7e6..1271724 100644 --- a/src/Xlsx/XlsxAdapter.php +++ b/src/Xlsx/XlsxAdapter.php @@ -4,26 +4,15 @@ namespace LeKoala\SpreadCompat\Xlsx; -use Exception; +use LeKoala\SpreadCompat\Common\Configure; use LeKoala\SpreadCompat\SpreadInterface; abstract class XlsxAdapter implements SpreadInterface { + use Configure; + public bool $assoc = false; public ?string $creator = null; public ?string $autofilter = null; public ?string $freezePane = null; - - public function configure(...$opts): void - { - foreach ($opts as $k => $v) { - if (is_numeric($k)) { - throw new Exception("Invalid key"); - } - if (!property_exists($this, $k)) { - continue; - } - $this->$k = $v; - } - } } diff --git a/tests/SpreadCompatCommonTest.php b/tests/SpreadCompatCommonTest.php new file mode 100644 index 0000000..09637cb --- /dev/null +++ b/tests/SpreadCompatCommonTest.php @@ -0,0 +1,51 @@ +separator = ";"; + $csv = new Native(); + $csv->configure($options); + $this->assertEquals(";", $csv->separator); + } + + public function testCanUseNamedArguments() + { + $csv = new Native(); + $csv->configure(separator: ";"); + $this->assertEquals(";", $csv->separator); + } + + public function testCanUseArray() + { + $csv = new Native(); + $csv->configure(...["separator" => ";"]); + $this->assertEquals(";", $csv->separator); + } + + public function testCanReadContents() + { + // Extension is determined based on content + $csvBom = file_get_contents(__DIR__ . '/data/bom.csv'); + $csvBomData = SpreadCompat::readString($csvBom); + $csv = file_get_contents(__DIR__ . '/data/basic.csv'); + $csvData = SpreadCompat::readString($csv); + $xlsx = file_get_contents(__DIR__ . '/data/basic.xlsx'); + $xlsxData = SpreadCompat::readString($xlsx); + + $this->assertEquals($csvData, $csvBomData); + $this->assertEquals($csvData, $xlsxData); + $this->assertEquals($csvBomData, $xlsxData); + } +} diff --git a/tests/SpreadCompatCsvTest.php b/tests/SpreadCompatCsvTest.php index 2626ed8..7fe4cca 100644 --- a/tests/SpreadCompatCsvTest.php +++ b/tests/SpreadCompatCsvTest.php @@ -144,6 +144,13 @@ public function testLeagueCanWriteCsv() ]); $expected = file_get_contents(__DIR__ . '/data/separator.csv'); $this->assertEquals($expected, $string); + + $native = new League(); + $stream = fopen(__DIR__ . '/data/headers.csv', 'r'); + $data = iterator_to_array($native->readStream($stream, assoc: true)); + $this->assertCount(1, $data); + $this->assertCount(3, $data[0]); + $this->assertArrayHasKey('email', $data[0]); } public function testNativeCanReadCsv() @@ -172,6 +179,13 @@ public function testNativeCanReadCsv() $this->assertCount(1, $data); $this->assertCount(3, $data[0]); $this->assertArrayHasKey('email', $data[0]); + + $native = new Native(); + $stream = fopen(__DIR__ . '/data/headers.csv', 'r'); + $data = iterator_to_array($native->readStream($stream, assoc: true)); + $this->assertCount(1, $data); + $this->assertCount(3, $data[0]); + $this->assertArrayHasKey('email', $data[0]); } public function testNativeCanWriteCsv() diff --git a/tests/SpreadCompatXlsTest.php b/tests/SpreadCompatXlsTest.php index cc2097a..854f019 100644 --- a/tests/SpreadCompatXlsTest.php +++ b/tests/SpreadCompatXlsTest.php @@ -12,7 +12,7 @@ class SpreadCompatXlsTest extends TestCase { public function testFacadeCanReadXls() { - $adapter = SpreadCompat::getAdapterName(__DIR__ . '/data/basic.xls'); + $adapter = SpreadCompat::getAdapterName('xls'); $this->assertEquals("PhpSpreadsheet", $adapter); $data = iterator_to_array(SpreadCompat::read(__DIR__ . '/data/basic.xls'));