From cf998030ee17794b6779f492921ec30be9906c53 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 07:41:36 +0000 Subject: [PATCH] Let the 2.0.0 update SQL run again after an update which stopped halfway If an update stops in the middle of sql/updates/2.0.0.sql - converting a large failed login table to utf8mb4 can take longer than a request may - the schema version stays at 1.4.3 and the next attempt runs the file from the start. Its first statement, ADD COLUMN attempts, then fails ("Duplicate column name"), so every further attempt stopped there too and the site could not be updated any more. The ADD COLUMN and ADD INDEX statements, which MySQL can't make conditional, are marked CAN FAIL (supported by Joomla's installer since 4.2); everything else in the file already works on tables it changed before. A unit test checks that every non-repeatable statement in the update files from 2.0.0 on is marked, and an integration test runs the update through Joomla's installer on tables which already have all its changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AFJFMMUpch4LGexEiXN8Nc --- sql/updates/2.0.0.sql | 20 +++++-- tests/Integration/UpdateSqlTest.php | 64 +++++++++++++++++++++ tests/Unit/UpdateSqlTest.php | 86 +++++++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 tests/Integration/UpdateSqlTest.php create mode 100644 tests/Unit/UpdateSqlTest.php diff --git a/sql/updates/2.0.0.sql b/sql/updates/2.0.0.sql index 1ba4186..08726c5 100755 --- a/sql/updates/2.0.0.sql +++ b/sql/updates/2.0.0.sql @@ -1,10 +1,18 @@ -- Update DB schema to version 2.0.0 +-- If an update stops halfway (e.g. a timeout converting a large table), the +-- schema version stays at the previous one, and the next attempt runs this +-- file again from the start: every statement has to work on tables which +-- already have its change. Statements which can't be written that way in +-- MySQL (ADD COLUMN, ADD INDEX) are marked "CAN FAIL" for Joomla's installer +-- (Joomla 4.2+), which then goes on if they fail. Checked by +-- tests/Unit/UpdateSqlTest.php and tests/Integration/UpdateSqlTest.php. + -- number of requests rejected because of a block, and the time of the -- latest one (issue #219); existing blocks start with no data -ALTER TABLE `#__bfstop_bannedip` ADD COLUMN attempts int unsigned NOT NULL DEFAULT 0; +ALTER TABLE `#__bfstop_bannedip` ADD COLUMN attempts int unsigned NOT NULL DEFAULT 0 /** CAN FAIL **/; -ALTER TABLE `#__bfstop_bannedip` ADD COLUMN last_attempt datetime NULL DEFAULT NULL; +ALTER TABLE `#__bfstop_bannedip` ADD COLUMN last_attempt datetime NULL DEFAULT NULL /** CAN FAIL **/; -- use utf8mb4 like Joomla's own tables; with utf8 (3-byte), a failed login @@ -18,7 +26,7 @@ ALTER TABLE `#__bfstop_unblock` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4 ALTER TABLE `#__bfstop_unblock_token` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- the user an unblock link was sent to, to limit the links per user -ALTER TABLE `#__bfstop_unblock_token` ADD COLUMN username varchar(150) NULL DEFAULT NULL; +ALTER TABLE `#__bfstop_unblock_token` ADD COLUMN username varchar(150) NULL DEFAULT NULL /** CAN FAIL **/; ALTER TABLE `#__bfstop_allowlist` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; @@ -60,10 +68,10 @@ INSERT INTO `#__bfstop_username_stats` (username, attempts, first_attempt, last_ ON DUPLICATE KEY UPDATE attempts=attempts; -- speeds up the per-username (account-level) throttle query -ALTER TABLE `#__bfstop_failedlogin` ADD INDEX username_logtime (username, logtime); +ALTER TABLE `#__bfstop_failedlogin` ADD INDEX username_logtime (username, logtime) /** CAN FAIL **/; -- every request looks up the visitor's address in the block and allow list; -- addresses and networks are stored in canonical form (the plugin brings -- existing entries into it once, IpHelper::canonicalEntry()) -ALTER TABLE `#__bfstop_bannedip` ADD INDEX ipaddress (ipaddress); -ALTER TABLE `#__bfstop_allowlist` ADD INDEX ipaddress (ipaddress); +ALTER TABLE `#__bfstop_bannedip` ADD INDEX ipaddress (ipaddress) /** CAN FAIL **/; +ALTER TABLE `#__bfstop_allowlist` ADD INDEX ipaddress (ipaddress) /** CAN FAIL **/; diff --git a/tests/Integration/UpdateSqlTest.php b/tests/Integration/UpdateSqlTest.php new file mode 100644 index 0000000..bd39c08 --- /dev/null +++ b/tests/Integration/UpdateSqlTest.php @@ -0,0 +1,64 @@ +queryValue('SELECT version_id FROM #__schemas WHERE extension_id='.(int) $extensionId); + } + + public function testUpdateCanRunAgainOnTablesItAlreadyChanged() + { + if ($this->db->getServerType() !== 'mysql') + { + $this->markTestSkipped('PostgreSQL is supported from 2.0.0 on, there is no update SQL for it yet'); + } + $extensionId = (int) $this->queryValue("SELECT extension_id FROM #__extensions WHERE type='plugin' AND element='bfstop'"); + $original = $this->schemaVersion($extensionId); + $this->assertNotNull($original); + try + { + $this->db->setQuery("UPDATE #__schemas SET version_id='1.4.3' WHERE extension_id=".$extensionId); + $this->db->execute(); + + $root = dirname(__DIR__, 2); + $manifest = simplexml_load_file($root.'/bfstop.xml'); + $installer = new Installer(); + $installer->setDatabase($this->db); + $installer->setPath('extension_root', $root); + $result = $installer->parseSchemaUpdates($manifest->update->schemas, $extensionId); + + $this->assertNotFalse($result, 'an update SQL statement failed, see the "Update" log of the Joomla site'); + $versions = array_map(function ($file) { return basename($file, '.sql'); }, + glob($root.'/sql/updates/*.sql')); + usort($versions, 'version_compare'); + $this->assertSame(end($versions), $this->schemaVersion($extensionId)); + } + finally + { + $this->db->setQuery('UPDATE #__schemas SET version_id='.$this->db->quote($original). + ' WHERE extension_id='.$extensionId); + $this->db->execute(); + } + } +} diff --git a/tests/Unit/UpdateSqlTest.php b/tests/Unit/UpdateSqlTest.php new file mode 100644 index 0000000..31b1c15 --- /dev/null +++ b/tests/Unit/UpdateSqlTest.php @@ -0,0 +1,86 @@ +=')) + { + $files[basename($file)] = array($file); + } + } + return $files; + } + + private static function statements($file) + { + $sql = preg_replace('/^\s*--.*$/m', '', file_get_contents($file)); + return array_filter(array_map('trim', explode(';', $sql)), 'strlen'); + } + + #[DataProvider('updateFiles')] + public function testStatementsWhichCanOnlyRunOnceAreMarkedCanFail($file) + { + foreach (self::statements($file) as $statement) + { + if (preg_match(self::NotRepeatable, $statement)) + { + $this->assertStringEndsWith(self::Marker, $statement, + basename($file).": fails if run again, mark it CAN FAIL: $statement"); + } + } + } + + #[DataProvider('updateFiles')] + public function testTablesAreCreatedOnlyIfTheyDontExist($file) + { + foreach (self::statements($file) as $statement) + { + if (preg_match('/^CREATE\s+TABLE\b/i', $statement)) + { + $this->assertMatchesRegularExpression('/^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i', $statement, + basename($file)); + } + } + } + + public function testTheMarkerIsWhereJoomlaLooksForIt() + { + // Installer::parseSchemaUpdates() only recognises it right before the + // semicolon which ends the statement + $marked = 0; + foreach (self::updateFiles() as list($file)) + { + $sql = preg_replace('/^\s*--.*$/m', '', file_get_contents($file)); + $marked += substr_count($sql, self::Marker); + $this->assertSame(substr_count($sql, self::Marker), substr_count($sql, ' '.self::Marker.';'), basename($file)); + } + $this->assertGreaterThan(0, $marked); + } +}