Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 51 additions & 5 deletions Classes/Core/Testbase.php
Original file line number Diff line number Diff line change
Expand Up @@ -895,7 +895,7 @@ private function truncateAllTablesForMysql(): void
}

/**
* Truncates all PostgreSQL tables and restarts their sequences in one statement.
* Truncates PostgreSQL tables containing rows or changed sequences.
*/
private function truncateAllTablesForPostgres(): void
{
Expand All @@ -906,10 +906,56 @@ private function truncateAllTablesForPostgres(): void
if ($tableNames === []) {
return;
}
$quotedTableNames = array_map($connection->quoteIdentifier(...), $tableNames);
$connection->executeStatement(
'TRUNCATE TABLE ' . implode(', ', $quotedTableNames) . ' RESTART IDENTITY CASCADE'
);

// Preserve TRUNCATE trigger and CASCADE behavior, including for empty tables.
$requiresFullReset = $connection->executeQuery(
"SELECT EXISTS(SELECT 1 FROM pg_trigger WHERE (tgtype & 32) <> 0 AND tgenabled <> 'D')
OR EXISTS(SELECT 1 FROM pg_constraint WHERE contype = 'f')"
)->fetchOne();
if ($requiresFullReset) {
$connection->executeStatement(
'TRUNCATE TABLE ' . implode(', ', array_map($connection->quoteIdentifier(...), $tableNames)) . ' RESTART IDENTITY CASCADE'
);
return;
}

$sequenceRows = $connection->executeQuery(
"SELECT CASE WHEN N.nspname = current_schema() THEN quote_ident(T.relname)
ELSE N.nspname || '.' || quote_ident(T.relname) END AS tablename,
S.oid::regclass::text AS sequencename, Q.seqstart
FROM pg_class S
JOIN pg_depend D ON D.objid = S.oid AND D.classid = 'pg_class'::regclass
JOIN pg_class T ON T.oid = D.refobjid AND D.refclassid = 'pg_class'::regclass
JOIN pg_namespace N ON N.oid = T.relnamespace
JOIN pg_sequence Q ON Q.seqrelid = S.oid
WHERE S.relkind = 'S' AND D.refobjsubid > 0 AND D.deptype IN ('a', 'i')"
)->fetchAllAssociative();
$sequenceChecks = [];
foreach ($sequenceRows as $sequence) {
$sequenceChecks[$sequence['tablename']][] = sprintf(
'EXISTS(SELECT 1 FROM %s WHERE is_called OR last_value <> %s)',
$sequence['sequencename'],
$connection->quote((string)$sequence['seqstart'])
);
}
$queries = [];
foreach ($tableNames as $tableName) {
$checks = array_merge(
['EXISTS(SELECT 1 FROM ' . $connection->quoteIdentifier($tableName) . ')'],
$sequenceChecks[$tableName] ?? []
);
$queries[] = sprintf('SELECT %s AS tablename WHERE %s', $connection->quote($tableName), implode(' OR ', $checks));
}
$tablesToTruncate = [];
// Bound planning depth when a test creates many tables.
foreach (array_chunk($queries, 100) as $chunk) {
array_push($tablesToTruncate, ...$connection->executeQuery(implode(' UNION ALL ', $chunk))->fetchFirstColumn());
}
if ($tablesToTruncate !== []) {
$connection->executeStatement(
'TRUNCATE TABLE ' . implode(', ', array_map($connection->quoteIdentifier(...), $tablesToTruncate)) . ' RESTART IDENTITY CASCADE'
);
}
}

