diff --git a/inc/checkdatabasecommand.class.php b/inc/checkdatabasecommand.class.php
new file mode 100644
index 00000000..955b4594
--- /dev/null
+++ b/inc/checkdatabasecommand.class.php
@@ -0,0 +1,107 @@
+.
+ * -------------------------------------------------------------------------
+ * @copyright Copyright (C) 2013-2022 by Fields plugin team.
+ * @license GPLv2 https://www.gnu.org/licenses/gpl-2.0.html
+ * @link https://github.com/pluginsGLPI/fields
+ * -------------------------------------------------------------------------
+ */
+
+use Glpi\Console\AbstractCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class PluginFieldsCheckDatabaseCommand extends AbstractCommand
+{
+ protected function configure()
+ {
+ $this->setName('plugin:fields:check_database');
+ $this->setDescription(__('Check database to detect inconsistencies.', 'fields'));
+ $this->setHelp(
+ __('This command will chec database to detect following inconsistencies:', 'fields')
+ . "\n"
+ . sprintf(
+ __('- some deleted fields may still be present in database (bug introduced in %s and fixed in version %s)', 'fields'),
+ '1.15.0',
+ '1.15.3'
+ )
+ );
+
+ $this->addOption(
+ 'fix',
+ null,
+ InputOption::VALUE_NONE,
+ __('Use this option to actually fix database', 'fields')
+ );
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output)
+ {
+ // Read option
+ $fix = $input->getOption('fix');
+
+ $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) {
+ $output->writeln(
+ '' . __('Everything is in order - no action needed.', 'fields') . '',
+ );
+ return Command::SUCCESS;
+ }
+
+ // Indicate which fields will have been or must be deleted
+ $error = $fix
+ ? sprintf(__('Database was containing %s gone field(s).', 'fields'), $dead_fields_count)
+ : sprintf(__('Database contains %s gone 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);
+ }
+ }
+
+ // Show extra info in dry-run mode
+ if (!$fix) {
+ // Print command to do the actual deletion
+ $next_command = sprintf(
+ __('Run "%s" to delete the found field(s).', 'fields'),
+ sprintf("php bin/console %s --fix", $this->getName())
+ );
+ $output->writeln(
+ '' . $next_command . '',
+ OutputInterface::VERBOSITY_QUIET
+ );
+ }
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/inc/migration.class.php b/inc/migration.class.php
index bcf3c33d..b66a4c04 100644
--- a/inc/migration.class.php
+++ b/inc/migration.class.php
@@ -81,4 +81,129 @@ public static function getSQLFields(string $field_name, string $field_type): arr
return $fields;
}
+
+ /**
+ * An issue affected field removal in 1.15.0, 1.15.1 and 1.15.2.
+ * Using these versions, removing a field from a container would drop the
+ * field from glpi_plugin_fields_fields but not from the custom container
+ * table
+ *
+ * This function looks into containers tables for fields that
+ * should have been removed and list them.
+ * If parameter $fix is true, fields are deleted from database.
+ *
+ * @param bool $fix
+ *
+ * @return array
+ */
+ public static function checkDeadFields(bool $fix): array
+ {
+ /** @var DBMysql $DB */
+ global $DB;
+
+ $dead_fields = [];
+
+ // For each existing container
+ $containers = (new PluginFieldsContainer())->find([]);
+ foreach ($containers as $row) {
+ // Get expected fields
+ $valid_fields = self::getValidFieldsForContainer($row['id']);
+
+ // Read itemtypes and container name
+ $itemtypes = importArrayFromDB($row['itemtypes']);
+ $name = $row['name'];
+
+ // One table to handle per itemtype
+ foreach ($itemtypes as $itemtype) {
+ // Build table name
+ $table = getTableForItemType("PluginFields{$itemtype}{$name}");
+
+ if (!$DB->tableExists($table)) {
+ // Missing table; skip (abnormal)
+ continue;
+ }
+
+ // Get the actual fields defined in the container table
+ $found_fields = self::getCustomFieldsInContainerTable($table);
+
+ // Compute which fields should be removed
+ $fields_to_drop = array_diff($found_fields, $valid_fields);
+
+ if (count($fields_to_drop) > 0) {
+ $dead_fields[$table] = $fields_to_drop;
+ }
+ }
+ }
+
+ if ($fix) {
+ $migration = new PluginFieldsMigration(0);
+
+ foreach ($dead_fields as $table => $fields) {
+ foreach ($fields as $field) {
+ $migration->dropField($table, $field);
+ }
+ }
+
+ $migration->executeMigration();
+ }
+
+ return $dead_fields;
+ }
+
+ /**
+ * Get all fields defined for a container in glpi_plugin_fields_fields
+ *
+ * @param int $container_id Id of the container
+ *
+ * @return array
+ */
+ private static function getValidFieldsForContainer(int $container_id): array
+ {
+ $valid_fields = [];
+
+ // For each defined fields in the given container
+ $fields = (new PluginFieldsField())->find(['plugin_fields_containers_id' => $container_id]);
+ foreach ($fields as $row) {
+ $fields = self::getSQLFields($row['name'], $row['type']);
+ array_push($valid_fields, ...array_keys($fields));
+ }
+
+ return $valid_fields;
+ }
+
+ /**
+ * Get custom fields in a given container table
+ * This means all fields found in the table expect those defined in
+ * $basic_fields
+ *
+ * @param string $table
+ *
+ * @return array
+ */
+ private static function getCustomFieldsInContainerTable(
+ string $table
+ ): array {
+ /** @var DBMysql $DB */
+ global $DB;
+
+ // Read table fields
+ $fields = $DB->listFields($table);
+
+ // Reduce to fields name only
+ $fields = array_column($fields, "Field");
+
+ // Remove basic fields
+ $basic_fields = [
+ 'id',
+ 'items_id',
+ 'itemtype',
+ 'plugin_fields_containers_id',
+ ];
+ return array_filter(
+ $fields,
+ function (string $field) use ($basic_fields) {
+ return !in_array($field, $basic_fields);
+ }
+ );
+ }
}