Skip to content

Commit

Permalink
Allow Spreadsheet Serialization
Browse files Browse the repository at this point in the history
Fix #4324. Serialization was explicity forbidden by PR #3199. This was in response to several issues, and concern that the Spreadsheet object contained non-serializable properties. This PR restores the ability to serialize a spreadsheet. Json serialization remains unsupported.

Fix #1757, closed in Nov. 2023 but just reopened. At the time, Cell property `formulaAttributes` was stored as a SimpleXmlElement. Dynamic arrays PR #3962 defined that property as `null|array<string, string>` in the doc block. However, it left the formal Php type for the property as `mixed`. This PR changes the formal type to `?array`.

Fix #1741, closed in Dec. 2020 but just reopened. Calculation property `referenceHelper` was defined as static, and, since static properties don't take part in serialization, this caused a problem after unserialization. There are at least 3 trivial ways to deal with this - make it an instance property, reinitialize it when unserialized using a wakeup method, or remove the property altogether. This PR uses the last of those 3.

Calculation does have other static properties. Almost all of these deal with locale. So serialize/unserialize might wind up using a default locale when non-default is desired (but not necessarily required). If that is a problem for end-users, it will be a new one, and I will work on a solution if and when the time comes.

Static property `returnArrayAsType` is potentially problematic. However, instance property `instanceArrayReturnType` is the preferred method of handling this, and using that will avoid any problems.

Issue #932 also dealt with serialization. I do not have the wherewithal to investigate that issue. If it is not solved by this and the earlier PR's, I will have to leave it to others to re-raise it.

Spreadsheet `copy` is now simplified to use serialize followed by unserialize. Formal tests are added. In addition, I have made a number of informal tests on very complicated spreadsheets, and it has performed correctly for all of them.
  • Loading branch information
oleibman committed Jan 26, 2025
1 parent fb757cf commit 8af9963
Show file tree
Hide file tree
Showing 7 changed files with 117 additions and 58 deletions.
19 changes: 8 additions & 11 deletions src/PhpSpreadsheet/Calculation/Calculation.php
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,6 @@ class Calculation
*/
public ?string $formulaError = null;

/**
* Reference Helper.
*/
private static ReferenceHelper $referenceHelper;

/**
* An array of the nested cell references accessed by the calculation engine, used for the debug log.
*/
Expand Down Expand Up @@ -2890,7 +2885,6 @@ public function __construct(?Spreadsheet $spreadsheet = null)
$this->cyclicReferenceStack = new CyclicReferenceStack();
$this->debugLog = new Logger($this->cyclicReferenceStack);
$this->branchPruner = new BranchPruner($this->branchPruningEnabled);
self::$referenceHelper = ReferenceHelper::getInstance();
}

