From 678c4d9d53b7f5d6ddc13f2ec979fe8825254c85 Mon Sep 17 00:00:00 2001 From: Karl Gray Date: Sun, 30 Aug 2026 23:59:48 +0100 Subject: [PATCH 1/2] fix: strip the table prefix correctly when rebuilding a SQLite3 table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite cannot alter or drop a column in place, so `SQLite3\Table` rebuilds the whole table and recreates its foreign keys from the metadata it collected. That metadata holds prefixed table names, and `createTable()` stripped the prefix with `trim($name, $this->db->DBPrefix)`. `trim()`'s second argument is a set of characters, not a prefix. It removes any of those characters from either end of the string, repeatedly, so with the prefix `db_` it turns `db_bandit_fk` into `andit_fk` — the leading `b` of the table's own name is eaten as well, and characters are stripped from the end too. The rebuilt table's foreign keys then reference tables that do not exist. Nothing fails at that point, because foreign key enforcement is off for the duration of the rebuild; the error surfaces at the next write to the referenced table, naming a table that appears nowhere in the schema. Whether a given table is affected depends on which characters its name happens to begin and end with, so most tables come through untouched. Strip the prefix the way `fromTable()` in the same class already does. The existing tests could not catch this: `AlterTableTest` builds its own connection without a `DBPrefix`, and the damage is invisible until the constraint is used. The regression test therefore sets a prefix and names the referenced table so that it begins with a character the prefix also contains, which is what makes the bug reproduce. --- system/Database/SQLite3/Table.php | 12 +++- .../Database/Live/SQLite3/AlterTableTest.php | 62 +++++++++++++++++++ user_guide_src/source/changelogs/v4.7.5.rst | 1 + 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/system/Database/SQLite3/Table.php b/system/Database/SQLite3/Table.php index 1884d8c3ed44..83913fd786a9 100644 --- a/system/Database/SQLite3/Table.php +++ b/system/Database/SQLite3/Table.php @@ -333,9 +333,19 @@ protected function createTable() } foreach ($this->foreignKeys as $foreignKey) { + $foreignTableName = $foreignKey->foreign_table_name; + $prefix = $this->db->DBPrefix; + + // trim() takes a set of characters, not a prefix, so it would also + // strip those characters from the end of the name, and from the + // start beyond the prefix itself. + if ($prefix !== '' && str_starts_with($foreignTableName, $prefix)) { + $foreignTableName = substr($foreignTableName, strlen($prefix)); + } + $this->forge->addForeignKey( $foreignKey->column_name, - trim($foreignKey->foreign_table_name, $this->db->DBPrefix), + $foreignTableName, $foreignKey->foreign_column_name, ); } diff --git a/tests/system/Database/Live/SQLite3/AlterTableTest.php b/tests/system/Database/Live/SQLite3/AlterTableTest.php index a68d42ca1440..a7ba8ad13f03 100644 --- a/tests/system/Database/Live/SQLite3/AlterTableTest.php +++ b/tests/system/Database/Live/SQLite3/AlterTableTest.php @@ -271,6 +271,68 @@ public function testProcessCopiesOldData(): void $this->seeInDatabase('foo', ['email' => 'funkalicious@example.com']); } + public function testDropColumnKeepsForeignKeyTableNameWhenPrefixIsSet(): void + { + $config = [ + 'DBDriver' => 'SQLite3', + 'database' => ':memory:', + 'DBDebug' => true, + 'DBPrefix' => 'db_', + ]; + + $db = db_connect($config, false); + $this->assertInstanceOf(Connection::class, $db); + + $forge = Database::forge($db); + $this->assertInstanceOf(Forge::class, $forge); + + // The referenced table must begin with a character that also appears in + // the prefix, or the prefix stripping cannot damage its name. + $forge->addField([ + 'id' => [ + 'type' => 'integer', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + ]); + $forge->addPrimaryKey('id'); + $forge->createTable('bandit_fk'); + + $forge->addField([ + 'id' => [ + 'type' => 'integer', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'key_id' => [ + 'type' => 'integer', + 'constraint' => 11, + 'unsigned' => true, + ], + 'name' => [ + 'type' => 'varchar', + 'constraint' => 255, + 'null' => true, + ], + ]); + $forge->addPrimaryKey('id'); + $forge->addForeignKey('key_id', 'bandit_fk', 'id'); + $forge->createTable('bandit'); + + // Dropping a column rebuilds the table, recreating its foreign keys. + $this->assertTrue($forge->dropColumn('bandit', 'name')); + + $keys = array_values($db->getForeignKeyData('bandit')); + + $this->assertCount(1, $keys); + $this->assertSame($db->DBPrefix . 'bandit_fk', $keys[0]->foreign_table_name); + + $forge->dropTable('bandit', true); + $forge->dropTable('bandit_fk', true); + } + protected function createTable(string $tableName = 'foo'): void { // Create support table for foreign keys diff --git a/user_guide_src/source/changelogs/v4.7.5.rst b/user_guide_src/source/changelogs/v4.7.5.rst index 47d447dfbbc3..ea56c99af3cd 100644 --- a/user_guide_src/source/changelogs/v4.7.5.rst +++ b/user_guide_src/source/changelogs/v4.7.5.rst @@ -36,6 +36,7 @@ Bugs Fixed - **CLIRequest:** Fixed a bug where ``parseCommand()`` could throw a TypeError when ``argv`` is missing. - **Content Security Policy:** Fixed a bug where empty ``Content-Security-Policy``, ``Content-Security-Policy-Report-Only``, and ``Reporting-Endpoints`` response headers were generated when no corresponding values existed. +- **Database:** Fixed a bug where rebuilding a SQLite3 table (e.g., ``Forge::dropColumn()``, ``Forge::modifyColumn()``, ``Forge::dropForeignKey()`` and ``Forge::dropPrimaryKey()``) corrupted the table names referenced by its foreign keys when ``DBPrefix`` was set. - **Helpers:** Fixed a bug where ``get_dir_file_info()`` returned incomplete entries for subdirectories and missing files instead of omitting them. - **Honeypot:** Fixed a bug where bot detection returned an HTTP 500 response instead of 403 (Forbidden). - **Logger:** Fixed a bug where interpolating a log message with array or non-stringable context values could raise PHP warnings or errors. From fed993cabbf03344a7e5a89c46b9f30b258a74f9 Mon Sep 17 00:00:00 2001 From: Karl Gray Date: Tue, 1 Sep 2026 13:05:30 +0100 Subject: [PATCH 2/2] Update system/Database/SQLite3/Table.php Co-authored-by: Michal Sniatala --- system/Database/SQLite3/Table.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/system/Database/SQLite3/Table.php b/system/Database/SQLite3/Table.php index 83913fd786a9..3737822eaeeb 100644 --- a/system/Database/SQLite3/Table.php +++ b/system/Database/SQLite3/Table.php @@ -336,9 +336,6 @@ protected function createTable() $foreignTableName = $foreignKey->foreign_table_name; $prefix = $this->db->DBPrefix; - // trim() takes a set of characters, not a prefix, so it would also - // strip those characters from the end of the name, and from the - // start beyond the prefix itself. if ($prefix !== '' && str_starts_with($foreignTableName, $prefix)) { $foreignTableName = substr($foreignTableName, strlen($prefix)); }