diff --git a/CHANGELOG.md b/CHANGELOG.md index f7ff05c1..675f1261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [Unreleased] + +### Fixed + +- Disable containers whose table name exceeds MySQL's 64-character limit instead of crashing; rename them from their edit form to reactivate, recovering existing data when a matching table is found. +- `plugins:fields:check_database` now also reports container/item type pairs with no matching table, and tables with no matching container/item type pair. + ## [1.24.4] - 2026-08-06 ### Fixed diff --git a/front/container.form.php b/front/container.form.php index adfb8a6b..14d32093 100644 --- a/front/container.form.php +++ b/front/container.form.php @@ -54,6 +54,10 @@ $container->check($_POST['id'], UPDATE); $container->update($_POST); Html::back(); +} elseif (isset($_POST['rename_oversized'])) { + $container->check($_POST['id'], UPDATE); + PluginFieldsContainer::renameOversizedContainer((int) $_POST['id'], (string) ($_POST['new_name'] ?? '')); + Html::back(); } elseif (isset($_POST['update_fields_values'])) { $right = PluginFieldsProfile::getRightOnContainer($_SESSION['glpiactiveprofile']['id'], $_POST['plugin_fields_containers_id']); if ($right > READ) { diff --git a/inc/checkdatabasecommand.class.php b/inc/checkdatabasecommand.class.php index 758ac783..d39d68fc 100644 --- a/inc/checkdatabasecommand.class.php +++ b/inc/checkdatabasecommand.class.php @@ -47,7 +47,9 @@ protected function configure() __('- some deleted fields may still be present in database (bug introduced in version %s and fixed in version %s)', 'fields'), '1.15.0', '1.15.3', - ), + ) + . "\n" + . __('- container/item type pairs with no matching table, or tables with no matching container/item type pair', 'fields'), ); $this->addOption( @@ -66,8 +68,9 @@ protected function execute(InputInterface $input, OutputInterface $output) $dead_fields = PluginFieldsMigration::checkDeadFields($fix); $dead_fields_count = count($dead_fields, COUNT_RECURSIVE) - count($dead_fields); - // No invalid fields found - if ($dead_fields_count === 0) { + $tables_consistency = PluginFieldsMigration::checkContainerTablesConsistency(); + + if ($dead_fields_count === 0 && $tables_consistency['missing'] === [] && $tables_consistency['orphaned'] === []) { $output->writeln( '' . __('Everything is in order - no action needed.', 'fields') . '', ); @@ -75,32 +78,58 @@ protected function execute(InputInterface $input, OutputInterface $output) return Command::SUCCESS; } - // Indicate which fields will have been or must be deleted - $error = $fix - ? sprintf(__('Database was containing orphaned data from %s improperly deleted field(s).', 'fields'), $dead_fields_count) - : sprintf(__('Database contains orphaned data from %s improperly deleted field(s).', 'fields'), $dead_fields_count); - $output->writeln('' . $error . '', OutputInterface::VERBOSITY_QUIET); + if ($dead_fields_count > 0) { + $error = $fix + ? sprintf(__('Database was containing orphaned data from %s improperly deleted field(s).', 'fields'), $dead_fields_count) + : sprintf(__('Database contains orphaned data from %s improperly deleted field(s).', 'fields'), $dead_fields_count); + $output->writeln('' . $error . '', OutputInterface::VERBOSITY_QUIET); - foreach ($dead_fields as $table => $fields) { - foreach ($fields as $field) { - $info = $fix - ? sprintf(__('-> "%s.%s" has been deleted.', 'fields'), $table, $field) - : sprintf(__('-> "%s.%s" should be deleted.', 'fields'), $table, $field); - $output->writeln($info); + foreach ($dead_fields as $table => $fields) { + foreach ($fields as $field) { + $info = $fix + ? sprintf(__('-> "%s.%s" has been deleted.', 'fields'), $table, $field) + : sprintf(__('-> "%s.%s" should be deleted.', 'fields'), $table, $field); + $output->writeln($info); + } + } + + // Show extra info in dry-run mode + if (!$fix) { + // Print command to do the actual deletion + $next_command = sprintf( + __('Run "%s" to fix database inconsistencies.', 'fields'), + sprintf('php bin/console %s --fix', $this->getName()), + ); + $output->writeln( + '' . $next_command . '', + OutputInterface::VERBOSITY_QUIET, + ); } } - // Show extra info in dry-run mode - if (!$fix) { - // Print command to do the actual deletion - $next_command = sprintf( - __('Run "%s" to fix database inconsistencies.', 'fields'), - sprintf('php bin/console %s --fix', $this->getName()), + if ($tables_consistency['missing'] !== []) { + $output->writeln( + '' . sprintf(__('%d container/item type pair(s) have no matching table.', 'fields'), count($tables_consistency['missing'])) . '', + OutputInterface::VERBOSITY_QUIET, ); + foreach ($tables_consistency['missing'] as $entry) { + $output->writeln(sprintf( + __('-> container #%d (%s): expected table "%s" not found', 'fields'), + $entry['container_id'], + $entry['itemtype'], + $entry['table'], + )); + } + } + + if ($tables_consistency['orphaned'] !== []) { $output->writeln( - '' . $next_command . '', + '' . sprintf(__('%d table(s) do not match any container/item type pair.', 'fields'), count($tables_consistency['orphaned'])) . '', OutputInterface::VERBOSITY_QUIET, ); + foreach ($tables_consistency['orphaned'] as $table) { + $output->writeln(sprintf('-> "%s"', $table)); + } } return Command::SUCCESS; diff --git a/inc/container.class.php b/inc/container.class.php index 0c0b2ebb..1eabdd9f 100644 --- a/inc/container.class.php +++ b/inc/container.class.php @@ -241,6 +241,54 @@ public static function installUserData(Migration $migration, $version) /** @var DBmysql $DB */ global $DB; + // Quarantine generated class files: hides stale ones from the autoloader, purged once migration succeeds. + foreach (glob(PLUGINFIELDS_CLASS_PATH . '/*.class.php') ?: [] as $existing_file) { + if (str_ends_with($existing_file, 'dropdown.class.php')) { + continue; + } + + rename($existing_file, $existing_file . '.bak'); + } + + // Disable oversized-table containers instead of crashing. + $obj = new self(); + $active_containers = $obj->find(['is_active' => 1]); + foreach ($active_containers as $container) { + if (empty($container['itemtypes'])) { + continue; + } + + $itemtypes = PluginFieldsToolbox::decodeJSONItemtypes($container['itemtypes']); + if (!is_array($itemtypes)) { + continue; + } + + $too_long = false; + foreach ($itemtypes as $itemtype) { + $table = getTableForItemType(self::getClassname($itemtype, $container['name'])); + if (strlen($table) > 64) { + $too_long = true; + break; + } + } + + if (!$too_long) { + continue; + } + + $DB->update( + self::getTable(), + ['is_active' => 0], + ['id' => $container['id']], + ); + + $migration->addWarningMessage(sprintf( + __('Container #%1$d (%2$s) disabled: table name too long. Rename it from its edit form to reactivate it.', 'fields'), + $container['id'], + $container['name'], + )); + } + // -> 0.90-1.3: generated class moved // Drop them, they will be regenerated $obj = new self(); @@ -443,9 +491,24 @@ public static function installUserData(Migration $migration, $version) $obj = new self(); $containers = $obj->find(); foreach ($containers as $container) { + $itemtypes = PluginFieldsToolbox::decodeJSONItemtypes($container['itemtypes']); + if (is_array($itemtypes)) { + foreach ($itemtypes as $itemtype) { + if (strlen(getTableForItemType(self::getClassname($itemtype, $container['name']))) > 64) { + // Table name still too long: the container was just disabled above, skip it. + continue 2; + } + } + } + self::create($container); } + // Migration succeeded: remaining quarantined files are stale for good. + foreach (glob(PLUGINFIELDS_CLASS_PATH . '/*.class.php.bak') ?: [] as $stale_file) { + unlink($stale_file); + } + return true; } @@ -928,6 +991,189 @@ public static function getTypeName($nb = 0) return __('Block', 'fields'); } + /** + * Rename an oversized-table container, then reactivate it. + * + * @param int $id Container ID. + * @param string $new_name New internal name. + */ + public static function renameOversizedContainer(int $id, string $new_name): bool + { + /** @var DBmysql $DB */ + global $DB; + + $container = new self(); + if (!$container->getFromDB($id)) { + Session::AddMessageAfterRedirect(sprintf(__('Unknown container #%d.', 'fields'), $id), false, ERROR); + + return false; + } + + $itemtypes = PluginFieldsToolbox::decodeJSONItemtypes($container->fields['itemtypes']); + if (!is_array($itemtypes) || $itemtypes === []) { + Session::AddMessageAfterRedirect(__('No associated item type.', 'fields'), false, ERROR); + + return false; + } + + $is_oversized = false; + foreach ($itemtypes as $itemtype) { + if (strlen(getTableForItemType(self::getClassname($itemtype, $container->fields['name']))) > 64) { + $is_oversized = true; + break; + } + } + + if (!$is_oversized) { + Session::AddMessageAfterRedirect(__('Container is not oversized.', 'fields'), false, ERROR); + + return false; + } + + $new_name = preg_replace('/[^\da-zA-Z]/', '', $new_name) ?? ''; + if ($new_name === '') { + Session::AddMessageAfterRedirect(__('Invalid name.', 'fields'), false, ERROR); + + return false; + } + + $too_long_tables = []; + foreach ($itemtypes as $itemtype) { + $table = getTableForItemType(self::getClassname($itemtype, $new_name)); + if (strlen($table) > 64) { + $too_long_tables[] = $table; + } + } + + if ($too_long_tables !== []) { + Session::AddMessageAfterRedirect(sprintf( + __('Still too long: %s.', 'fields'), + implode(', ', $too_long_tables), + ), false, ERROR); + + return false; + } + + $found = $container->find(['name' => $new_name]); + foreach ($found as $other) { + if ((int) $other['id'] === $id) { + continue; + } + + $other_itemtypes = PluginFieldsToolbox::decodeJSONItemtypes($other['itemtypes']); + if (is_array($other_itemtypes) && array_intersect($itemtypes, $other_itemtypes) !== []) { + Session::AddMessageAfterRedirect(__('Name already used for this item type.', 'fields'), false, ERROR); + + return false; + } + } + + $plugin = new Plugin(); + $plugin->getFromDBbyDir('fields'); + + $migration = new Migration((string) ($plugin->fields['version'] ?? '')); + + $old_name = $container->fields['name']; + $claimed_orphans = []; + $data_preserved = false; + foreach ($itemtypes as $itemtype) { + $new_table = getTableForItemType(self::getClassname($itemtype, $new_name)); + + $old_table = getTableForItemType(self::getClassname($itemtype, $old_name)); + if (!$DB->tableExists($old_table)) { + $old_table = self::findOrphanTableForContainer($id, $claimed_orphans); + } + + if ($old_table === null || !$DB->tableExists($old_table)) { + continue; + } + + $claimed_orphans[] = $old_table; + + if (countElementsInTable($old_table) > 0) { + $migration->renameTable($old_table, $new_table); + $data_preserved = true; + } else { + // Empty leftover table: drop it, a fresh one is created below. + $migration->dropTable($old_table); + } + } + + $DB->clearSchemaCache(); + + $migration->executeMigration(); + + $container->update([ + 'id' => $id, + 'name' => $new_name, + 'is_active' => 1, + ]); + + $container->getFromDB($id); + self::create($container->fields); + + $message = $data_preserved + ? sprintf(__('Renamed to "%s" and reactivated, existing data preserved.', 'fields'), $new_name) + : sprintf(__('Renamed to "%s" and reactivated, no existing data found.', 'fields'), $new_name); + Session::AddMessageAfterRedirect($message, false, INFO); + + return true; + } + + /** + * Find an orphaned table belonging to this container. + * + * @param int $container_id Container ID. + * @param string[] $already_claimed Orphan tables already assigned to another itemtype in this call. + */ + private static function findOrphanTableForContainer(int $container_id, array $already_claimed): ?string + { + /** @var DBmysql $DB */ + global $DB; + + $orphaned = array_diff( + PluginFieldsMigration::checkContainerTablesConsistency()['orphaned'], + $already_claimed, + ); + + // Primary match: `plugin_fields_containers_id` is DEFAULTed to the container's own id + // at table creation time, a link that survives any later name corruption. + $by_default = []; + foreach ($orphaned as $table) { + foreach ($DB->listFields($table) as $column) { + if ($column['Field'] === 'plugin_fields_containers_id' && (int) $column['Default'] === $container_id) { + $by_default[] = $table; + break; + } + } + } + + if (count($by_default) === 1) { + return $by_default[0]; + } + + // Fallback for tables predating that default: match by custom field columns. + $expected_columns = PluginFieldsMigration::getValidFieldsForContainer($container_id); + if ($expected_columns === []) { + return null; + } + + sort($expected_columns); + + $base_columns = ['id', 'items_id', 'itemtype', 'plugin_fields_containers_id', 'entities_id']; + $candidates = []; + foreach (array_diff($orphaned, $by_default) as $table) { + $columns = array_diff(array_column($DB->listFields($table), 'Field'), $base_columns); + sort($columns); + + if ($columns === $expected_columns) { + $candidates[] = $table; + } + } + + return count($candidates) === 1 ? $candidates[0] : null; + } + public function showForm($ID, $options = []) { /** @var array $CFG_GLPI */ @@ -1062,6 +1308,34 @@ public function showForm($ID, $options = []) echo ''; echo ''; + if (!$this->isNewID($ID) && (int) $this->fields['is_active'] === 0) { + $oversized_table = null; + $itemtypes = PluginFieldsToolbox::decodeJSONItemtypes($this->fields['itemtypes']); + if (is_array($itemtypes)) { + foreach ($itemtypes as $itemtype) { + $table = getTableForItemType(self::getClassname($itemtype, $this->fields['name'])); + if (strlen($table) > 64) { + $oversized_table = $table; + break; + } + } + } + + if ($oversized_table !== null) { + echo ''; + echo ''; + echo sprintf(__('Table name too long (%s):', 'fields'), $oversized_table); + echo ''; + echo ''; + echo Html::input('new_name', ['placeholder' => __('New name', 'fields')]); + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + } + $this->showFormButtons($options); return true; diff --git a/inc/migration.class.php b/inc/migration.class.php index e9e3fc45..f15d088a 100644 --- a/inc/migration.class.php +++ b/inc/migration.class.php @@ -154,7 +154,7 @@ public static function checkDeadFields(bool $fix): array * * @param int $container_id Id of the container */ - private static function getValidFieldsForContainer(int $container_id): array + public static function getValidFieldsForContainer(int $container_id): array { $valid_fields = []; @@ -193,6 +193,7 @@ private static function getCustomFieldsInContainerTable( 'items_id', 'itemtype', 'plugin_fields_containers_id', + 'entities_id', ]; return array_filter( @@ -201,6 +202,68 @@ private static function getCustomFieldsInContainerTable( ); } + /** + * Compare container/itemtype pairs against actual database tables. + * + * @return array{missing: array, orphaned: string[]} + */ + public static function checkContainerTablesConsistency(): array + { + /** @var DBmysql $DB */ + global $DB; + + $expected_tables = []; + $entries_by_table = []; + $containers = (new PluginFieldsContainer())->find([]); + foreach ($containers as $row) { + $itemtypes = PluginFieldsToolbox::decodeJSONItemtypes((string) $row['itemtypes']); + if (!is_array($itemtypes)) { + continue; + } + + foreach ($itemtypes as $itemtype) { + $table = getTableForItemType(PluginFieldsContainer::getClassname($itemtype, $row['name'])); + $expected_tables[$table] = true; + $entries_by_table[$table][] = ['container_id' => (int) $row['id'], 'itemtype' => $itemtype, 'table' => $table]; + } + } + + $missing = []; + foreach ($entries_by_table as $table => $entries) { + if (!$DB->tableExists($table)) { + array_push($missing, ...$entries); + } + } + + // Tables that never map to a container/itemtype pair. + $system_tables = [ + PluginFieldsContainer::getTable(), + PluginFieldsField::getTable(), + PluginFieldsProfile::getTable(), + PluginFieldsLabelTranslation::getTable(), + PluginFieldsContainerDisplayCondition::getTable(), + PluginFieldsStatusOverride::getTable(), + ]; + + // Dropdown fields have their own dedicated table, unrelated to any container/itemtype pair. + $dropdown_fields = (new PluginFieldsField())->find(['type' => 'dropdown']); + foreach ($dropdown_fields as $field) { + $system_tables[] = getTableForItemType(PluginFieldsDropdown::getClassname($field['name'])); + } + + $orphaned = []; + foreach ($DB->listTables('glpi_plugin_fields_%') as $row) { + $table = $row['TABLE_NAME']; + if (in_array($table, $system_tables, true) || isset($expected_tables[$table])) { + continue; + } + + $orphaned[] = $table; + } + + return ['missing' => $missing, 'orphaned' => $orphaned]; + } + public static function getGenericObjectTypes(): array { /** @var DBmysql $DB */ diff --git a/tests/Units/ContainerTest.php b/tests/Units/ContainerTest.php index 9d7c2cd1..7bbd5668 100644 --- a/tests/Units/ContainerTest.php +++ b/tests/Units/ContainerTest.php @@ -33,9 +33,11 @@ namespace GlpiPlugin\Field\Tests\Units; use Computer; +use DBmysql; use Glpi\Tests\DbTestCase; use Glpi\Tests\GLPITestCase; use GlpiPlugin\Field\Tests\FieldTestTrait; +use Migration; use PHPUnit\Framework\Attributes\DataProvider; use PluginFieldsContainer; use Ticket; @@ -124,4 +126,200 @@ public function testAddDomtabWithIncompatibleItemtypeIsRejected(): void ]); $this->assertFalse($result); } + + public function testInstallUserDataDisablesOversizedContainerName(): void + { + /** @var DBmysql $DB */ + global $DB; + + // Bypass prepareInputForAdd's own length guard to simulate a container + // whose name was corrupted/imported before this length was enforced. + $DB->insert(PluginFieldsContainer::getTable(), [ + 'name' => str_repeat('a', 100), + 'label' => 'Oversized ' . $this->getUniqueString(), + 'itemtypes' => json_encode([Computer::class]), + 'type' => 'tab', + 'entities_id' => 0, + 'is_recursive' => 1, + 'is_active' => 1, + ]); + $container_id = $DB->insertId(); + + try { + $result = PluginFieldsContainer::installUserData(new Migration('1.24.4'), '1.24.4'); + $this->assertTrue($result); + + $container = new PluginFieldsContainer(); + $this->assertTrue($container->getFromDB($container_id)); + $this->assertSame(0, (int) $container->fields['is_active']); + } finally { + $DB->delete(PluginFieldsContainer::getTable(), ['id' => $container_id]); + } + } + + public function testRenameOversizedContainerSucceeds(): void + { + $container = $this->createFieldContainer([ + 'label' => 'RenameMe ' . $this->getUniqueString(), + 'type' => 'tab', + 'itemtypes' => [Computer::class], + 'is_active' => 0, + 'entities_id' => 0, + 'is_recursive' => 1, + ]); + + /** @var DBmysql $DB */ + global $DB; + $DB->update(PluginFieldsContainer::getTable(), ['name' => str_repeat('a', 100)], ['id' => $container->getID()]); + + $new_name = 'Renamed' . str_replace('-', '', $this->getUniqueString()); + + $result = PluginFieldsContainer::renameOversizedContainer($container->getID(), $new_name); + $this->assertTrue($result); + + $reloaded = new PluginFieldsContainer(); + $this->assertTrue($reloaded->getFromDB($container->getID())); + $this->assertSame($new_name, $reloaded->fields['name']); + $this->assertSame(1, (int) $reloaded->fields['is_active']); + + $table = getTableForItemType(PluginFieldsContainer::getClassname(Computer::class, $new_name)); + $this->assertTrue($DB->tableExists($table)); + + // The reactivated container must be genuinely usable, not just flagged active. + $field = $this->createField([ + 'label' => 'Serial extra ' . str_replace('-', '', $this->getUniqueString()), + 'type' => 'text', + PluginFieldsContainer::getForeignKeyField() => $container->getID(), + 'ranking' => 1, + 'is_active' => 1, + 'is_readonly' => 0, + ]); + $field_name = $field->fields['name']; + + $computer = $this->createItem(Computer::class, [ + 'name' => 'Computer for rename test', + 'entities_id' => 0, + ]); + $computer_item = new Computer(); + $this->assertTrue($computer_item->update([ + 'id' => $computer->getID(), + $field_name => 'real value', + ])); + + $rows = $DB->request(['FROM' => $table, 'WHERE' => ['items_id' => $computer->getID()]]); + $this->assertCount(1, $rows); + $this->assertSame('real value', $rows->current()[$field_name]); + } + + public function testRenameOversizedContainerRecoversDataFromOrphanTable(): void + { + // Simulate a container that had a real, working table on an older GLPI/plugin + // version: the container row gets its name overwritten by a migration step + // (losing the link to its own table), while the physical table survives untouched. + $container = $this->createFieldContainer([ + 'label' => 'RealData ' . $this->getUniqueString(), + 'type' => 'tab', + 'itemtypes' => [Computer::class], + 'is_active' => 1, + 'entities_id' => 0, + 'is_recursive' => 1, + ]); + + $field = $this->createField([ + 'label' => 'Serial extra ' . str_replace('-', '', $this->getUniqueString()), + 'type' => 'text', + PluginFieldsContainer::getForeignKeyField() => $container->getID(), + 'ranking' => 1, + 'is_active' => 1, + 'is_readonly' => 0, + ]); + $field_name = $field->fields['name']; + + $computer = $this->createItem(Computer::class, [ + 'name' => 'Computer with real data', + 'entities_id' => 0, + ]); + $computer_item = new Computer(); + $this->assertTrue($computer_item->update([ + 'id' => $computer->getID(), + $field_name => 'data from an older version', + ])); + + $old_table = getTableForItemType(PluginFieldsContainer::getClassname(Computer::class, $container->fields['name'])); + + /** @var DBmysql $DB */ + global $DB; + $DB->update(PluginFieldsContainer::getTable(), ['name' => str_repeat('a', 100)], ['id' => $container->getID()]); + + $new_name = 'Recovered' . str_replace('-', '', $this->getUniqueString()); + + $result = PluginFieldsContainer::renameOversizedContainer($container->getID(), $new_name); + $this->assertTrue($result); + + $new_table = getTableForItemType(PluginFieldsContainer::getClassname(Computer::class, $new_name)); + $this->assertFalse($DB->tableExists($old_table)); + $this->assertTrue($DB->tableExists($new_table)); + + $rows = $DB->request(['FROM' => $new_table, 'WHERE' => ['items_id' => $computer->getID()]]); + $this->assertCount(1, $rows); + $this->assertSame('data from an older version', $rows->current()[$field_name]); + } + + public function testRenameOversizedContainerFailsForUnknownContainer(): void + { + $result = PluginFieldsContainer::renameOversizedContainer(999999999, 'whatever'); + + $this->assertFalse($result); + } + + public function testRenameOversizedContainerFailsWhenNameIsStillTooLong(): void + { + $container = $this->createFieldContainer([ + 'label' => 'StillTooLong ' . $this->getUniqueString(), + 'type' => 'tab', + 'itemtypes' => [Computer::class], + 'is_active' => 0, + 'entities_id' => 0, + 'is_recursive' => 1, + ]); + $original_name = $container->fields['name']; + + $result = PluginFieldsContainer::renameOversizedContainer($container->getID(), str_repeat('a', 100)); + + $this->assertFalse($result); + + $reloaded = new PluginFieldsContainer(); + $this->assertTrue($reloaded->getFromDB($container->getID())); + $this->assertSame($original_name, $reloaded->fields['name']); + $this->assertSame(0, (int) $reloaded->fields['is_active']); + } + + public function testRenameOversizedContainerFailsOnNameCollision(): void + { + $existing = $this->createFieldContainer([ + 'label' => 'Existing ' . $this->getUniqueString(), + 'type' => 'tab', + 'itemtypes' => [Computer::class], + 'is_active' => 1, + 'entities_id' => 0, + 'is_recursive' => 1, + ]); + + $container = $this->createFieldContainer([ + 'label' => 'Colliding ' . $this->getUniqueString(), + 'type' => 'tab', + 'itemtypes' => [Computer::class], + 'is_active' => 0, + 'entities_id' => 0, + 'is_recursive' => 1, + ]); + + $result = PluginFieldsContainer::renameOversizedContainer($container->getID(), $existing->fields['name']); + + $this->assertFalse($result); + + $reloaded = new PluginFieldsContainer(); + $this->assertTrue($reloaded->getFromDB($container->getID())); + $this->assertSame(0, (int) $reloaded->fields['is_active']); + } } diff --git a/tests/Units/MigrationTest.php b/tests/Units/MigrationTest.php new file mode 100644 index 00000000..7a6c91f0 --- /dev/null +++ b/tests/Units/MigrationTest.php @@ -0,0 +1,133 @@ +. + * ------------------------------------------------------------------------- + * @copyright Copyright (C) 2013-2023 by Fields plugin team. + * @license GPLv2 https://www.gnu.org/licenses/gpl-2.0.html + * @link https://github.com/pluginsGLPI/fields + * ------------------------------------------------------------------------- + */ + +declare(strict_types=1); + +namespace GlpiPlugin\Field\Tests\Units; + +use Computer; +use DBmysql; +use Glpi\Tests\DbTestCase; +use Glpi\Tests\GLPITestCase; +use GlpiPlugin\Field\Tests\FieldTestTrait; +use PluginFieldsContainer; +use PluginFieldsMigration; + +require_once __DIR__ . '/../FieldTestCase.php'; + +final class MigrationTest extends DbTestCase +{ + use FieldTestTrait; + + public function setUp(): void + { + GLPITestCase::setUp(); + $this->login(); + } + + public function tearDown(): void + { + $this->tearDownFieldTest(); + GLPITestCase::tearDown(); + } + + public function testCheckContainerTablesConsistencyOnHealthyContainer(): void + { + $container = $this->createFieldContainer([ + 'label' => 'Consistent ' . $this->getUniqueString(), + 'type' => 'tab', + 'itemtypes' => [Computer::class], + 'is_active' => 1, + 'entities_id' => 0, + 'is_recursive' => 1, + ]); + + $result = PluginFieldsMigration::checkContainerTablesConsistency(); + + $missing_ids = array_column($result['missing'], 'container_id'); + $this->assertNotContains($container->getID(), $missing_ids); + } + + public function testCheckContainerTablesConsistencyDetectsMissingTable(): void + { + $container = $this->createFieldContainer([ + 'label' => 'MissingTable ' . $this->getUniqueString(), + 'type' => 'tab', + 'itemtypes' => [Computer::class], + 'is_active' => 1, + 'entities_id' => 0, + 'is_recursive' => 1, + ]); + + $table = getTableForItemType(PluginFieldsContainer::getClassname(Computer::class, $container->fields['name'])); + + /** @var DBmysql $DB */ + global $DB; + $DB->doQuery(sprintf('DROP TABLE IF EXISTS `%s`', $table)); + + try { + $result = PluginFieldsMigration::checkContainerTablesConsistency(); + + $missing_entry = null; + foreach ($result['missing'] as $entry) { + if ($entry['container_id'] === $container->getID()) { + $missing_entry = $entry; + } + } + + $this->assertNotNull($missing_entry); + $this->assertSame(Computer::class, $missing_entry['itemtype']); + $this->assertSame($table, $missing_entry['table']); + } finally { + // Recreate the table so container cleanup in tearDown does not fail. + PluginFieldsContainer::create($container->fields); + } + } + + public function testCheckContainerTablesConsistencyDetectsOrphanedTable(): void + { + $orphan_table = 'glpi_plugin_fields_' . strtolower((string) preg_replace('/[^a-zA-Z0-9]/', '', (string) $this->getUniqueString())) . 'orphan'; + + /** @var DBmysql $DB */ + global $DB; + $DB->doQuery(sprintf( + 'CREATE TABLE `%s` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`))', + $orphan_table, + )); + + try { + $result = PluginFieldsMigration::checkContainerTablesConsistency(); + + $this->assertContains($orphan_table, $result['orphaned']); + } finally { + $DB->doQuery(sprintf('DROP TABLE IF EXISTS `%s`', $orphan_table)); + } + } +}