private static function loadLocales(): void
Expand Down Expand Up @@ -5732,11 +5726,14 @@ private function evaluateDefinedName(Cell $cell, DefinedName $namedRange, Worksh
$recursiveCalculationCellAddress = $recursiveCalculationCell->getCoordinate();

// Adjust relative references in ranges and formulae so that we execute the calculation for the correct rows and columns
$definedNameValue = self::$referenceHelper->updateFormulaReferencesAnyWorksheet(
$definedNameValue,
Coordinate::columnIndexFromString($cell->getColumn()) - 1,
$cell->getRow() - 1
);
$definedNameValue = ReferenceHelper::getInstance()
->updateFormulaReferencesAnyWorksheet(
$definedNameValue,
Coordinate::columnIndexFromString(
$cell->getColumn()
) - 1,
$cell->getRow() - 1
);

$this->debugLog->writeDebugLog('Value adjusted for relative references is %s', $definedNameValue);

Expand Down
2 changes: 1 addition & 1 deletion src/PhpSpreadsheet/Cell/Cell.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class Cell implements Stringable
*
* @var null|array<string, string>
*/
private mixed $formulaAttributes = null;
private ?array $formulaAttributes = null;

private IgnoredErrors $ignoredErrors;

Expand Down
23 changes: 1 addition & 22 deletions src/PhpSpreadsheet/Spreadsheet.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,12 @@
use PhpOffice\PhpSpreadsheet\Cell\IValueBinder;
use PhpOffice\PhpSpreadsheet\Document\Properties;
use PhpOffice\PhpSpreadsheet\Document\Security;
use PhpOffice\PhpSpreadsheet\Reader\Xlsx as XlsxReader;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Shared\File;
use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
use PhpOffice\PhpSpreadsheet\Style\Style;
use PhpOffice\PhpSpreadsheet\Worksheet\Iterator;
use PhpOffice\PhpSpreadsheet\Worksheet\Table;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx as XlsxWriter;

class Spreadsheet implements JsonSerializable
{
Expand Down Expand Up @@ -1042,17 +1039,7 @@ public function getWorksheetIterator(): Iterator
*/
public function copy(): self
{
$filename = File::temporaryFilename();
$writer = new XlsxWriter($this);
$writer->setIncludeCharts(true);
$writer->save($filename);

$reader = new XlsxReader();
$reader->setIncludeCharts(true);
$reloadedSpreadsheet = $reader->load($filename);
unlink($filename);

return $reloadedSpreadsheet;
return unserialize(serialize($this));
}

public function __clone()
Expand Down Expand Up @@ -1516,14 +1503,6 @@ public function reevaluateAutoFilters(bool $resetToMax): void
}
}

/**
* @throws Exception
*/
public function __serialize(): array
{
throw new Exception('Spreadsheet objects cannot be serialized');
}

/**
* @throws Exception
*/
Expand Down
1 change: 0 additions & 1 deletion src/PhpSpreadsheet/Worksheet/Worksheet.php
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,6 @@ public function __destruct()
public function __wakeup(): void
{
$this->hash = spl_object_id($this);
$this->parent = null;
}

/**
Expand Down
96 changes: 96 additions & 0 deletions tests/PhpSpreadsheetTests/SpreadsheetSerializeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

namespace PhpOffice\PhpSpreadsheetTests;

use PhpOffice\PhpSpreadsheet\Exception as SpreadsheetException;
use PhpOffice\PhpSpreadsheet\Helper\Sample;
use PhpOffice\PhpSpreadsheet\NamedRange;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PHPUnit\Framework\Attributes;
use PHPUnit\Framework\TestCase;

class SpreadsheetSerializeTest extends TestCase
{
private ?Spreadsheet $spreadsheet = null;

protected function tearDown(): void
{
if ($this->spreadsheet !== null) {
$this->spreadsheet->disconnectWorksheets();
$this->spreadsheet = null;
}
}

public function testSerialize(): void
{
$this->spreadsheet = new Spreadsheet();
$sheet = $this->spreadsheet->getActiveSheet();
$sheet->getCell('A1')->setValue(10);

$serialized = serialize($this->spreadsheet);
$newSpreadsheet = unserialize($serialized);
self::assertInstanceOf(Spreadsheet::class, $newSpreadsheet);
self::assertNotSame($this->spreadsheet, $newSpreadsheet);
$newSheet = $newSpreadsheet->getActiveSheet();
self::assertSame(10, $newSheet->getCell('A1')->getValue());
$newSpreadsheet->disconnectWorksheets();
}

public function testNotJsonEncodable(): void
{
$this->spreadsheet = new Spreadsheet();

$this->expectException(SpreadsheetException::class);
$this->expectExceptionMessage('Spreadsheet objects cannot be json encoded');
json_encode($this->spreadsheet);
}

/**
* These tests are a bit weird.
* If prepareSerialize and readSerialize are run in the same
* process, the latter's assertions will always succeed.
* So to demonstrate that the
* problem is solved, they need to run in separate processes.
* But then they can't share the file name. So we need to send
* the file to a semi-hard-coded destination.
*/
private static function getTempFileName(): string
{
$helper = new Sample();

return $helper->getTemporaryFolder() . '/spreadsheet.serialize.test.txt';
}

