From b6f55de8042eada75aca0c3c17df25b70b2a0eff Mon Sep 17 00:00:00 2001 From: Wouter Wolters Date: Sat, 14 Nov 2026 01:53:14 +0100 Subject: [PATCH 1/3] [BUGFIX] Preserve raw strings in database snapshots Snapshot rows already contain database values. Exporting a Doctrine JSON type causes the raw JSON string to be encoded again during restoration, changing objects and arrays into strings. Other types can likewise receive values already converted by the database. Use the string type for columns whose binding type is STRING so restoration preserves their stored representation. Keep the existing handling for other binding types. Cover JSON, datetime and text conversion in the unit suite and verify an export/import round trip, including JSON scalars and SQL NULL. Document running the functional regressions through a Core checkout. Releases: main --- CONTRIBUTING.rst | 11 ++- .../Snapshot/DatabaseAccessor.php | 4 + Tests/Functional/DatabaseAccessorTest.php | 83 +++++++++++++++++++ .../Snapshot/DatabaseAccessorTest.php | 70 ++++++++++++++++ 4 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 Tests/Functional/DatabaseAccessorTest.php create mode 100644 Tests/Unit/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessorTest.php diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 414f24da..b35a8417 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -78,7 +78,16 @@ To provide a change to the testing framework, you have to ./Build/Scripts/runTests.sh -s functional - and confirming that the test runs have the same result as without the + Run the framework's own functional regression tests from that Core + checkout as well, using a server database to exercise snapshot export + and restoration: + + .. code-block:: bash + + ./Build/Scripts/runTests.sh -s functional -d postgres -- \ + vendor/typo3/testing-framework/Tests/Functional/ + + Confirm that the Core test runs have the same result as without the changes, 4. push the change to your fork by diff --git a/Classes/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessor.php b/Classes/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessor.php index f6729e85..11d00266 100644 --- a/Classes/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessor.php +++ b/Classes/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessor.php @@ -118,6 +118,10 @@ function (string $columnName) use ($table) { // @todo Simplify this after Doctine DBAL v3 support can be dropped. $type = $table->getColumn($columnName)->getType(); $bindingType = $type->getBindingType(); + // Snapshot strings already contain database values, including encoded JSON. + if ($bindingType === ParameterType::STRING) { + return Types::STRING; + } return (enum_exists(ParameterType::class)) ? Type::lookupName($type) : $type->getBindingType(); diff --git a/Tests/Functional/DatabaseAccessorTest.php b/Tests/Functional/DatabaseAccessorTest.php new file mode 100644 index 00000000..52934726 --- /dev/null +++ b/Tests/Functional/DatabaseAccessorTest.php @@ -0,0 +1,83 @@ +get(ConnectionPool::class)->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME); + $schemaManager = $connection->createSchemaManager(); + $table = new Table('tx_testingframework_snapshot'); + $table->addColumn('uid', Types::INTEGER, ['autoincrement' => true]); + $table->addColumn('payload', Types::JSON, ['notnull' => false]); + $table->addColumn('created_at', Types::DATETIME_MUTABLE); + $table->addColumn('enabled', Types::BOOLEAN); + $table->setPrimaryKey(['uid']); + $schemaManager->createTable($table); + + try { + $jsonValues = [ + '{"name":"Alice","nested":{"items":[1,true,null]}}', + '[{"name":"Alice"},{"name":"Bob"}]', + '"quoted \\"text\\""', + '42', + '1.5', + 'true', + 'false', + 'null', + '{}', + '[]', + null, + ]; + foreach ($jsonValues as $index => $json) { + $connection->insert($table->getName(), [ + 'uid' => $index + 1, + 'payload' => $json, + 'created_at' => '2026-11-14 12:34:56', + 'enabled' => $index % 2 === 0, + ], [ + 'uid' => ParameterType::INTEGER, + 'payload' => $json === null ? ParameterType::NULL : ParameterType::STRING, + 'created_at' => ParameterType::STRING, + 'enabled' => ParameterType::BOOLEAN, + ]); + } + $expected = $connection->select(['*'], $table->getName(), [], [], ['uid' => 'ASC'])->fetchAllAssociative(); + self::assertCount(count($jsonValues), $expected); + $subject = new DatabaseAccessor($connection); + $snapshot = json_decode(json_encode($subject->export(), JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR); + $connection->executeStatement('DELETE FROM ' . $connection->quoteIdentifier($table->getName())); + + $subject->import($snapshot); + + self::assertSame($expected, $connection->select(['*'], $table->getName(), [], [], ['uid' => 'ASC'])->fetchAllAssociative()); + } finally { + $schemaManager->dropTable($table->getName()); + } + } +} diff --git a/Tests/Unit/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessorTest.php b/Tests/Unit/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessorTest.php new file mode 100644 index 00000000..dc24bbe6 --- /dev/null +++ b/Tests/Unit/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessorTest.php @@ -0,0 +1,70 @@ + [Types::JSON, '{"value":[1,true,null]}']; + yield 'database datetime' => [Types::DATETIME_MUTABLE, '2026-11-14 12:34:56']; + yield 'plain text' => [Types::TEXT, 'unchanged']; + } + + #[Test] + #[DataProvider('databaseValuesDataProvider')] + public function exportedStringsCanBeRestoredWithoutConversion(string $type, string $value): void + { + $table = new Table('tx_testingframework_snapshot'); + $table->addColumn('payload', $type); + $schemaManager = self::createStub(AbstractSchemaManager::class); + $schemaManager->method('listTables')->willReturn([$table]); + $result = self::createStub(Result::class); + $result->method('fetchAllAssociative')->willReturn([['payload' => $value]]); + $queryBuilder = self::createStub(QueryBuilder::class); + $queryBuilder->method('getRestrictions')->willReturn(new DefaultRestrictionContainer()); + $queryBuilder->method('select')->willReturnSelf(); + $queryBuilder->method('from')->willReturnSelf(); + $queryBuilder->method('executeQuery')->willReturn($result); + $connection = self::createStub(Connection::class); + $connection->method('createSchemaManager')->willReturn($schemaManager); + $connection->method('createQueryBuilder')->willReturn($queryBuilder); + + $export = (new DatabaseAccessor($connection))->export(); + $restoredValue = Type::getType($export[0]['columns']['payload'])->convertToDatabaseValue( + $export[0]['items'][0][0], + new PostgreSQLPlatform() + ); + + self::assertSame($value, $restoredValue); + } +} From 6cb8a26d5acf2a63867abae7a419c4f2be990317 Mon Sep 17 00:00:00 2001 From: Wouter Wolters Date: Sat, 14 Nov 2026 13:46:58 +0100 Subject: [PATCH 2/3] [BUGFIX] Stub snapshot query restrictions in unit tests Avoid constructing default query restrictions, which require TCA schema services on TYPO3 14.3. The snapshot regression only needs a restriction container that accepts removing all restrictions. --- .../Framework/DataHandling/Snapshot/DatabaseAccessorTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Unit/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessorTest.php b/Tests/Unit/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessorTest.php index dc24bbe6..866eba46 100644 --- a/Tests/Unit/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessorTest.php +++ b/Tests/Unit/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessorTest.php @@ -51,7 +51,7 @@ public function exportedStringsCanBeRestoredWithoutConversion(string $type, stri $result = self::createStub(Result::class); $result->method('fetchAllAssociative')->willReturn([['payload' => $value]]); $queryBuilder = self::createStub(QueryBuilder::class); - $queryBuilder->method('getRestrictions')->willReturn(new DefaultRestrictionContainer()); + $queryBuilder->method('getRestrictions')->willReturn(self::createStub(DefaultRestrictionContainer::class)); $queryBuilder->method('select')->willReturnSelf(); $queryBuilder->method('from')->willReturnSelf(); $queryBuilder->method('executeQuery')->willReturn($result); From 585631401729bcb72d373a64c9707766bc82deef Mon Sep 17 00:00:00 2001 From: Wouter Wolters Date: Sun, 15 Nov 2026 11:28:32 +0100 Subject: [PATCH 3/3] [TASK] Clarify snapshot regression coverage Assert the exported column types and raw values directly instead of performing Doctrine conversions in the unit test. Cover JSON, datetime and text as string-bound types and retain an integer control case. Document that DatabaseAccessor regression tests also run on SQLite; they do not require DatabaseSnapshot's server-database code path. --- CONTRIBUTING.rst | 6 +++--- .../Snapshot/DatabaseAccessorTest.php | 19 +++++++------------ 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index b35a8417..f23b5375 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -79,12 +79,12 @@ To provide a change to the testing framework, you have to ./Build/Scripts/runTests.sh -s functional Run the framework's own functional regression tests from that Core - checkout as well, using a server database to exercise snapshot export - and restoration: + checkout as well. They test the snapshot export and import of + ``DatabaseAccessor`` and run on every database, SQLite included: .. code-block:: bash - ./Build/Scripts/runTests.sh -s functional -d postgres -- \ + ./Build/Scripts/runTests.sh -s functional -d sqlite -- \ vendor/typo3/testing-framework/Tests/Functional/ Confirm that the Core test runs have the same result as without the diff --git a/Tests/Unit/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessorTest.php b/Tests/Unit/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessorTest.php index 866eba46..138a0a35 100644 --- a/Tests/Unit/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessorTest.php +++ b/Tests/Unit/Core/Functional/Framework/DataHandling/Snapshot/DatabaseAccessorTest.php @@ -17,11 +17,9 @@ namespace TYPO3\TestingFramework\Tests\Unit\Core\Functional\Framework\DataHandling\Snapshot; -use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\DBAL\Result; use Doctrine\DBAL\Schema\AbstractSchemaManager; use Doctrine\DBAL\Schema\Table; -use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\Types; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -35,14 +33,15 @@ final class DatabaseAccessorTest extends UnitTestCase { public static function databaseValuesDataProvider(): iterable { - yield 'encoded JSON' => [Types::JSON, '{"value":[1,true,null]}']; - yield 'database datetime' => [Types::DATETIME_MUTABLE, '2026-11-14 12:34:56']; - yield 'plain text' => [Types::TEXT, 'unchanged']; + yield 'encoded JSON' => [Types::JSON, '{"value":[1,true,null]}', Types::STRING]; + yield 'database datetime' => [Types::DATETIME_MUTABLE, '2026-11-14 12:34:56', Types::STRING]; + yield 'plain text' => [Types::TEXT, 'unchanged', Types::STRING]; + yield 'integer keeps its type' => [Types::INTEGER, '42', Types::INTEGER]; } #[Test] #[DataProvider('databaseValuesDataProvider')] - public function exportedStringsCanBeRestoredWithoutConversion(string $type, string $value): void + public function exportPreservesValuesAndNormalizesStringBoundTypes(string $type, string $value, string $expectedType): void { $table = new Table('tx_testingframework_snapshot'); $table->addColumn('payload', $type); @@ -60,11 +59,7 @@ public function exportedStringsCanBeRestoredWithoutConversion(string $type, stri $connection->method('createQueryBuilder')->willReturn($queryBuilder); $export = (new DatabaseAccessor($connection))->export(); - $restoredValue = Type::getType($export[0]['columns']['payload'])->convertToDatabaseValue( - $export[0]['items'][0][0], - new PostgreSQLPlatform() - ); - - self::assertSame($value, $restoredValue); + self::assertSame(['payload' => $expectedType], $export[0]['columns']); + self::assertSame([[$value]], $export[0]['items']); } }