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));
+ }
+ }
+}