public function testPrepareSerialize(): void
{
$this->spreadsheet = new Spreadsheet();
$sheet = $this->spreadsheet->getActiveSheet();
$this->spreadsheet->addNamedRange(new NamedRange('summedcells', $sheet, '$A$1:$A$5'));
$sheet->setCellValue('A1', 1);
$sheet->setCellValue('A2', 2);
$sheet->setCellValue('A3', 3);
$sheet->setCellValue('A4', 4);
$sheet->setCellValue('A5', 5);
$sheet->setCellValue('C1', '=SUM(summedcells)');
$ser = serialize($this->spreadsheet);
$this->spreadsheet->disconnectWorksheets();
$outputFileName = self::getTempFileName();
self::assertNotFalse(
file_put_contents($outputFileName, $ser)
);
}

#[Attributes\RunInSeparateProcess]
public function testReadSerialize(): void
{
$inputFileName = self::getTempFileName();
$ser = (string) file_get_contents($inputFileName);
unlink($inputFileName);
$this->spreadsheet = unserialize($ser);
$sheet = $this->spreadsheet->getActiveSheet();
self::assertSame('=SUM(summedcells)', $sheet->getCell('C1')->getValue());
self::assertSame(15, $sheet->getCell('C1')->getCalculatedValue());
}
}
18 changes: 0 additions & 18 deletions tests/PhpSpreadsheetTests/SpreadsheetTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -291,22 +291,4 @@ public function testAddExternalRowDimensionStyles(): void
self::assertEquals($countXfs + $index, $sheet3->getCell('A2')->getXfIndex());
self::assertEquals($countXfs + $index, $sheet3->getRowDimension(2)->getXfIndex());
}

public function testNotSerializable(): void
{
$this->spreadsheet = new Spreadsheet();

$this->expectException(Exception::class);
$this->expectExceptionMessage('Spreadsheet objects cannot be serialized');
serialize($this->spreadsheet);
}

public function testNotJsonEncodable(): void
{
$this->spreadsheet = new Spreadsheet();

$this->expectException(Exception::class);
$this->expectExceptionMessage('Spreadsheet objects cannot be json encoded');
json_encode($this->spreadsheet);
}
}
16 changes: 11 additions & 5 deletions tests/PhpSpreadsheetTests/Worksheet/CloneTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,27 @@ public function testGetCloneIndex(): void

public function testSerialize1(): void
{
// If worksheet attached to spreadsheet, can't serialize it.
$this->expectException(SpreadsheetException::class);
$this->expectExceptionMessage('cannot be serialized');
$spreadsheet = new Spreadsheet();
$sheet1 = $spreadsheet->getActiveSheet();
serialize($sheet1);
$sheet1->getCell('A1')->setValue(10);
$serialized = serialize($sheet1);
$newSheet = unserialize($serialized);
self::assertInstanceOf(Worksheet::class, $newSheet);
self::assertSame(10, $newSheet->getCell('A1')->getValue());
self::assertNotEquals($newSheet->getHashInt(), $sheet1->getHashInt());
self::assertNotNull($newSheet->getParent());
self::assertNotSame($newSheet->getParent(), $sheet1->getParent());
$newSheet->getParent()->disconnectWorksheets();
$spreadsheet->disconnectWorksheets();
}

public function testSerialize2(): void
{
$sheet1 = new Worksheet();
$sheet1->getCell('A1')->setValue(10);
$serialized = serialize($sheet1);
/** @var Worksheet */
$newSheet = unserialize($serialized);
self::assertInstanceOf(Worksheet::class, $newSheet);
self::assertSame(10, $newSheet->getCell('A1')->getValue());
self::assertNotEquals($newSheet->getHashInt(), $sheet1->getHashInt());
}
Expand Down

0 comments on commit 8af9963

Please sign in to comment.