From d3ee983537e9ea77cf9b379d271412d5fbc50c79 Mon Sep 17 00:00:00 2001 From: Wouter Wolters Date: Sun, 15 Nov 2026 00:01:45 +0100 Subject: [PATCH] [TASK] Skip unchanged PostgreSQL tables during database reset Only truncate tables containing rows or changed owned sequences. Preserve sequence starts for serial and identity columns, including empty tables whose IDs have already been consumed. Keep the full reset for databases with foreign keys or enabled truncate triggers. Bound table checks to 100 SELECT branches and resolve sequence owners against the current schema search path. Add functional coverage for untouched tables, sequence state, multiple identity columns, schema changes, query batches and both fallbacks. Repeated PostgreSQL 17 benchmarks improve WorkspacesModify by 8.3% and RootlineUtility by 15.7%, with identical tests and assertions. --- Classes/Core/Testbase.php | 56 ++++- .../PostgreSqlDatabaseResetTest.php | 197 ++++++++++++++++++ 2 files changed, 248 insertions(+), 5 deletions(-) create mode 100644 Tests/Functional/PostgreSqlDatabaseResetTest.php 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'); + } + } +}