diff --git a/php/src/EasyExcel/Compat/Cell/StringValueBinder.php b/php/src/EasyExcel/Compat/Cell/StringValueBinder.php new file mode 100644 index 0000000..2749833 --- /dev/null +++ b/php/src/EasyExcel/Compat/Cell/StringValueBinder.php @@ -0,0 +1,120 @@ +setIgnoredErrors = $setIgnoredErrors; + + return $this; + } + + public function setNullConversion(bool $suppressConversion = false): self + { + $this->convertNull = $suppressConversion; + + return $this; + } + + public function setBooleanConversion(bool $suppressConversion = false): self + { + $this->convertBoolean = $suppressConversion; + + return $this; + } + + public function getBooleanConversion(): bool + { + return $this->convertBoolean; + } + + public function setNumericConversion(bool $suppressConversion = false): self + { + $this->convertNumeric = $suppressConversion; + + return $this; + } + + public function setFormulaConversion(bool $suppressConversion = false): self + { + $this->convertFormula = $suppressConversion; + + return $this; + } + + public function setConversionForAllValueTypes(bool $suppressConversion = false): self + { + $this->convertNull = $suppressConversion; + $this->convertBoolean = $suppressConversion; + $this->convertNumeric = $suppressConversion; + $this->convertFormula = $suppressConversion; + + return $this; + } + + public function bindValue(Cell $cell, mixed $value): bool + { + if (\is_object($value)) { + return $this->bindObjectValue($cell, $value); + } + if ($value !== null && !\is_scalar($value)) { + throw new Exception('Unable to bind unstringable ' . \gettype($value)); + } + + if ($value === null && $this->convertNull === false) { + $cell->setValueExplicit($value, DataType::TYPE_NULL); + } elseif (\is_bool($value) && $this->convertBoolean === false) { + $cell->setValueExplicit($value, DataType::TYPE_BOOL); + } elseif ((\is_int($value) || \is_float($value)) && $this->convertNumeric === false) { + $cell->setValueExplicit($value, DataType::TYPE_NUMERIC); + } elseif (\is_string($value) && \strlen($value) > 1 && $value[0] === '=' && $this->convertFormula === false && parent::dataTypeForValue($value) === DataType::TYPE_FORMULA) { + $cell->setValueExplicit($value, DataType::TYPE_FORMULA); + } else { + $cell->setValueExplicit((string) $value, DataType::TYPE_STRING); + } + + return true; + } + + protected function bindObjectValue(Cell $cell, object $value): bool + { + if ($value instanceof \DateTimeInterface) { + $cell->setValueExplicit($value->format('Y-m-d H:i:s'), DataType::TYPE_STRING); + } elseif ($value instanceof RichText) { + $cell->setValueExplicit($value, DataType::TYPE_INLINE); + } elseif ($value instanceof \Stringable) { + $cell->setValueExplicit((string) $value, DataType::TYPE_STRING); + } else { + throw new Exception('Unable to bind unstringable object of type ' . $value::class); + } + + return true; + } +} diff --git a/php/src/EasyExcel/Compat/Spreadsheet.php b/php/src/EasyExcel/Compat/Spreadsheet.php index 754bed5..51ea9a8 100644 --- a/php/src/EasyExcel/Compat/Spreadsheet.php +++ b/php/src/EasyExcel/Compat/Spreadsheet.php @@ -4,6 +4,7 @@ namespace EasyExcel\Compat; +use EasyExcel\Compat\Cell\IValueBinder; use EasyExcel\Compat\Worksheet\Worksheet; use EasyExcel\Native; @@ -84,6 +85,30 @@ public function setActiveSheetIndexByName(string $worksheetName): Worksheet throw new Exception("Workbook does not contain sheet: $worksheetName"); } + /** + * PhpSpreadsheet parity: attach an externally constructed (detached) + * Worksheet. The sheet is created natively under its current title and + * inserted at $sheetIndex (appended when null/out of range). + */ + public function addSheet(Worksheet $worksheet, ?int $sheetIndex = null): Worksheet + { + if ($this->getSheetByName($worksheet->getTitle()) !== null) { + throw new Exception( + 'Workbook already contains a worksheet named "' . $worksheet->getTitle() . '"; rename it first' + ); + } + $worksheet->rebindParent($this); + Native::addSheet($this->getHandle(), $worksheet->getTitle()); + if ($sheetIndex === null || $sheetIndex >= \count($this->worksheets)) { + $this->worksheets[] = $worksheet; + } else { + Native::moveSheet($this->getHandle(), $worksheet->getTitle(), $sheetIndex); + \array_splice($this->worksheets, $sheetIndex, 0, [$worksheet]); + } + + return $worksheet; + } + public function createSheet(?int $sheetIndex = null): Worksheet { $name = $this->nextSheetName(); @@ -166,6 +191,21 @@ public function removeSheetByIndex(int $sheetIndex): void \array_splice($this->worksheets, $sheetIndex, 1); } + /** Workbook-level value binder (PhpSpreadsheet >= 2.x); overrides the legacy static Cell binder. */ + private ?IValueBinder $valueBinder = null; + + public function setValueBinder(?IValueBinder $valueBinder): static + { + $this->valueBinder = $valueBinder; + + return $this; + } + + public function getValueBinder(): ?IValueBinder + { + return $this->valueBinder; + } + private ?Document\Properties $properties = null; public function getProperties(): Document\Properties diff --git a/php/src/EasyExcel/Compat/Worksheet/Worksheet.php b/php/src/EasyExcel/Compat/Worksheet/Worksheet.php index 1e62574..6c6d480 100644 --- a/php/src/EasyExcel/Compat/Worksheet/Worksheet.php +++ b/php/src/EasyExcel/Compat/Worksheet/Worksheet.php @@ -8,6 +8,7 @@ use EasyExcel\Compat\Cell\Coordinate; use EasyExcel\Compat\Cell\DataType; use EasyExcel\Compat\Cell\Hyperlink; +use EasyExcel\Compat\Cell\IValueBinder; use EasyExcel\Compat\Comment; use EasyExcel\Compat\RichText\RichText; use EasyExcel\Compat\Exception; @@ -43,15 +44,48 @@ class Worksheet /** @var list List of attached drawings */ private array $drawings = []; - public function __construct(private Spreadsheet $parent, private string $title) + /** + * PhpSpreadsheet parity: a Worksheet may be constructed detached + * (`new Worksheet()`) and attached later via Spreadsheet::addSheet(). + * Until attached, only title get/set work; anything touching the native + * workbook throws (see workbookHandle()). + */ + public function __construct(private ?Spreadsheet $parent = null, private string $title = 'Worksheet') { } - public function getParent(): Spreadsheet + public function getParent(): ?Spreadsheet { return $this->parent; } + /** @internal called by Spreadsheet::addSheet() when attaching a detached sheet */ + public function rebindParent(Spreadsheet $parent): static + { + if ($this->parent === $parent) { + return $this; + } + if ($this->parent !== null) { + throw new Exception( + 'Worksheet "' . $this->title . '" already belongs to a workbook; moving sheets between workbooks is not supported' + ); + } + $this->parent = $parent; + + return $this; + } + + private function workbookHandle(): int + { + if ($this->parent === null) { + throw new Exception( + 'Worksheet "' . $this->title . '" is not attached to a Spreadsheet yet — add it with $spreadsheet->addSheet() first' + ); + } + + return $this->parent->getHandle(); + } + public function getTitle(): string { return $this->title; @@ -62,7 +96,9 @@ public function setTitle(string $title, bool $updateFormulaCellReferences = true if ($title === $this->title) { return $this; } - Native::renameSheet($this->parent->getHandle(), $this->title, $title); + if ($this->parent !== null) { + Native::renameSheet($this->parent->getHandle(), $this->title, $title); + } $this->title = $title; return $this; @@ -91,7 +127,7 @@ public function setCellValue(string|array $coordinate, mixed $value): static return $this; } - if (($binder = Cell::customValueBinder()) !== null) { + if (($binder = $this->activeBinder()) !== null) { [$col, $row] = $this->toIndexes($coordinate); $binder->bindValue(new Cell($this, Coordinate::stringFromColumnIndex($col) . $row), $value); @@ -103,6 +139,16 @@ public function setCellValue(string|array $coordinate, mixed $value): static return $this; } + /** + * Effective custom binder: the workbook-level binder + * (Spreadsheet::setValueBinder(), PhpSpreadsheet >= 2.x) wins over the + * legacy process-wide Cell::setValueBinder(); null means default binding. + */ + private function activeBinder(): ?IValueBinder + { + return $this->parent?->getValueBinder() ?? Cell::customValueBinder(); + } + /** @internal rich-text cell value (wave 4.4): queued via the extension */ public function setRichTextValue(string $coordinate, RichText $richText): void { @@ -111,7 +157,7 @@ public function setRichTextValue(string $coordinate, RichText $richText): void [$col, $row] = Coordinate::indexesFromString($coordinate); $this->bufferCell($row, $col, $richText->getPlainText()); $this->flush(); - Native::setRichText($this->parent->getHandle(), $this->title, $coordinate, $richText->toRunSpecs()); + Native::setRichText($this->workbookHandle(), $this->title, $coordinate, $richText->toRunSpecs()); } public function setCellValueByColumnAndRow(int $columnIndex, int $row, mixed $value): static @@ -151,7 +197,7 @@ public function fromArray(array $source, mixed $nullValue = null, string $startC // a custom binder must see every value: route through the buffered // per-cell path (slower, still batched per 512 rows) - if (($binder = Cell::customValueBinder()) !== null) { + if (($binder = $this->activeBinder()) !== null) { $row = $startRow; foreach ($source as $rowData) { $col = $startCol; @@ -181,13 +227,13 @@ public function fromArray(array $source, mixed $nullValue = null, string $startC $chunk[] = $encoded; ++$row; if (\count($chunk) >= 1024) { - Native::writeRows($this->parent->getHandle(), $this->title, $chunkStart, $startCol, $chunk); + Native::writeRows($this->workbookHandle(), $this->title, $chunkStart, $startCol, $chunk); $chunk = []; $chunkStart = $row; } } if ($chunk !== []) { - Native::writeRows($this->parent->getHandle(), $this->title, $chunkStart, $startCol, $chunk); + Native::writeRows($this->workbookHandle(), $this->title, $chunkStart, $startCol, $chunk); } return $this; @@ -218,13 +264,13 @@ public function readCell(string $coordinate, int $mode): mixed } } - return Native::getCell($this->parent->getHandle(), $this->title, $coordinate, $mode); + return Native::getCell($this->workbookHandle(), $this->title, $coordinate, $mode); } public function toArray(mixed $nullValue = null, bool $calculateFormulas = true, bool $formatData = true, bool $returnCellRef = false): array { $this->flush(); - [$maxRow, $maxCol] = Native::dimensions($this->parent->getHandle(), $this->title); + [$maxRow, $maxCol] = Native::dimensions($this->workbookHandle(), $this->title); return $this->collectRows(1, \max($maxRow, 1), 1, \max($maxCol, 1), $nullValue, $formatData, $returnCellRef, $calculateFormulas); } @@ -243,7 +289,7 @@ public function rangeToArray(string $range, mixed $nullValue = null, bool $calcu */ private function collectRows(int $startRow, int $endRow, int $startCol, ?int $endCol, mixed $nullValue, bool $formatData, bool $returnCellRef, bool $calc = false): array { - $handle = $this->parent->getHandle(); + $handle = $this->workbookHandle(); $filter = $this->parent->getReadFilter(); $out = []; $row = $startRow; @@ -305,7 +351,7 @@ private function collectRows(int $startRow, int $endRow, int $startCol, ?int $en public function getHighestRow(?string $column = null): int { $this->flush(); - [$maxRow] = Native::dimensions($this->parent->getHandle(), $this->title); + [$maxRow] = Native::dimensions($this->workbookHandle(), $this->title); return \max($maxRow, 1); } @@ -313,7 +359,7 @@ public function getHighestRow(?string $column = null): int public function getHighestColumn(?int $row = null): string { $this->flush(); - [, $maxCol] = Native::dimensions($this->parent->getHandle(), $this->title); + [, $maxCol] = Native::dimensions($this->workbookHandle(), $this->title); return Coordinate::stringFromColumnIndex(\max($maxCol, 1)); } @@ -340,7 +386,7 @@ public function mergeCells(string|array $range): static } // no flush: the Go op-log applies merges by coordinates regardless of // when the rows arrive, and flushing here would end streaming early - Native::mergeCells($this->parent->getHandle(), $this->title, $range); + Native::mergeCells($this->workbookHandle(), $this->title, $range); return $this; } @@ -353,7 +399,7 @@ public function unmergeCells(string|array $range): static $range )); } - Native::unmergeCells($this->parent->getHandle(), $this->title, $range); + Native::unmergeCells($this->workbookHandle(), $this->title, $range); return $this; } @@ -363,7 +409,7 @@ public function getMergeCells(): array { $this->flush(); $out = []; - foreach (Native::getMerges($this->parent->getHandle(), $this->title) as $range) { + foreach (Native::getMerges($this->workbookHandle(), $this->title) as $range) { $out[$range] = $range; } @@ -441,7 +487,7 @@ public function setAutoFilter(string|array $range): static )); } $this->autoFilterRange = $range; - Native::autoFilter($this->parent->getHandle(), $this->title, $range); + Native::autoFilter($this->workbookHandle(), $this->title, $range); return $this; } @@ -476,7 +522,7 @@ public function duplicateStyle(Style $style, string $range): static { $spec = $style->describe(); if ($spec !== []) { - Native::applyStyle($this->parent->getHandle(), $this->title, $range, $spec); + Native::applyStyle($this->workbookHandle(), $this->title, $range, $spec); } return $this; @@ -484,7 +530,7 @@ public function duplicateStyle(Style $style, string $range): static public function freezePane(?string $coordinate): static { - Native::freezePanes($this->parent->getHandle(), $this->title, $coordinate ?? ''); + Native::freezePanes($this->workbookHandle(), $this->title, $coordinate ?? ''); return $this; } @@ -516,7 +562,7 @@ public function getCommentByColumnAndRow(int $columnIndex, int $row): Comment public function setHyperlink(string $cellCoordinate, ?Hyperlink $hyperlink): static { Native::setHyperlink( - $this->parent->getHandle(), + $this->workbookHandle(), $this->title, $cellCoordinate, $hyperlink?->getUrl() ?? '', @@ -541,7 +587,7 @@ public function getProtection(): Protection public function insertNewRowBefore(int $before, int $count = 1): static { $this->flush(); - Native::insertRows($this->parent->getHandle(), $this->title, $before, $count); + Native::insertRows($this->workbookHandle(), $this->title, $before, $count); return $this; } @@ -549,7 +595,7 @@ public function insertNewRowBefore(int $before, int $count = 1): static public function removeRow(int $row, int $count = 1): static { $this->flush(); - Native::removeRows($this->parent->getHandle(), $this->title, $row, $count); + Native::removeRows($this->workbookHandle(), $this->title, $row, $count); return $this; } @@ -557,7 +603,7 @@ public function removeRow(int $row, int $count = 1): static public function insertNewColumnBefore(string $before, int $count = 1): static { $this->flush(); - Native::insertCols($this->parent->getHandle(), $this->title, Coordinate::columnIndexFromString($before), $count); + Native::insertCols($this->workbookHandle(), $this->title, Coordinate::columnIndexFromString($before), $count); return $this; } @@ -565,7 +611,7 @@ public function insertNewColumnBefore(string $before, int $count = 1): static public function insertNewColumnBeforeByIndex(int $beforeColumnIndex, int $count = 1): static { $this->flush(); - Native::insertCols($this->parent->getHandle(), $this->title, $beforeColumnIndex, $count); + Native::insertCols($this->workbookHandle(), $this->title, $beforeColumnIndex, $count); return $this; } @@ -573,7 +619,7 @@ public function insertNewColumnBeforeByIndex(int $beforeColumnIndex, int $count public function removeColumn(string $column, int $count = 1): static { $this->flush(); - Native::removeCols($this->parent->getHandle(), $this->title, Coordinate::columnIndexFromString($column), $count); + Native::removeCols($this->workbookHandle(), $this->title, Coordinate::columnIndexFromString($column), $count); return $this; } @@ -581,7 +627,7 @@ public function removeColumn(string $column, int $count = 1): static public function removeColumnByIndex(int $columnIndex, int $count = 1): static { $this->flush(); - Native::removeCols($this->parent->getHandle(), $this->title, $columnIndex, $count); + Native::removeCols($this->workbookHandle(), $this->title, $columnIndex, $count); return $this; } @@ -629,7 +675,7 @@ public function setDataValidation(string $range, ?\EasyExcel\Compat\Cell\DataVal { if ($dataValidation !== null) { Native::setValidation( - $this->parent->getHandle(), + $this->workbookHandle(), $this->title, $range, $dataValidation->toSpec(), @@ -647,7 +693,7 @@ public function setDataValidation(string $range, ?\EasyExcel\Compat\Cell\DataVal */ public function addNativeChart(string $cell, array $spec): static { - Native::addChart($this->parent->getHandle(), $this->title, $cell, $spec); + Native::addChart($this->workbookHandle(), $this->title, $cell, $spec); return $this; } @@ -656,7 +702,7 @@ public function addNativeChart(string $cell, array $spec): static public function addChart(\EasyExcel\Compat\Chart\Chart $chart): static { Native::addChart( - $this->parent->getHandle(), + $this->workbookHandle(), $this->title, $chart->getTopLeftCell(), $chart->buildSpec(), @@ -678,7 +724,7 @@ public function flush(): void $this->bufferedRows = 0; \ksort($buffer); - $handle = $this->parent->getHandle(); + $handle = $this->workbookHandle(); $runRows = []; $runStart = null; $runMinCol = PHP_INT_MAX; diff --git a/php/tests/cases/string-value-binder.php b/php/tests/cases/string-value-binder.php new file mode 100644 index 0000000..bdc0c21 --- /dev/null +++ b/php/tests/cases/string-value-binder.php @@ -0,0 +1,73 @@ + function (): void { + EasyExcelFake::reset(); + $s = new Spreadsheet(); + $s->setValueBinder(new StringValueBinder()); + $sheet = $s->getActiveSheet(); + $sheet->setCellValue('A1', 123.45); + $sheet->setCellValue('B1', true); + $sheet->setCellValue('C1', '=SUM(A1:B1)'); + $sheet->fromArray([[42, 'text']], null, 'A2'); + $sheet->flush(); + + T::same('123.45', $sheet->getCell('A1')->getValue()); + T::same('1', $sheet->getCell('B1')->getValue()); + T::same('=SUM(A1:B1)', $sheet->getCell('C1')->getValue()); + T::same('42', $sheet->getCell('A2')->getValue()); + T::same('text', $sheet->getCell('B2')->getValue()); + }, + + 'string binder: suppressed conversions keep native types' => function (): void { + EasyExcelFake::reset(); + $s = new Spreadsheet(); + $binder = (new StringValueBinder()) + ->setNumericConversion(false) + ->setFormulaConversion(false); + $s->setValueBinder($binder); + $sheet = $s->getActiveSheet(); + $sheet->setCellValue('A1', 123.45); + $sheet->setCellValue('B1', '=SUM(1,2)'); + $sheet->setCellValue('C1', true); + $sheet->flush(); + + T::same(123.45, $sheet->getCell('A1')->getValue()); + T::same('=SUM(1,2)', $sheet->getCell('B1')->getValue()); + T::same('1', $sheet->getCell('C1')->getValue(), 'boolean conversion still on -> string'); + }, + + 'string binder: DateTime binds as formatted string, unstringable throws' => function (): void { + EasyExcelFake::reset(); + $s = new Spreadsheet(); + $s->setValueBinder(new StringValueBinder()); + $sheet = $s->getActiveSheet(); + $sheet->setCellValue('A1', new \DateTime('2025-07-01 08:30:00')); + $sheet->flush(); + + T::same('2025-07-01 08:30:00', $sheet->getCell('A1')->getValue()); + + T::throws(Exception::class, static function () use ($sheet): void { + $sheet->setCellValue('B1', [1, 2, 3]); + }, 'arrays are unstringable'); + }, + + 'string binder: aliases as PhpOffice\\PhpSpreadsheet\\Cell\\StringValueBinder' => function (): void { + EasyExcelFake::reset(); + T::ok( + \class_exists('PhpOffice\\PhpSpreadsheet\\Cell\\StringValueBinder'), + 'StringValueBinder must be part of the aliased Compat surface' + ); + }, +]; diff --git a/php/tests/cases/wave45.php b/php/tests/cases/wave45.php new file mode 100644 index 0000000..cca670e --- /dev/null +++ b/php/tests/cases/wave45.php @@ -0,0 +1,116 @@ +setValueExplicit((string) $value, DataType::TYPE_STRING); + + return true; + } + + return parent::bindValue($cell, $value); + } +} + +return [ + 'wave45: Spreadsheet::setValueBinder routes setCellValue and fromArray' => function (): void { + EasyExcelFake::reset(); + $s = new Spreadsheet(); + T::same(null, $s->getValueBinder(), 'no workbook binder by default'); + T::ok($s->setValueBinder(new AllStringBinder()) === $s, 'fluent'); + + $ws = $s->getActiveSheet(); + $ws->setCellValue('A1', 123); + $ws->fromArray([[456, 7.5]], null, 'A2', true); + $ws->flush(); + + T::same('123', $ws->getCell('A1')->getValue(), 'setCellValue routed through workbook binder'); + T::same('456', $ws->getCell('A2')->getValue(), 'fromArray routed through workbook binder'); + T::same('7.5', $ws->getCell('B2')->getValue()); + + $s->setValueBinder(null); + $ws->setCellValue('A3', 123); + $ws->flush(); + T::same(123.0, $ws->getCell('A3')->getValue(), 'null restores default binding'); + }, + + 'wave45: workbook binder wins over legacy static Cell binder' => function (): void { + EasyExcelFake::reset(); + $prop = new ReflectionProperty(Cell::class, 'valueBinder'); + $prop->setValue(null, null); + Cell::setValueBinder(new DefaultValueBinder()); // static set, default rules + try { + $s = new Spreadsheet(); + $s->setValueBinder(new AllStringBinder()); + $ws = $s->getActiveSheet(); + $ws->setCellValue('A1', 99); + $ws->flush(); + T::same('99', $ws->getCell('A1')->getValue(), 'instance binder overrides static'); + } finally { + $prop->setValue(null, null); + } + }, + + 'wave45: detached Worksheet construct, title, attach via addSheet' => function (): void { + EasyExcelFake::reset(); + $ws = new Worksheet(); + T::same('Worksheet', $ws->getTitle(), 'default title'); + T::same(null, $ws->getParent(), 'detached'); + $ws->setTitle('Region North'); + T::same('Region North', $ws->getTitle(), 'title set locally while detached'); + T::same(0, \count(EasyExcelFake::calls('rename_sheet')), 'no native rename while detached'); + + $s = new Spreadsheet(); + $returned = $s->addSheet($ws); + T::ok($returned === $ws, 'addSheet returns the sheet'); + T::ok($ws->getParent() === $s, 'attached'); + T::same(2, $s->getSheetCount()); + T::ok($s->getSheetByName('Region North') === $ws, 'find by title'); + + $ws->setCellValue('A1', 'hello'); + $ws->flush(); + T::same('hello', $ws->getCell('A1')->getValue(), 'writable after attach'); + }, + + 'wave45: addSheet honours the index and duplicate titles throw' => function (): void { + EasyExcelFake::reset(); + $s = new Spreadsheet(); + $ws = new Worksheet(null, 'First'); + $s->addSheet($ws, 0); + T::same(0, $s->getIndex($ws), 'inserted at requested index'); + T::same(1, \count(EasyExcelFake::calls('move_sheet')), 'native move issued'); + + T::throws(Exception::class, static fn () => $s->addSheet(new Worksheet(null, 'First'))); + + $other = new Spreadsheet(); + T::throws(Exception::class, static fn () => $other->addSheet($ws), 'cross-workbook rebind rejected'); + }, + + 'wave45: detached sheet operations fail loudly' => function (): void { + EasyExcelFake::reset(); + $ws = new Worksheet(null, 'Loose'); + T::throws(Exception::class, static fn () => $ws->setCellValue('A1', 1)->flush()); + }, + + 'wave45: eager aliasing covers instanceof and typed parameters' => function (): void { + // instanceof/type checks never trigger autoload, so these only pass + // when the alias exists up front (eagerAliasCompat at bootstrap). + EasyExcelFake::reset(); + $ws = (new Spreadsheet())->getActiveSheet(); + T::ok($ws instanceof \PhpOffice\PhpSpreadsheet\Worksheet\Worksheet, 'instanceof aliased name'); + $typed = static fn (\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $sheet): string => $sheet->getTitle(); + T::same('Worksheet', $typed($ws), 'typed parameter accepts Compat object'); + }, +];