From 37601d02a8b7ff4832d5ac42c2e69141419dccbd Mon Sep 17 00:00:00 2001 From: Rom1-B <8530352+Rom1-B@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:59:16 +0200 Subject: [PATCH 1/7] Feat: rename oversized containers from their edit form --- CHANGELOG.md | 2 + front/container.form.php | 4 + inc/checkdatabasecommand.class.php | 71 +++-- inc/container.class.php | 246 +++++++++++++++++ inc/migration.class.php | 65 ++++- tests/Units/ContainerTest.php | 410 +++++++++++++++++++++++++++++ tests/Units/MigrationTest.php | 133 ++++++++++ 7 files changed, 909 insertions(+), 22 deletions(-) create mode 100644 tests/Units/MigrationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c6f40086..2c4ed818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fix a field's default value not being applied to existing items and not being shown in search results for items with no dedicated row in the container table - Fix mandatory fields blocking automated item creation - Fix unclear mandatory field error when a GLPI form creating a ticket does not provide the field. +- 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.5] - 2026-09-11 diff --git a/front/container.form.php b/front/container.form.php index 6b5dafa1..bdb77d79 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'])) { if (!PluginFieldsContainer::canUpdateTargetItem($_REQUEST['itemtype'] ?? '', (int) ($_REQUEST['items_id'] ?? 0))) { throw new AccessDeniedHttpException(); diff --git a/inc/checkdatabasecommand.class.php b/inc/checkdatabasecommand.class.php index 758ac783..33b16b05 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', + $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 8221cd1e..bb11e7d4 100644 --- a/inc/container.class.php +++ b/inc/container.class.php @@ -242,6 +242,45 @@ public static function installUserData(Migration $migration, $version) /** @var DBmysql $DB */ global $DB; + // 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(); @@ -444,6 +483,16 @@ 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); } @@ -929,6 +978,175 @@ 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; + } + + $new_name = preg_replace('/[^\da-zA-Z]/', '', $new_name) ?? ''; + if ($new_name === '') { + Session::AddMessageAfterRedirect(__('Invalid name.', 'fields'), 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; + } + + $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 */ @@ -1063,6 +1281,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 dd655418..5e0dfd9b 100644 --- a/tests/Units/ContainerTest.php +++ b/tests/Units/ContainerTest.php @@ -33,11 +33,13 @@ namespace GlpiPlugin\Field\Tests\Units; use Computer; +use DBmysql; use Glpi\Tests\DbTestCase; use Glpi\Tests\GLPITestCase; use GlpiPlugin\Field\Tests\FieldTestTrait; use Laminas\Mail\Storage\Message; use MailCollector; +use Migration; use PHPUnit\Framework\Attributes\DataProvider; use PluginFieldsContainer; use PluginFieldsDropdown; @@ -536,4 +538,412 @@ public function testSearchoptionsShowsDefaultValueFieldWithoutAnyRow( $this->assertSame($default_value, $row['raw']['ITEM_Ticket_' . $so_id]); } } + + 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, + ]); + + // A first field so tickets already get a row in the container table. + $existing_field = $this->createField([ + 'label' => 'Existing Field', + 'type' => 'text', + PluginFieldsContainer::getForeignKeyField() => $container->getID(), + 'ranking' => 1, + 'is_active' => 1, + 'is_readonly' => 0, + ]); + $existing_field_name = $existing_field->fields['name']; + + // Tickets already existing before the new field is created. + $ticket1 = $this->createItem(Ticket::class, [ + 'name' => 'Ticket 1 ' . $this->getUniqueString(), + 'content' => 'Test', + 'entities_id' => 0, + $existing_field_name => 'value 1', + ], [$existing_field_name]); + + $ticket2 = $this->createItem(Ticket::class, [ + 'name' => 'Ticket 2 ' . $this->getUniqueString(), + 'content' => 'Test', + 'entities_id' => 0, + $existing_field_name => 'value 2', + ], [$existing_field_name]); + + if ($type === 'dropdown-Computer') { + [$computer1, $computer2] = $this->createItems(Computer::class, [ + ['name' => 'Computer 1', 'entities_id' => 0], + ['name' => 'Computer 2', 'entities_id' => 0], + ]); + + $created_default = $multiple ? [$computer1->getID()] : $computer1->getID(); + $updated_default = $multiple ? [$computer2->getID()] : $computer2->getID(); + } + + // Create a new field with a default value on the same container. + $new_field = $this->createField( + [ + 'label' => 'New Field', + 'type' => $type, + 'multiple' => $multiple ? 1 : 0, + PluginFieldsContainer::getForeignKeyField() => $container->getID(), + 'ranking' => 2, + 'is_active' => 1, + 'is_readonly' => 0, + 'default_value' => $created_default, + ], + $multiple ? ['default_value'] : [], + ); + $new_field_name = $new_field->fields['name']; + + $readValue = function (Ticket $ticket) use ($container, $new_field_name, $multiple): mixed { + $stored = $this->getDefaultValueStored($ticket, $container, $new_field_name); + + return $multiple ? json_decode((string) $stored, true) : $stored; + }; + + // Assert: the default value was applied to all objects that already existed. + $this->assertEquals($created_default, $readValue($ticket1)); + $this->assertEquals($created_default, $readValue($ticket2)); + + // Change the default value afterwards, through an update, not a creation. + $this->updateItem( + PluginFieldsField::class, + $new_field->getID(), + ['default_value' => $updated_default], + $multiple ? ['default_value'] : [], + ); + + // The update must not retroactively change existing objects values. + $this->assertEquals($created_default, $readValue($ticket1)); + $this->assertEquals($created_default, $readValue($ticket2)); + + // Sanity check: a ticket created after the update still gets the new default, + // proving the update did take effect, just not retroactively. + $ticket3 = $this->createItem(Ticket::class, [ + 'name' => 'Ticket 3 ' . $this->getUniqueString(), + 'content' => 'Test', + 'entities_id' => 0, + ]); + $this->assertEquals($updated_default, $readValue($ticket3)); + } + + public static function provideFieldTypesForSearchDefaultValue(): iterable + { + yield 'text' => ['type' => 'text', 'default_value' => 'search default text']; + yield 'textarea' => ['type' => 'textarea', 'default_value' => 'search default textarea']; + yield 'richtext' => ['type' => 'richtext', 'default_value' => 'search default richtext']; + yield 'url' => ['type' => 'url', 'default_value' => 'https://example.org/search-default']; + yield 'number' => ['type' => 'number', 'default_value' => '42']; + yield 'yesno' => ['type' => 'yesno', 'default_value' => '1']; + yield 'date' => ['type' => 'date', 'default_value' => '2024-01-01']; + yield 'date now' => ['type' => 'date', 'default_value' => 'now']; + yield 'datetime' => ['type' => 'datetime', 'default_value' => '2024-01-01 10:00:00']; + yield 'dropdown' => ['type' => 'dropdown', 'multiple' => false]; + yield 'dropdown multiple' => ['type' => 'dropdown', 'multiple' => true]; + yield 'dropdown itemtype computer' => ['type' => 'dropdown-Computer', 'multiple' => false]; + yield 'dropdown itemtype computer multiple' => ['type' => 'dropdown-Computer', 'multiple' => true]; + } + + #[DataProvider('provideFieldTypesForSearchDefaultValue')] + public function testSearchoptionsShowsDefaultValueFieldWithoutAnyRow( + string $type, + ?string $default_value = null, + bool $multiple = false, + ): void { + $this->login(); + $entities_id = $_SESSION['glpiactive_entity']; + + $container = $this->createFieldContainer([ + 'label' => 'Search Default Container', + 'type' => 'dom', + 'itemtypes' => [Ticket::class], + 'is_active' => 1, + 'entities_id' => $entities_id, + 'is_recursive' => 1, + ]); + + // Ticket created before the field exists + $ticket = $this->createItem(Ticket::class, [ + 'name' => 'Search default ticket ' . $this->getUniqueString(), + 'content' => 'Test', + 'entities_id' => $entities_id, + ]); + + $expected_displayname = null; + + if ($type === 'dropdown-Computer') { + [$option1, $option2] = $this->createItems(Computer::class, [ + ['name' => 'Search default option 1 ' . $this->getUniqueString(), 'entities_id' => $entities_id], + ['name' => 'Search default option 2 ' . $this->getUniqueString(), 'entities_id' => $entities_id], + ]); + + $default_value = $multiple ? [$option1->getID(), $option2->getID()] : (string) $option1->getID(); + $expected_displayname = $multiple + ? implode('
', [$option1->fields['name'], $option2->fields['name']]) + : $option1->fields['name']; + } + + $field_input = [ + 'label' => 'Search Default Field', + 'type' => $type, + 'multiple' => $multiple ? 1 : 0, + PluginFieldsContainer::getForeignKeyField() => $container->getID(), + 'ranking' => 1, + 'is_active' => 1, + 'is_readonly' => 0, + ]; + + // The default value for dropdown type fields can only be set after the field is created, + // because it requires the creation of dropdown items first. + if ($type !== 'dropdown') { + $field_input['default_value'] = $default_value; + } elseif ($multiple) { + $field_input['default_value'] = []; + } + + $field = $this->createField($field_input, $multiple ? ['default_value'] : []); + + if ($type === 'dropdown') { + $dropdown_classname = PluginFieldsDropdown::getClassname($field->fields['name']); + [$option1, $option2] = $this->createItems($dropdown_classname, [ + ['name' => 'Search default option 1 ' . $this->getUniqueString()], + ['name' => 'Search default option 2 ' . $this->getUniqueString()], + ]); + + $default_value = $multiple ? [$option1->getID(), $option2->getID()] : (string) $option1->getID(); + $expected_displayname = $multiple + ? implode('
', [$option1->fields['name'], $option2->fields['name']]) + : $option1->fields['name']; + + $this->updateItem( + PluginFieldsField::class, + $field->getID(), + ['default_value' => $default_value], + $multiple ? ['default_value'] : [], + ); + } + + $searchopt = Search::getOptions(Ticket::class); + $so_id = PluginFieldsField::SEARCH_OPTION_STARTING_INDEX + $field->getID(); + $this->assertArrayHasKey($so_id, $searchopt); + + $data = Search::getDatas( + Ticket::class, + [ + 'is_deleted' => 0, + 'start' => 0, + 'criteria' => [ + ['field' => 'view', 'searchtype' => 'contains', 'value' => $ticket->fields['name']], + ], + ], + [$so_id], + ); + + $this->assertSame(1, $data['data']['totalcount']); + $row = current($data['data']['rows']); + + if ($type === 'dropdown-Computer' || $type === 'dropdown') { + $this->assertTrue(isset($row['Ticket_' . $so_id]['displayname'])); + $this->assertSame($expected_displayname, $row['Ticket_' . $so_id]['displayname']); + } elseif ($default_value === 'now') { + // 'now' is resolved to the current server time at query time, + // not stored as the literal string 'now'. + $this->assertMatchesRegularExpression( + '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', + (string) $row['raw']['ITEM_Ticket_' . $so_id], + ); + } else { + $this->assertSame($default_value, $row['raw']['ITEM_Ticket_' . $so_id]); + } + $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..d63d652c --- /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(preg_replace('/[^a-zA-Z0-9]/', '', $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)); + } + } +} From 76c886b959a379087a6cbabd697278ea17ee2bef Mon Sep 17 00:00:00 2001 From: Rom1-B <8530352+Rom1-B@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:37:17 +0200 Subject: [PATCH 2/7] rector --- tests/Units/MigrationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Units/MigrationTest.php b/tests/Units/MigrationTest.php index d63d652c..7a6c91f0 100644 --- a/tests/Units/MigrationTest.php +++ b/tests/Units/MigrationTest.php @@ -113,7 +113,7 @@ public function testCheckContainerTablesConsistencyDetectsMissingTable(): void public function testCheckContainerTablesConsistencyDetectsOrphanedTable(): void { - $orphan_table = 'glpi_plugin_fields_' . strtolower(preg_replace('/[^a-zA-Z0-9]/', '', $this->getUniqueString())) . 'orphan'; + $orphan_table = 'glpi_plugin_fields_' . strtolower((string) preg_replace('/[^a-zA-Z0-9]/', '', (string) $this->getUniqueString())) . 'orphan'; /** @var DBmysql $DB */ global $DB; From 470f5f1f7497d434772048624074bad799d5e981 Mon Sep 17 00:00:00 2001 From: Rom1-B <8530352+Rom1-B@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:47:36 +0200 Subject: [PATCH 3/7] stan --- inc/checkdatabasecommand.class.php | 2 +- inc/container.class.php | 26 ++++++++++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/inc/checkdatabasecommand.class.php b/inc/checkdatabasecommand.class.php index 33b16b05..d39d68fc 100644 --- a/inc/checkdatabasecommand.class.php +++ b/inc/checkdatabasecommand.class.php @@ -114,7 +114,7 @@ protected function execute(InputInterface $input, OutputInterface $output) ); foreach ($tables_consistency['missing'] as $entry) { $output->writeln(sprintf( - '-> container #%d (%s): expected table "%s" not found', + __('-> container #%d (%s): expected table "%s" not found', 'fields'), $entry['container_id'], $entry['itemtype'], $entry['table'], diff --git a/inc/container.class.php b/inc/container.class.php index bb11e7d4..46ff7ede 100644 --- a/inc/container.class.php +++ b/inc/container.class.php @@ -996,16 +996,30 @@ public static function renameOversizedContainer(int $id, string $new_name): bool return false; } - $new_name = preg_replace('/[^\da-zA-Z]/', '', $new_name) ?? ''; - if ($new_name === '') { - Session::AddMessageAfterRedirect(__('Invalid name.', 'fields'), false, ERROR); + $itemtypes = PluginFieldsToolbox::decodeJSONItemtypes($container->fields['itemtypes']); + if (!is_array($itemtypes) || $itemtypes === []) { + Session::AddMessageAfterRedirect(__('No associated item type.', 'fields'), false, ERROR); return false; } - $itemtypes = PluginFieldsToolbox::decodeJSONItemtypes($container->fields['itemtypes']); - if (!is_array($itemtypes) || $itemtypes === []) { - Session::AddMessageAfterRedirect(__('No associated item type.', 'fields'), false, ERROR); + $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; } From 7c2fa63fc566376d9733223d99bacfd22d8a2316 Mon Sep 17 00:00:00 2001 From: Rom1-B <8530352+Rom1-B@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:15:36 +0200 Subject: [PATCH 4/7] Fix: quarantine stale generated container class files on update --- inc/container.class.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/inc/container.class.php b/inc/container.class.php index 46ff7ede..5da49da7 100644 --- a/inc/container.class.php +++ b/inc/container.class.php @@ -242,6 +242,15 @@ 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]); @@ -496,6 +505,11 @@ public static function installUserData(Migration $migration, $version) 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; } From 86a20423878ba3889a40fb5f39bbe77ee4d655cb Mon Sep 17 00:00:00 2001 From: Rom1-B <8530352+Rom1-B@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:49:36 +0200 Subject: [PATCH 5/7] review :shark: --- inc/container.class.php | 29 +++++++++++++++++++++-------- tests/Units/ContainerTest.php | 29 ++++++++++++++++++++++++----- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/inc/container.class.php b/inc/container.class.php index 5da49da7..de8ee386 100644 --- a/inc/container.class.php +++ b/inc/container.class.php @@ -974,7 +974,7 @@ public static function preItemPurge($item) { $itemtype = $item::class; $containers = new self(); - $founded_containers = $containers->find(); + $founded_containers = $containers->find(['is_active' => 1]); foreach ($founded_containers as $container) { $itemtypes = PluginFieldsToolbox::decodeJSONItemtypes($container['itemtypes']); if (in_array($itemtype, $itemtypes)) { @@ -1031,7 +1031,8 @@ public static function renameOversizedContainer(int $id, string $new_name): bool return false; } - $new_name = preg_replace('/[^\da-zA-Z]/', '', $new_name) ?? ''; + $toolbox = new PluginFieldsToolbox(); + $new_name = $toolbox->getSystemNameFromLabel($new_name); if ($new_name === '') { Session::AddMessageAfterRedirect(__('Invalid name.', 'fields'), false, ERROR); @@ -1082,7 +1083,7 @@ public static function renameOversizedContainer(int $id, string $new_name): bool $old_table = getTableForItemType(self::getClassname($itemtype, $old_name)); if (!$DB->tableExists($old_table)) { - $old_table = self::findOrphanTableForContainer($id, $claimed_orphans); + $old_table = self::findOrphanTableForContainer($id, $itemtype, $claimed_orphans); } if ($old_table === null || !$DB->tableExists($old_table)) { @@ -1125,9 +1126,10 @@ public static function renameOversizedContainer(int $id, string $new_name): bool * Find an orphaned table belonging to this container. * * @param int $container_id Container ID. + * @param string $itemtype Item type the orphan table must have been generated for. * @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 + private static function findOrphanTableForContainer(int $container_id, string $itemtype, array $already_claimed): ?string { /** @var DBmysql $DB */ global $DB; @@ -1137,16 +1139,27 @@ private static function findOrphanTableForContainer(int $container_id, array $al $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. + // Primary match: `plugin_fields_containers_id` and `itemtype` are both DEFAULTed + // at table creation time, a link that survives any later name corruption. Matching + // on both is required when a container spans several item types, since every one + // of its orphan tables shares the same `plugin_fields_containers_id` default. $by_default = []; foreach ($orphaned as $table) { + $matches_container = false; + $matches_itemtype = false; foreach ($DB->listFields($table) as $column) { if ($column['Field'] === 'plugin_fields_containers_id' && (int) $column['Default'] === $container_id) { - $by_default[] = $table; - break; + $matches_container = true; + } + + if ($column['Field'] === 'itemtype' && $column['Default'] === $itemtype) { + $matches_itemtype = true; } } + + if ($matches_container && $matches_itemtype) { + $by_default[] = $table; + } } if (count($by_default) === 1) { diff --git a/tests/Units/ContainerTest.php b/tests/Units/ContainerTest.php index 5e0dfd9b..eadb3d5e 100644 --- a/tests/Units/ContainerTest.php +++ b/tests/Units/ContainerTest.php @@ -44,6 +44,7 @@ use PluginFieldsContainer; use PluginFieldsDropdown; use PluginFieldsField; +use PluginFieldsToolbox; use Search; use Session; use Ticket; @@ -584,17 +585,18 @@ public function testRenameOversizedContainerSucceeds(): void global $DB; $DB->update(PluginFieldsContainer::getTable(), ['name' => str_repeat('a', 100)], ['id' => $container->getID()]); - $new_name = 'Renamed' . str_replace('-', '', $this->getUniqueString()); + $new_name = 'Renamed' . str_replace('-', '', $this->getUniqueString()); + $expected_new_name = (new PluginFieldsToolbox())->getSystemNameFromLabel($new_name); $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($expected_new_name, $reloaded->fields['name']); $this->assertSame(1, (int) $reloaded->fields['is_active']); - $table = getTableForItemType(PluginFieldsContainer::getClassname(Computer::class, $new_name)); + $table = getTableForItemType(PluginFieldsContainer::getClassname(Computer::class, $expected_new_name)); $this->assertTrue($DB->tableExists($table)); // The reactivated container must be genuinely usable, not just flagged active. @@ -906,7 +908,11 @@ public function testRenameOversizedContainerFailsWhenNameIsStillTooLong(): void 'entities_id' => 0, 'is_recursive' => 1, ]); - $original_name = $container->fields['name']; + $short_name = $container->fields['name']; + + /** @var DBmysql $DB */ + global $DB; + $DB->update(PluginFieldsContainer::getTable(), ['name' => str_repeat('a', 100)], ['id' => $container->getID()]); $result = PluginFieldsContainer::renameOversizedContainer($container->getID(), str_repeat('a', 100)); @@ -914,8 +920,12 @@ public function testRenameOversizedContainerFailsWhenNameIsStillTooLong(): void $reloaded = new PluginFieldsContainer(); $this->assertTrue($reloaded->getFromDB($container->getID())); - $this->assertSame($original_name, $reloaded->fields['name']); + $this->assertSame(str_repeat('a', 100), $reloaded->fields['name']); $this->assertSame(0, (int) $reloaded->fields['is_active']); + + // Restore a table-name-safe value so the deletion done in tearDown does not + // itself attempt a DROP TABLE with an over-64-char identifier. + $DB->update(PluginFieldsContainer::getTable(), ['name' => $short_name], ['id' => $container->getID()]); } public function testRenameOversizedContainerFailsOnNameCollision(): void @@ -937,6 +947,11 @@ public function testRenameOversizedContainerFailsOnNameCollision(): void 'entities_id' => 0, 'is_recursive' => 1, ]); + $short_name = $container->fields['name']; + + /** @var DBmysql $DB */ + global $DB; + $DB->update(PluginFieldsContainer::getTable(), ['name' => str_repeat('a', 100)], ['id' => $container->getID()]); $result = PluginFieldsContainer::renameOversizedContainer($container->getID(), $existing->fields['name']); @@ -945,5 +960,9 @@ public function testRenameOversizedContainerFailsOnNameCollision(): void $reloaded = new PluginFieldsContainer(); $this->assertTrue($reloaded->getFromDB($container->getID())); $this->assertSame(0, (int) $reloaded->fields['is_active']); + + // Restore a table-name-safe value so the deletion done in tearDown does not + // itself attempt a DROP TABLE with an over-64-char identifier. + $DB->update(PluginFieldsContainer::getTable(), ['name' => $short_name], ['id' => $container->getID()]); } } From 6819668b4cc712438d794f71d5c0c62f5cae2e6d Mon Sep 17 00:00:00 2001 From: Rom1-B <8530352+Rom1-B@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:09:01 +0200 Subject: [PATCH 6/7] stan --- inc/container.class.php | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/inc/container.class.php b/inc/container.class.php index de8ee386..43592919 100644 --- a/inc/container.class.php +++ b/inc/container.class.php @@ -972,14 +972,22 @@ public function pre_deleteItem() public static function preItemPurge($item) { + /** @var DBmysql $DB */ + global $DB; + $itemtype = $item::class; $containers = new self(); - $founded_containers = $containers->find(['is_active' => 1]); + $founded_containers = $containers->find(); foreach ($founded_containers as $container) { $itemtypes = PluginFieldsToolbox::decodeJSONItemtypes($container['itemtypes']); if (in_array($itemtype, $itemtypes)) { $classname = self::getClassname($itemtype, $container['name']); - $fields = new $classname(); + if (!$DB->tableExists($classname::getTable())) { + // Disabled oversized container: no table to purge from. + continue; + } + + $fields = new $classname(); $fields->deleteByCriteria(['items_id' => $item->fields['id']], true); } } @@ -1075,7 +1083,19 @@ public static function renameOversizedContainer(int $id, string $new_name): bool $migration = new Migration((string) ($plugin->fields['version'] ?? '')); - $old_name = $container->fields['name']; + $old_name = $container->fields['name']; + + foreach ($itemtypes as $itemtype) { + $new_table = getTableForItemType(self::getClassname($itemtype, $new_name)); + $old_table = getTableForItemType(self::getClassname($itemtype, $old_name)); + + if ($new_table !== $old_table && $DB->tableExists($new_table)) { + Session::AddMessageAfterRedirect(sprintf(__('Table %s already exists.', 'fields'), $new_table), false, ERROR); + + return false; + } + } + $claimed_orphans = []; $data_preserved = false; foreach ($itemtypes as $itemtype) { From c8f511691ba2bf208960a1b8f6f7ee5915759e89 Mon Sep 17 00:00:00 2001 From: Stanislas Date: Thu, 1 Oct 2026 15:57:57 +0200 Subject: [PATCH 7/7] Update inc/container.class.php --- inc/container.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/container.class.php b/inc/container.class.php index 43592919..4b667af6 100644 --- a/inc/container.class.php +++ b/inc/container.class.php @@ -982,7 +982,7 @@ public static function preItemPurge($item) $itemtypes = PluginFieldsToolbox::decodeJSONItemtypes($container['itemtypes']); if (in_array($itemtype, $itemtypes)) { $classname = self::getClassname($itemtype, $container['name']); - if (!$DB->tableExists($classname::getTable())) { + if (!$DB->tableExists(getTableForItemType($classname))) { // Disabled oversized container: no table to purge from. continue; }