]> git.basschouten.com Git - openhab-addons.git/commitdiff
[jdbc] Add console maintenance commands (#13662)
authorJacob Laursen <jacob-github@vindvejr.dk>
Sat, 12 Nov 2022 11:30:53 +0000 (12:30 +0100)
committerGitHub <noreply@github.com>
Sat, 12 Nov 2022 11:30:53 +0000 (12:30 +0100)
* Add console command for listing tables
* Query row counts only when needed and while generating output
* Add cleanup command
* Add documentation

Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
bundles/org.openhab.persistence.jdbc/README.md
bundles/org.openhab.persistence.jdbc/src/main/java/org/openhab/persistence/jdbc/ItemTableCheckEntry.java [new file with mode: 0644]
bundles/org.openhab.persistence.jdbc/src/main/java/org/openhab/persistence/jdbc/ItemTableCheckEntryStatus.java [new file with mode: 0644]
bundles/org.openhab.persistence.jdbc/src/main/java/org/openhab/persistence/jdbc/console/JdbcCommandExtension.java [new file with mode: 0644]
bundles/org.openhab.persistence.jdbc/src/main/java/org/openhab/persistence/jdbc/db/JdbcBaseDAO.java
bundles/org.openhab.persistence.jdbc/src/main/java/org/openhab/persistence/jdbc/internal/JdbcMapper.java
bundles/org.openhab.persistence.jdbc/src/main/java/org/openhab/persistence/jdbc/internal/JdbcPersistenceService.java
bundles/org.openhab.persistence.jdbc/src/main/java/org/openhab/persistence/jdbc/internal/JdbcPersistenceServiceConstants.java [new file with mode: 0644]

index bc20c206a6be0a968ea1f0840f562dccacbc0ab6..bec3af3fd330468158d2c15bc23c8c26a1c25320 100644 (file)
@@ -29,6 +29,7 @@ The following databases are currently supported and tested:
        - [Database Table Schema](#database-table-schema)
        - [Number Precision](#number-precision)
        - [Rounding results](#rounding-results)
+       - [Maintenance](#maintenance)
        - [For Developers](#for-developers)
        - [Performance Tests](#performance-tests)
 
@@ -138,6 +139,62 @@ The results of database queries of number items are rounded to three decimal pla
 With `numberDecimalcount` decimals can be changed.
 Especially if sql types `DECIMAL` or  `NUMERIC` are used for `sqltype.NUMBER`, rounding can be disabled by setting `numberDecimalcount=-1`.
 
+### Maintenance
+
+Some maintenance tools are provided as console commands.
+
+#### List Tables
+
+Tables and corresponding items can be listed with the command `jdbc tables list`.
+Per default only tables with some kind of problem are listed.
+To list all tables, use the command `jdbc tables list all`.
+
+The list contains table name, item name, row count and status, which can be one of:
+
+- **Valid:** Table is consistent.
+- **Item missing:** Table has no corresponding item.
+- **Table missing:** Referenced table does not exist.
+- **Item and table missing:** Referenced table does not exist nor has corresponding item.
+- **Orphan table:** Mapping for table does not exist in index.
+
+#### Clean Inconsistent Items
+
+Some issues can be fixed automatically using the command `jdbc clean` (all items having issues) or `jdbc clean <itemName>` (single item).
+This cleanup operation will remove items from the index (table `Items`) if the referenced table does not exist.
+
+If the item does not exist, the table will be physically deleted, but only if it's empty.
+This precaution is taken because items may have existed previously, and the data might still be valuable.
+For example, an item for a lost or repurposed sensor could have been deleted from the system while preserving persisted data.
+To skip this check for a single item, use `jdbc clean <itemName> force` with care.
+
+Prior to performing a `jdbc clean` operation, it's recommended to review the result of `jdbc tables list`.
+
+Fixing integrity issues can be useful before performing a migration to another naming scheme.
+For example, when migrating to `tableCaseSensitiveItemNames`, an index will no longer exist after the migration:
+
+**Before migration:**
+
+| Table             | Row count | Item   | Status        |
+|-------------------|---------: |--------|---------------|
+| ActualItem        |         0 |        | Orphan table  |
+| TableNotBelonging |         0 |        | Orphan table  |
+| item0077          |         0 | MyItem | Table missing |
+
+**After migration:**
+
+| Table             | Row count | Item              | Status        |
+|-------------------|---------: |-------------------|---------------|
+| ActualItem        |         0 | ActualItem        | Valid         |
+| TableNotBelonging |         0 | TableNotBelonging | Item missing  |
+
+This happened:
+
+- `ActualItem` was missing in the index and became valid because it was left untouched, not being a part of the migration. After the migration, it happened to match the name of an existing item, thus it became valid.
+- `TableNotBelonging` was also not part of the migration, but since now assumed to match an item, status changed since no item with that name exists.
+- `item0077`, being the only correct table name according to previous naming scheme, disappeared from the list since it didn't have a corresponding table, and is now no longer part of any index.
+
+In other words, extracting this information from the index before removing it, can be beneficial in order to understand the issues and possible causes.
+
 ### For Developers
 
 * Clearly separated source files for the database-specific part of openHAB logic.
diff --git a/bundles/org.openhab.persistence.jdbc/src/main/java/org/openhab/persistence/jdbc/ItemTableCheckEntry.java b/bundles/org.openhab.persistence.jdbc/src/main/java/org/openhab/persistence/jdbc/ItemTableCheckEntry.java
new file mode 100644 (file)
index 0000000..e644138
--- /dev/null
@@ -0,0 +1,45 @@
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.persistence.jdbc;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * This class represents a checked item/table relation.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public class ItemTableCheckEntry {
+    private String itemName;
+    private String tableName;
+    private ItemTableCheckEntryStatus status;
+
+    public ItemTableCheckEntry(String itemName, String tableName, ItemTableCheckEntryStatus status) {
+        this.itemName = itemName;
+        this.tableName = tableName;
+        this.status = status;
+    }
+
+    public String getItemName() {
+        return itemName;
+    }
+
+    public String getTableName() {
+        return tableName;
+    }
+
+    public ItemTableCheckEntryStatus getStatus() {
+        return status;
+    }
+}
diff --git a/bundles/org.openhab.persistence.jdbc/src/main/java/org/openhab/persistence/jdbc/ItemTableCheckEntryStatus.java b/bundles/org.openhab.persistence.jdbc/src/main/java/org/openhab/persistence/jdbc/ItemTableCheckEntryStatus.java
new file mode 100644 (file)
index 0000000..3553aee
--- /dev/null
@@ -0,0 +1,69 @@
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.persistence.jdbc;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * This class represents status for an {@link ItemTableCheckEntry}.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public enum ItemTableCheckEntryStatus {
+    /**
+     * Table is consistent.
+     */
+    VALID {
+        @Override
+        public String toString() {
+            return "Valid";
+        }
+    },
+    /**
+     * Table has no corresponding item.
+     */
+    ITEM_MISSING {
+        @Override
+        public String toString() {
+            return "Item missing";
+        }
+    },
+    /**
+     * Referenced table does not exist.
+     */
+    TABLE_MISSING {
+        @Override
+        public String toString() {
+            return "Table missing";
+        }
+    },
+    /**
+     * Referenced table does not exist nor has corresponding item.
+     */
+    ITEM_AND_TABLE_MISSING {
+        @Override
+        public String toString() {
+            return "Item and table missing";
+        }
+    },
+    /**
+     * Mapping for table does not exist in index.
+     */
+    ORPHAN_TABLE {
+        @Override
+        public String toString() {
+            return "Orphan table";
+        }
+    }
+}
diff --git a/bundles/org.openhab.persistence.jdbc/src/main/java/org/openhab/persistence/jdbc/console/JdbcCommandExtension.java b/bundles/org.openhab.persistence.jdbc/src/main/java/org/openhab/persistence/jdbc/console/JdbcCommandExtension.java
new file mode 100644 (file)
index 0000000..0bcab36
--- /dev/null
@@ -0,0 +1,191 @@
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.persistence.jdbc.console;
+
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.io.console.Console;
+import org.openhab.core.io.console.ConsoleCommandCompleter;
+import org.openhab.core.io.console.StringsCompleter;
+import org.openhab.core.io.console.extensions.AbstractConsoleCommandExtension;
+import org.openhab.core.io.console.extensions.ConsoleCommandExtension;
+import org.openhab.core.persistence.PersistenceService;
+import org.openhab.core.persistence.PersistenceServiceRegistry;
+import org.openhab.persistence.jdbc.ItemTableCheckEntry;
+import org.openhab.persistence.jdbc.ItemTableCheckEntryStatus;
+import org.openhab.persistence.jdbc.internal.JdbcPersistenceService;
+import org.openhab.persistence.jdbc.internal.JdbcPersistenceServiceConstants;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+
+/**
+ * The {@link JdbcCommandExtension} is responsible for handling console commands
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = ConsoleCommandExtension.class)
+public class JdbcCommandExtension extends AbstractConsoleCommandExtension implements ConsoleCommandCompleter {
+
+    private static final String CMD_TABLES = "tables";
+    private static final String SUBCMD_TABLES_LIST = "list";
+    private static final String SUBCMD_TABLES_CLEAN = "clean";
+    private static final String PARAMETER_ALL = "all";
+    private static final String PARAMETER_FORCE = "force";
+    private static final StringsCompleter CMD_COMPLETER = new StringsCompleter(List.of(CMD_TABLES), false);
+    private static final StringsCompleter SUBCMD_TABLES_COMPLETER = new StringsCompleter(
+            List.of(SUBCMD_TABLES_LIST, SUBCMD_TABLES_CLEAN), false);
+
+    private final PersistenceServiceRegistry persistenceServiceRegistry;
+
+    @Activate
+    public JdbcCommandExtension(final @Reference PersistenceServiceRegistry persistenceServiceRegistry) {
+        super(JdbcPersistenceServiceConstants.SERVICE_ID, "Interact with the JDBC persistence service.");
+        this.persistenceServiceRegistry = persistenceServiceRegistry;
+    }
+
+    @Override
+    public void execute(String[] args, Console console) {
+        if (args.length < 2 || args.length > 4 || !CMD_TABLES.equals(args[0])) {
+            printUsage(console);
+            return;
+        }
+        JdbcPersistenceService persistenceService = getPersistenceService();
+        if (persistenceService == null) {
+            return;
+        }
+        if (SUBCMD_TABLES_LIST.equalsIgnoreCase(args[1])) {
+            listTables(persistenceService, console, args.length == 3 && PARAMETER_ALL.equalsIgnoreCase(args[2]));
+            return;
+        } else if (SUBCMD_TABLES_CLEAN.equalsIgnoreCase(args[1])) {
+            if (args.length == 3) {
+                cleanupItem(persistenceService, console, args[2], false);
+                return;
+            } else if (args.length == 4 && PARAMETER_FORCE.equalsIgnoreCase(args[3])) {
+                cleanupItem(persistenceService, console, args[2], true);
+                return;
+            } else {
+                cleanupTables(persistenceService, console);
+                return;
+            }
+        }
+        printUsage(console);
+    }
+
+    private @Nullable JdbcPersistenceService getPersistenceService() {
+        for (PersistenceService persistenceService : persistenceServiceRegistry.getAll()) {
+            if (persistenceService instanceof JdbcPersistenceService) {
+                return (JdbcPersistenceService) persistenceService;
+            }
+        }
+        return null;
+    }
+
+    private void listTables(JdbcPersistenceService persistenceService, Console console, Boolean all) {
+        List<ItemTableCheckEntry> entries = persistenceService.getCheckedEntries();
+        if (!all) {
+            entries.removeIf(t -> t.getStatus() == ItemTableCheckEntryStatus.VALID);
+        }
+        entries.sort(Comparator.comparing(ItemTableCheckEntry::getTableName));
+        int itemNameMaxLength = Math
+                .max(entries.stream().map(t -> t.getItemName().length()).max(Integer::compare).get(), 4);
+        int tableNameMaxLength = Math
+                .max(entries.stream().map(t -> t.getTableName().length()).max(Integer::compare).get(), 5);
+        int statusMaxLength = Stream.of(ItemTableCheckEntryStatus.values()).map(t -> t.toString().length())
+                .max(Integer::compare).get();
+        console.println(String.format(
+                "%1$-" + (tableNameMaxLength + 2) + "sRow Count  %2$-" + (itemNameMaxLength + 2) + "s%3$s", "Table",
+                "Item", "Status"));
+        console.println("-".repeat(tableNameMaxLength) + "  " + "---------  " + "-".repeat(itemNameMaxLength) + "  "
+                + "-".repeat(statusMaxLength));
+        for (ItemTableCheckEntry entry : entries) {
+            String tableName = entry.getTableName();
+            ItemTableCheckEntryStatus status = entry.getStatus();
+            long rowCount = status == ItemTableCheckEntryStatus.VALID
+                    || status == ItemTableCheckEntryStatus.ITEM_MISSING ? persistenceService.getRowCount(tableName) : 0;
+            console.println(String.format(
+                    "%1$-" + (tableNameMaxLength + 2) + "s%2$9d  %3$-" + (itemNameMaxLength + 2) + "s%4$s", tableName,
+                    rowCount, entry.getItemName(), status));
+        }
+    }
+
+    private void cleanupTables(JdbcPersistenceService persistenceService, Console console) {
+        console.println("Cleaning up all inconsistent items...");
+        List<ItemTableCheckEntry> entries = persistenceService.getCheckedEntries();
+        entries.removeIf(t -> t.getStatus() == ItemTableCheckEntryStatus.VALID || t.getItemName().isEmpty());
+        for (ItemTableCheckEntry entry : entries) {
+            console.print(entry.getItemName() + " -> ");
+            if (persistenceService.cleanupItem(entry)) {
+                console.println("done.");
+            } else {
+                console.println("skipped/failed.");
+            }
+        }
+    }
+
+    private void cleanupItem(JdbcPersistenceService persistenceService, Console console, String itemName,
+            boolean force) {
+        console.print("Cleaning up item " + itemName + "... ");
+        if (persistenceService.cleanupItem(itemName, force)) {
+            console.println("done.");
+        } else {
+            console.println("skipped/failed.");
+        }
+    }
+
+    @Override
+    public List<String> getUsages() {
+        return Arrays.asList(
+                buildCommandUsage(CMD_TABLES + " " + SUBCMD_TABLES_LIST + " [" + PARAMETER_ALL + "]",
+                        "list tables (all = include valid)"),
+                buildCommandUsage(
+                        CMD_TABLES + " " + SUBCMD_TABLES_CLEAN + " [<itemName>]" + " [" + PARAMETER_FORCE + "]",
+                        "clean inconsistent items (remove from index and drop tables)"));
+    }
+
+    @Override
+    public @Nullable ConsoleCommandCompleter getCompleter() {
+        return this;
+    }
+
+    @Override
+    public boolean complete(String[] args, int cursorArgumentIndex, int cursorPosition, List<String> candidates) {
+        if (cursorArgumentIndex <= 0) {
+            return CMD_COMPLETER.complete(args, cursorArgumentIndex, cursorPosition, candidates);
+        } else if (cursorArgumentIndex == 1) {
+            if (CMD_TABLES.equalsIgnoreCase(args[0])) {
+                return SUBCMD_TABLES_COMPLETER.complete(args, cursorArgumentIndex, cursorPosition, candidates);
+            }
+        } else if (cursorArgumentIndex == 2) {
+            if (CMD_TABLES.equalsIgnoreCase(args[0])) {
+                if (SUBCMD_TABLES_CLEAN.equalsIgnoreCase(args[1])) {
+                    JdbcPersistenceService persistenceService = getPersistenceService();
+                    if (persistenceService != null) {
+                        return new StringsCompleter(persistenceService.getItemNames(), true).complete(args,
+                                cursorArgumentIndex, cursorPosition, candidates);
+                    }
+                } else if (SUBCMD_TABLES_LIST.equalsIgnoreCase(args[1])) {
+                    new StringsCompleter(List.of(PARAMETER_ALL), false).complete(args, cursorArgumentIndex,
+                            cursorPosition, candidates);
+                }
+            }
+        }
+        return false;
+    }
+}
index 6bc19dc7ab16e0aba9d1be60fa12f1367c2d96f1..393635d968c41e88f8633de181d6d31cf45062f3 100644 (file)
@@ -85,11 +85,13 @@ public class JdbcBaseDAO {
     protected String sqlCreateNewEntryInItemsTable = "INSERT INTO #itemsManageTable# (ItemName) VALUES ('#itemname#')";
     protected String sqlCreateItemsTableIfNot = "CREATE TABLE IF NOT EXISTS #itemsManageTable# (ItemId INT NOT NULL AUTO_INCREMENT,#colname# #coltype# NOT NULL,PRIMARY KEY (ItemId))";
     protected String sqlDropItemsTableIfExists = "DROP TABLE IF EXISTS #itemsManageTable#";
-    protected String sqlDeleteItemsEntry = "DELETE FROM items WHERE ItemName=#itemname#";
+    protected String sqlDropTable = "DROP TABLE #tableName#";
+    protected String sqlDeleteItemsEntry = "DELETE FROM #itemsManageTable# WHERE ItemName='#itemname#'";
     protected String sqlGetItemIDTableNames = "SELECT ItemId, ItemName FROM #itemsManageTable#";
     protected String sqlGetItemTables = "SELECT table_name FROM information_schema.tables WHERE table_type='BASE TABLE' AND table_schema='#jdbcUriDatabaseName#' AND NOT table_name='#itemsManageTable#'";
     protected String sqlCreateItemTable = "CREATE TABLE IF NOT EXISTS #tableName# (time #tablePrimaryKey# NOT NULL, value #dbType#, PRIMARY KEY(time))";
     protected String sqlInsertItemValue = "INSERT INTO #tableName# (TIME, VALUE) VALUES( #tablePrimaryValue#, ? ) ON DUPLICATE KEY UPDATE VALUE= ?";
+    protected String sqlGetRowCount = "SELECT COUNT(*) FROM #tableName#";
 
     /********
      * INIT *
@@ -264,6 +266,14 @@ public class JdbcBaseDAO {
         return Objects.nonNull(result);
     }
 
+    public boolean doIfTableExists(String tableName) {
+        String sql = StringUtilsExt.replaceArrayMerge(sqlIfTableExists, new String[] { "#searchTable#" },
+                new String[] { tableName });
+        logger.debug("JDBC::doIfTableExists sql={}", sql);
+        final @Nullable String result = Yank.queryScalar(sql, String.class, null);
+        return Objects.nonNull(result);
+    }
+
     public Long doCreateNewEntryInItemsTable(ItemsVO vo) {
         String sql = StringUtilsExt.replaceArrayMerge(sqlCreateNewEntryInItemsTable,
                 new String[] { "#itemsManageTable#", "#itemname#" },
@@ -289,9 +299,17 @@ public class JdbcBaseDAO {
         return vo;
     }
 
+    public void doDropTable(String tableName) {
+        String sql = StringUtilsExt.replaceArrayMerge(sqlDropTable, new String[] { "#tableName#" },
+                new String[] { tableName });
+        logger.debug("JDBC::doDropTable sql={}", sql);
+        Yank.execute(sql, null);
+    }
+
     public void doDeleteItemsEntry(ItemsVO vo) {
-        String sql = StringUtilsExt.replaceArrayMerge(sqlDeleteItemsEntry, new String[] { "#itemname#" },
-                new String[] { vo.getItemName() });
+        String sql = StringUtilsExt.replaceArrayMerge(sqlDeleteItemsEntry,
+                new String[] { "#itemsManageTable#", "#itemname#" },
+                new String[] { vo.getItemsManageTable(), vo.getItemName() });
         logger.debug("JDBC::doDeleteItemsEntry sql={}", sql);
         Yank.execute(sql, null);
     }
@@ -373,6 +391,14 @@ public class JdbcBaseDAO {
         Yank.execute(sql, null);
     }
 
+    public long doGetRowCount(String tableName) {
+        final String sql = StringUtilsExt.replaceArrayMerge(sqlGetRowCount, new String[] { "#tableName#" },
+                new String[] { tableName });
+        logger.debug("JDBC::doGetRowCount sql={}", sql);
+        final @Nullable Long result = Yank.queryScalar(sql, Long.class, null);
+        return Objects.requireNonNullElse(result, 0L);
+    }
+
     /*************
      * Providers *
      *************/
index f59e7c8b6bd053acbddd8a9eeeff199f9ba850d9..c956e706ecda8b0708d9f81d242b8d4c79969714 100644 (file)
@@ -106,6 +106,14 @@ public class JdbcMapper {
         return res;
     }
 
+    public boolean ifTableExists(String tableName) {
+        logger.debug("JDBC::ifTableExists");
+        long timerStart = System.currentTimeMillis();
+        boolean res = conf.getDBDAO().doIfTableExists(tableName);
+        logTime("doIfTableExists", timerStart, System.currentTimeMillis());
+        return res;
+    }
+
     public ItemsVO createNewEntryInItemsTable(ItemsVO vo) {
         logger.debug("JDBC::createNewEntryInItemsTable");
         long timerStart = System.currentTimeMillis();
@@ -131,6 +139,13 @@ public class JdbcMapper {
         return true;
     }
 
+    public void dropTable(String tableName) {
+        logger.debug("JDBC::dropTable");
+        long timerStart = System.currentTimeMillis();
+        conf.getDBDAO().doDropTable(tableName);
+        logTime("doDropTable", timerStart, System.currentTimeMillis());
+    }
+
     public ItemsVO deleteItemsEntry(ItemsVO vo) {
         logger.debug("JDBC::deleteItemsEntry");
         long timerStart = System.currentTimeMillis();
@@ -189,6 +204,10 @@ public class JdbcMapper {
         return item;
     }
 
+    public long getRowCount(String tableName) {
+        return conf.getDBDAO().doGetRowCount(tableName);
+    }
+
     public List<HistoricItem> getHistItemFilterQuery(FilterCriteria filter, int numberDecimalcount, String table,
             Item item) {
         logger.debug(
@@ -350,7 +369,7 @@ public class JdbcMapper {
         }
 
         List<ItemsVO> itemIdTableNames = ifItemsTableExists() ? getItemIDTableNames() : new ArrayList<ItemsVO>();
-        List<String> itemTables = getItemTables().stream().map(t -> t.getTableName()).collect(Collectors.toList());
+        var itemTables = getItemTables().stream().map(ItemsVO::getTableName).collect(Collectors.toList());
         List<ItemVO> oldNewTableNames;
 
         if (itemIdTableNames.isEmpty()) {
index bac25c3e5a11ea2bb54fa6c3e7369f185cb49f9c..d267ad097045dc11b6f4385ff19bd7e908dd7f87 100644 (file)
 package org.openhab.persistence.jdbc.internal;
 
 import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Date;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Map.Entry;
 import java.util.Set;
+import java.util.stream.Collectors;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
@@ -36,6 +40,9 @@ import org.openhab.core.persistence.QueryablePersistenceService;
 import org.openhab.core.persistence.strategy.PersistenceStrategy;
 import org.openhab.core.types.State;
 import org.openhab.core.types.UnDefType;
+import org.openhab.persistence.jdbc.ItemTableCheckEntry;
+import org.openhab.persistence.jdbc.ItemTableCheckEntryStatus;
+import org.openhab.persistence.jdbc.dto.ItemsVO;
 import org.osgi.framework.BundleContext;
 import org.osgi.framework.Constants;
 import org.osgi.service.component.annotations.Activate;
@@ -55,13 +62,9 @@ import org.slf4j.LoggerFactory;
 @Component(service = { PersistenceService.class,
         QueryablePersistenceService.class }, configurationPid = "org.openhab.jdbc", //
         property = Constants.SERVICE_PID + "=org.openhab.jdbc")
-@ConfigurableService(category = "persistence", label = "JDBC Persistence Service", description_uri = JdbcPersistenceService.CONFIG_URI)
+@ConfigurableService(category = "persistence", label = "JDBC Persistence Service", description_uri = JdbcPersistenceServiceConstants.CONFIG_URI)
 public class JdbcPersistenceService extends JdbcMapper implements ModifiablePersistenceService {
 
-    private static final String SERVICE_ID = "jdbc";
-    private static final String SERVICE_LABEL = "JDBC";
-    protected static final String CONFIG_URI = "persistence:jdbc";
-
     private final Logger logger = LoggerFactory.getLogger(JdbcPersistenceService.class);
 
     private final ItemRegistry itemRegistry;
@@ -116,12 +119,12 @@ public class JdbcPersistenceService extends JdbcMapper implements ModifiablePers
     @Override
     public String getId() {
         logger.debug("JDBC::getName: returning name 'jdbc' for queryable persistence service.");
-        return SERVICE_ID;
+        return JdbcPersistenceServiceConstants.SERVICE_ID;
     }
 
     @Override
     public String getLabel(@Nullable Locale locale) {
-        return SERVICE_LABEL;
+        return JdbcPersistenceServiceConstants.SERVICE_LABEL;
     }
 
     @Override
@@ -275,4 +278,122 @@ public class JdbcPersistenceService extends JdbcMapper implements ModifiablePers
 
         return result;
     }
+
+    /**
+     * Get a list of names of persisted items.
+     */
+    public Collection<String> getItemNames() {
+        return itemNameToTableNameMap.keySet();
+    }
+
+    /**
+     * Get a list of all items with corresponding tables and an {@link ItemTableCheckEntryStatus} indicating
+     * its condition.
+     *
+     * @return list of {@link ItemTableCheckEntry}
+     */
+    public List<ItemTableCheckEntry> getCheckedEntries() {
+        List<ItemTableCheckEntry> entries = new ArrayList<>();
+
+        if (!checkDBAccessability()) {
+            logger.warn("JDBC::getCheckedEntries: database not connected");
+            return entries;
+        }
+
+        var orphanTables = getItemTables().stream().map(ItemsVO::getTableName).collect(Collectors.toSet());
+        for (Entry<String, String> entry : itemNameToTableNameMap.entrySet()) {
+            String itemName = entry.getKey();
+            String tableName = entry.getValue();
+            entries.add(getCheckedEntry(itemName, tableName, orphanTables.contains(tableName)));
+            orphanTables.remove(tableName);
+        }
+        for (String orphanTable : orphanTables) {
+            entries.add(new ItemTableCheckEntry("", orphanTable, ItemTableCheckEntryStatus.ORPHAN_TABLE));
+        }
+        return entries;
+    }
+
+    private ItemTableCheckEntry getCheckedEntry(String itemName, String tableName, boolean tableExists) {
+        boolean itemExists;
+        try {
+            itemRegistry.getItem(itemName);
+            itemExists = true;
+        } catch (ItemNotFoundException e) {
+            itemExists = false;
+        }
+
+        ItemTableCheckEntryStatus status;
+        if (!tableExists) {
+            if (itemExists) {
+                status = ItemTableCheckEntryStatus.TABLE_MISSING;
+            } else {
+                status = ItemTableCheckEntryStatus.ITEM_AND_TABLE_MISSING;
+            }
+        } else if (itemExists) {
+            status = ItemTableCheckEntryStatus.VALID;
+        } else {
+            status = ItemTableCheckEntryStatus.ITEM_MISSING;
+        }
+        return new ItemTableCheckEntry(itemName, tableName, status);
+    }
+
+    /**
+     * Clean up inconsistent item: Remove from index and drop table.
+     * Tables with any rows are skipped, unless force is set.
+     *
+     * @param itemName Name of item to clean
+     * @param force If true, non-empty tables will be dropped too
+     * @return true if item was cleaned up
+     */
+    public boolean cleanupItem(String itemName, boolean force) {
+        String tableName = itemNameToTableNameMap.get(itemName);
+        if (tableName == null) {
+            return false;
+        }
+        ItemTableCheckEntry entry = getCheckedEntry(itemName, tableName, ifTableExists(tableName));
+        return cleanupItem(entry, force);
+    }
+
+    /**
+     * Clean up inconsistent item: Remove from index and drop table.
+     * Tables with any rows are skipped.
+     *
+     * @param entry
+     * @return true if item was cleaned up
+     */
+    public boolean cleanupItem(ItemTableCheckEntry entry) {
+        return cleanupItem(entry, false);
+    }
+
+    private boolean cleanupItem(ItemTableCheckEntry entry, boolean force) {
+        if (!checkDBAccessability()) {
+            logger.warn("JDBC::cleanupItem: database not connected");
+            return false;
+        }
+
+        ItemTableCheckEntryStatus status = entry.getStatus();
+        String tableName = entry.getTableName();
+        switch (status) {
+            case ITEM_MISSING:
+                if (!force && getRowCount(tableName) > 0) {
+                    return false;
+                }
+                dropTable(tableName);
+                // Fall through to remove from index.
+            case TABLE_MISSING:
+            case ITEM_AND_TABLE_MISSING:
+                if (!conf.getTableUseRealCaseSensitiveItemNames()) {
+                    ItemsVO itemsVo = new ItemsVO();
+                    itemsVo.setItemName(entry.getItemName());
+                    deleteItemsEntry(itemsVo);
+                }
+                itemNameToTableNameMap.remove(entry.getItemName());
+                return true;
+            case ORPHAN_TABLE:
+            case VALID:
+            default:
+                // Nothing to clean.
+                return false;
+        }
+    }
 }
diff --git a/bundles/org.openhab.persistence.jdbc/src/main/java/org/openhab/persistence/jdbc/internal/JdbcPersistenceServiceConstants.java b/bundles/org.openhab.persistence.jdbc/src/main/java/org/openhab/persistence/jdbc/internal/JdbcPersistenceServiceConstants.java
new file mode 100644 (file)
index 0000000..53d4ee5
--- /dev/null
@@ -0,0 +1,29 @@
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.persistence.jdbc.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link JdbcPersistenceServiceConstants} class defines common constants, which are
+ * used across the whole persistence service.
+ *
+ * @author Jacob Laursen - Initial contribution
+ */
+@NonNullByDefault
+public class JdbcPersistenceServiceConstants {
+
+    public static final String SERVICE_ID = "jdbc";
+    public static final String SERVICE_LABEL = "JDBC";
+    public static final String CONFIG_URI = "persistence:jdbc";
+}