/**
Expand Down
197 changes: 197 additions & 0 deletions Tests/Functional/PostgreSqlDatabaseResetTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
<?php

declare(strict_types=1);

namespace TYPO3\TestingFramework\Tests\Functional;

/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/

use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
use TYPO3\TestingFramework\Core\Testbase;

final class PostgreSqlDatabaseResetTest extends FunctionalTestCase
{
protected function setUp(): void
{
parent::setUp();
if (!$this->get(ConnectionPool::class)->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME)->getDatabasePlatform() instanceof PostgreSQLPlatform) {
self::markTestSkipped('PostgreSQL database reset behavior');
}
}

#[Test]
public function resetLeavesUnusedTablesUntouched(): void
{
$connection = $this->getConnectionPool()->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME);
$connection->executeStatement('CREATE TABLE tx_reset_unused (value INT)');
$connection->executeStatement('CREATE TABLE tx_reset_used (value INT)');
try {
$connection->executeStatement('INSERT INTO tx_reset_used VALUES (1)');
$unusedFile = $connection->executeQuery("SELECT pg_relation_filenode('tx_reset_unused')")->fetchOne();
$usedFile = $connection->executeQuery("SELECT pg_relation_filenode('tx_reset_used')")->fetchOne();

(new Testbase())->initializeTestDatabaseAndTruncateTables();

self::assertSame($unusedFile, $connection->executeQuery("SELECT pg_relation_filenode('tx_reset_unused')")->fetchOne());
self::assertNotSame($usedFile, $connection->executeQuery("SELECT pg_relation_filenode('tx_reset_used')")->fetchOne());
self::assertSame(0, $connection->count('*', 'tx_reset_used', []));
} finally {
$connection->executeStatement('DROP TABLE tx_reset_unused, tx_reset_used');
}
}

public static function sequenceStateDataProvider(): iterable
{
yield 'unused sequence' => ['uid', [], 1];
yield 'populated table' => ['uid', ['INSERT INTO tx_reset_sequence DEFAULT VALUES'], 1];
yield 'deleted row with consumed ID' => ['uid', ['INSERT INTO tx_reset_sequence DEFAULT VALUES', 'DELETE FROM tx_reset_sequence'], 1];
yield 'explicit ID without sequence advance' => ['uid', ['INSERT INTO tx_reset_sequence (uid) VALUES (400)'], 1];
yield 'changed uncalled sequence' => ['uid', ["SELECT setval('tx_reset_sequence_uid_seq', 42, false)"], 1];
yield 'custom column and start' => ['record_id', ['ALTER SEQUENCE tx_reset_sequence_record_id_seq START WITH 13'], 13];
yield 'descending sequence' => ['uid', ['ALTER SEQUENCE tx_reset_sequence_uid_seq MINVALUE -10 MAXVALUE 10 INCREMENT BY -1 START WITH -2 RESTART WITH -2', "SELECT nextval('tx_reset_sequence_uid_seq')"], -2];
}

#[Test]
#[DataProvider('sequenceStateDataProvider')]
public function resetRestoresSequenceStart(string $column, array $statements, int $expectedId): void
{
$connection = $this->getConnectionPool()->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME);
$connection->executeStatement('CREATE TABLE tx_reset_sequence (' . $connection->quoteIdentifier($column) . ' SERIAL PRIMARY KEY)');
try {
foreach ($statements as $statement) {
$connection->executeStatement($statement);
}

(new Testbase())->initializeTestDatabaseAndTruncateTables();

self::assertSame(0, $connection->count('*', 'tx_reset_sequence', []));
self::assertSame($expectedId, $connection->executeQuery('INSERT INTO tx_reset_sequence DEFAULT VALUES RETURNING ' . $connection->quoteIdentifier($column))->fetchOne());
} finally {
$connection->executeStatement('DROP TABLE tx_reset_sequence');
}
}

#[Test]
public function resetRestoresMultipleIdentityColumns(): void
{
$connection = $this->getConnectionPool()->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME);
$connection->executeStatement('CREATE TABLE tx_reset_identity (id BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 5), other BIGINT GENERATED BY DEFAULT AS IDENTITY (START WITH 9))');
try {
$connection->executeStatement('INSERT INTO tx_reset_identity DEFAULT VALUES');
$connection->executeStatement('DELETE FROM tx_reset_identity');

(new Testbase())->initializeTestDatabaseAndTruncateTables();

self::assertSame(['id' => 5, 'other' => 9], $connection->executeQuery('INSERT INTO tx_reset_identity DEFAULT VALUES RETURNING id, other')->fetchAssociative());
} finally {
$connection->executeStatement('DROP TABLE tx_reset_identity');
}
}

#[Test]
public function resetRestoresSequencesWithAnotherCurrentSchema(): void
{
$connection = $this->getConnectionPool()->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME);
$searchPath = $connection->executeQuery('SHOW search_path')->fetchOne();
$connection->executeStatement('CREATE SCHEMA tx_reset_schema');
$connection->executeStatement('CREATE TABLE public.tx_reset_public (uid SERIAL PRIMARY KEY)');
$connection->executeStatement('CREATE TABLE tx_reset_schema.tx_reset_other (uid SERIAL PRIMARY KEY)');
try {
$connection->executeStatement('INSERT INTO public.tx_reset_public DEFAULT VALUES');
$connection->executeStatement('DELETE FROM public.tx_reset_public');
$connection->executeStatement('INSERT INTO tx_reset_schema.tx_reset_other DEFAULT VALUES');
$connection->executeStatement('DELETE FROM tx_reset_schema.tx_reset_other');
$connection->executeStatement('SET search_path TO tx_reset_schema, public');

(new Testbase())->initializeTestDatabaseAndTruncateTables();

self::assertSame(1, $connection->executeQuery('INSERT INTO public.tx_reset_public DEFAULT VALUES RETURNING uid')->fetchOne());
self::assertSame(1, $connection->executeQuery('INSERT INTO tx_reset_schema.tx_reset_other DEFAULT VALUES RETURNING uid')->fetchOne());
} finally {
$connection->executeQuery("SELECT set_config('search_path', ?, false)", [$searchPath]);
$connection->executeStatement('DROP TABLE public.tx_reset_public');
$connection->executeStatement('DROP SCHEMA tx_reset_schema CASCADE');
}
}

#[Test]
public function resetChecksMoreThanOneBatchOfTables(): void
{
$connection = $this->getConnectionPool()->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME);
$tables = [];
try {
for ($index = 0; $index <= 100; ++$index) {
$table = sprintf('tx_reset_batch_%03d', $index);
$connection->executeStatement('CREATE TABLE ' . $table . ' (value INT)');
$tables[] = $table;
}
$connection->executeStatement('INSERT INTO tx_reset_batch_100 VALUES (1)');

(new Testbase())->initializeTestDatabaseAndTruncateTables();

self::assertSame(0, $connection->count('*', 'tx_reset_batch_100', []));
} finally {
if ($tables !== []) {
$connection->executeStatement('DROP TABLE ' . implode(', ', $tables));
}
}
}

#[Test]
public function resetFiresTruncateTriggersOnEmptyTables(): void
{
$connection = $this->getConnectionPool()->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME);
$connection->executeStatement('CREATE TABLE tx_reset_trigger (uid INT)');
$connection->executeStatement('CREATE TABLE tx_reset_audit (message TEXT)');
$connection->executeStatement("CREATE FUNCTION tx_reset_log() RETURNS TRIGGER AS $$ BEGIN INSERT INTO tx_reset_audit VALUES ('fired'); RETURN NULL; END $$ LANGUAGE plpgsql");
try {
$connection->executeStatement('CREATE TRIGGER tx_reset_truncated AFTER TRUNCATE ON tx_reset_trigger FOR EACH STATEMENT EXECUTE FUNCTION tx_reset_log()');

(new Testbase())->initializeTestDatabaseAndTruncateTables();

self::assertSame(['fired'], $connection->select(['message'], 'tx_reset_audit', [])->fetchFirstColumn());
} finally {
$connection->executeStatement('DROP TABLE tx_reset_trigger, tx_reset_audit');
$connection->executeStatement('DROP FUNCTION tx_reset_log()');
}
}

#[Test]
public function resetPreservesForeignKeyCleanupAndResetsUntouchedTables(): void
{
$connection = $this->getConnectionPool()->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME);
$connection->executeStatement('CREATE TABLE tx_reset_parent (uid SERIAL PRIMARY KEY)');
$connection->executeStatement('CREATE TABLE tx_reset_child (uid SERIAL PRIMARY KEY, pid INT REFERENCES tx_reset_parent(uid))');
$connection->executeStatement('CREATE TABLE tx_reset_unused (value INT)');
try {
$connection->executeStatement('INSERT INTO tx_reset_parent DEFAULT VALUES');
$connection->executeStatement('INSERT INTO tx_reset_child (pid) VALUES (1)');
$unusedFile = $connection->executeQuery("SELECT pg_relation_filenode('tx_reset_unused')")->fetchOne();

(new Testbase())->initializeTestDatabaseAndTruncateTables();

self::assertSame(0, $connection->count('*', 'tx_reset_parent', []));
self::assertSame(0, $connection->count('*', 'tx_reset_child', []));
self::assertNotSame($unusedFile, $connection->executeQuery("SELECT pg_relation_filenode('tx_reset_unused')")->fetchOne());
self::assertSame(1, $connection->executeQuery('INSERT INTO tx_reset_parent DEFAULT VALUES RETURNING uid')->fetchOne());
self::assertSame(1, $connection->executeQuery('INSERT INTO tx_reset_child (pid) VALUES (1) RETURNING uid')->fetchOne());
} finally {
$connection->executeStatement('DROP TABLE tx_reset_child, tx_reset_parent, tx_reset_unused');
}
}
}