Jelajahi Sumber

Studio: uninstall modules whose stored vendor was renamed (#5892)

Ticket #4577 changed 'vendor' in modules' config.php from 'BoonEx' to
'UNA INC', but installed sites keep the vendor the module was installed
with: no update rewrites sys_modules.vendor. deleteModule() looked the row
up by vendor and path, so uninstalling such a module ran the uninstall SQL
and removed its language category, but left the sys_modules row and its
file tracks. A second uninstall then failed at "Update languages", because
the category was already gone.

- deleteModule() matches by path and uri, both unique per module.
- removeLanguageByModule() treats a missing category as nothing to remove,
  so sites that already hit this can finish the uninstall.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Yasko 5 hari lalu
induk
melakukan
adb416e0c7

+ 9 - 2
studio/classes/BxDolStudioInstallerQuery.php

@@ -96,10 +96,17 @@ class BxDolStudioInstallerQuery extends BxDolModuleQuery
 
 
     function deleteModule($aConfig)
     function deleteModule($aConfig)
     {
     {
-        $sQuery = $this->prepare("SELECT `id` FROM `sys_modules` WHERE `vendor`=? AND `path`=? LIMIT 1", $aConfig['vendor'], $aConfig['home_dir']);
+        /*
+         * Match by path and uri, both unique per module. The vendor is left out:
+         * the row keeps the vendor it was installed with, and config.php may have
+         * renamed it since (BoonEx -> UNA INC, ticket #4577).
+         */
+        $sQuery = $this->prepare("SELECT `id` FROM `sys_modules` WHERE `path`=? AND `uri`=? LIMIT 1", $aConfig['home_dir'], $aConfig['home_uri']);
         $iId = (int)$this->getOne($sQuery);
         $iId = (int)$this->getOne($sQuery);
+        if(!$iId)
+            return 0;
 
 
-        $sQuery = $this->prepare("DELETE FROM `sys_modules` WHERE `vendor`=? AND `path`=? LIMIT 1", $aConfig['vendor'], $aConfig['home_dir']);
+        $sQuery = $this->prepare("DELETE FROM `sys_modules` WHERE `id`=? LIMIT 1", $iId);
         $this->query($sQuery);
         $this->query($sQuery);
 
 
         $this->deleteModuleTrackFiles($iId);
         $this->deleteModuleTrackFiles($iId);

+ 2 - 1
studio/classes/BxDolStudioLanguagesUtils.php

@@ -338,8 +338,9 @@ class BxDolStudioLanguagesUtils extends BxDolLanguages implements iBxDolSingleto
 
 
         $aCategory = array();
         $aCategory = array();
         $this->oDb->getCategoriesBy(array('type' => 'by_name', 'value' => $aModule['lang_category']), $aCategory, false);
         $this->oDb->getCategoriesBy(array('type' => 'by_name', 'value' => $aModule['lang_category']), $aCategory, false);
+        // No category means its keys are already gone, e.g. an uninstall that stopped half way.
         if(empty($aCategory) || !is_array($aCategory))
         if(empty($aCategory) || !is_array($aCategory))
-            return false;
+            return $bRecompile ? $this->compileLanguage() : true;
 
 
         $bResult = (int)$this->oDb->deleteKeysBy(array('type' => 'by_cat_id', 'value' => $aCategory['id'])) >= 0;
         $bResult = (int)$this->oDb->deleteKeysBy(array('type' => 'by_cat_id', 'value' => $aCategory['id'])) >= 0;
         return $bResult && ($bRecompile ? $this->compileLanguage() : true);
         return $bResult && ($bRecompile ? $this->compileLanguage() : true);

+ 123 - 0
tests/integration/studio/ModuleUninstallVendorTest.php

@@ -0,0 +1,123 @@
+<?php
+
+use PHPUnit\Framework\Attributes\Group;
+
+/**
+ * Uninstalling a module whose sys_modules row was stored with an older vendor.
+ *
+ * Ticket #4577 renamed the vendor in modules' config.php from 'BoonEx' to 'UNA INC',
+ * but installed sites keep the vendor the module was installed with. The uninstall
+ * used to look the row up by vendor and path, so it ran the uninstall SQL and removed
+ * the language category, but left the sys_modules row and its file tracks behind.
+ */
+#[Group('integration')]
+class ModuleUninstallVendorTest extends BxDolIntegrationTestCase
+{
+    private const URI = 'bx_test_vendor_rename';
+    private const PATH = 'boonex/bx_test_vendor_rename/';
+
+    private BxDolStudioInstallerQuery $_oDb;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        $this->_oDb = new BxDolStudioInstallerQuery();
+        $this->cleanup();
+    }
+
+    protected function tearDown(): void
+    {
+        $this->cleanup();
+
+        parent::tearDown();
+    }
+
+    /**
+     * Removes the fixture without deleteModule, which is the code under test.
+     */
+    private function cleanup(): void
+    {
+        $aModule = $this->_oDb->getModuleByUri(self::URI, false);
+        if (empty($aModule))
+            return;
+
+        $this->_oDb->deleteModuleTrackFiles((int)$aModule['id']);
+        $this->_oDb->query("DELETE FROM `sys_modules` WHERE `id` = :id", ['id' => (int)$aModule['id']]);
+        $this->_oDb->cleanCache('sys_modules_list');
+    }
+
+    private function config(string $sVendor): array
+    {
+        return [
+            'type' => BX_DOL_MODULE_TYPE_MODULE,
+            'name' => 'bx_test_vendor_rename',
+            'title' => 'Vendor rename fixture',
+            'vendor' => $sVendor,
+            'version' => '12.0.1',
+            'home_dir' => self::PATH,
+            'home_uri' => self::URI,
+            'class_prefix' => 'BxTestVendorRename',
+            'db_prefix' => 'bx_test_vendor_rename_',
+            'language_category' => 'Vendor rename fixture',
+        ];
+    }
+
+    private function install(string $sVendor): int
+    {
+        $aConfig = $this->config($sVendor);
+        $iId = $this->_oDb->insertModule($aConfig);
+        $this->assertGreaterThan(0, $iId, 'The fixture module was not inserted.');
+
+        $aFile = ['file' => 'install/config.php', 'hash' => md5('fixture')];
+        $this->_oDb->insertModuleTrack($iId, $aFile);
+
+        return $iId;
+    }
+
+    public function testUninstallRemovesRowStoredWithOldVendor(): void
+    {
+        $iId = $this->install('BoonEx');
+
+        $this->assertSame($iId, $this->_oDb->deleteModule($this->config('UNA INC')));
+        $this->assertEmpty($this->_oDb->getModuleById($iId, false));
+        $this->assertEmpty($this->_oDb->getModuleTrackFiles($iId));
+    }
+
+    public function testUninstallRemovesRowStoredWithSameVendor(): void
+    {
+        $iId = $this->install('UNA INC');
+
+        $this->assertSame($iId, $this->_oDb->deleteModule($this->config('UNA INC')));
+        $this->assertEmpty($this->_oDb->getModuleById($iId, false));
+        $this->assertEmpty($this->_oDb->getModuleTrackFiles($iId));
+    }
+
+    public function testUninstallLeavesModuleWithAnotherPath(): void
+    {
+        $iId = $this->install('BoonEx');
+        $this->_oDb->updateModule(['path' => 'boonex/bx_test_vendor_rename_other/'], ['id' => $iId]);
+
+        $this->assertSame(0, $this->_oDb->deleteModule($this->config('UNA INC')));
+        $this->assertNotEmpty($this->_oDb->getModuleById($iId, false));
+        $this->assertNotEmpty($this->_oDb->getModuleTrackFiles($iId));
+    }
+
+    /**
+     * Sites that already hit the bug have the row but no language category. Removing
+     * the module's strings must succeed then, or the second uninstall fails with
+     * "Update languages: An error occurred".
+     */
+    public function testRemovingLanguageOfMissingCategorySucceeds(): void
+    {
+        $aConfig = $this->config('UNA INC');
+        $aModule = [
+            'name' => $aConfig['name'],
+            'path' => $aConfig['home_dir'],
+            'uri' => $aConfig['home_uri'],
+            'lang_category' => $aConfig['language_category'],
+        ];
+
+        $this->assertTrue(BxDolStudioLanguagesUtils::getInstance()->removeLanguageByModule($aModule, false));
+    }
+}