diff --git a/Classes/Core/Testbase.php b/Classes/Core/Testbase.php index 9eb783f9..202c1393 100644 --- a/Classes/Core/Testbase.php +++ b/Classes/Core/Testbase.php @@ -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 { @@ -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' + ); + } } /** diff --git a/Tests/Functional/PostgreSqlDatabaseResetTest.php b/Tests/Functional/PostgreSqlDatabaseResetTest.php new file mode 100644 index 00000000..eccfc524 --- /dev/null +++ b/Tests/Functional/PostgreSqlDatabaseResetTest.php @@ -0,0 +1,197 @@ +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'); + } + } +}