]> git.basschouten.com Git - openhab-addons.git/blob
73f3f29e51c49c206a480365aea0b314fb9ea117
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.persistence.jdbc.internal;
14
15 import java.time.ZonedDateTime;
16 import java.util.ArrayList;
17 import java.util.Collection;
18 import java.util.Date;
19 import java.util.List;
20 import java.util.Locale;
21 import java.util.Map;
22 import java.util.Map.Entry;
23 import java.util.Set;
24 import java.util.concurrent.Executors;
25 import java.util.concurrent.ScheduledExecutorService;
26 import java.util.stream.Collectors;
27
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.openhab.core.common.NamedThreadFactory;
31 import org.openhab.core.config.core.ConfigurableService;
32 import org.openhab.core.i18n.TimeZoneProvider;
33 import org.openhab.core.items.GroupItem;
34 import org.openhab.core.items.Item;
35 import org.openhab.core.items.ItemNotFoundException;
36 import org.openhab.core.items.ItemRegistry;
37 import org.openhab.core.persistence.FilterCriteria;
38 import org.openhab.core.persistence.HistoricItem;
39 import org.openhab.core.persistence.ModifiablePersistenceService;
40 import org.openhab.core.persistence.PersistenceItemInfo;
41 import org.openhab.core.persistence.PersistenceService;
42 import org.openhab.core.persistence.QueryablePersistenceService;
43 import org.openhab.core.persistence.strategy.PersistenceStrategy;
44 import org.openhab.core.types.State;
45 import org.openhab.core.types.UnDefType;
46 import org.openhab.persistence.jdbc.internal.db.JdbcBaseDAO;
47 import org.openhab.persistence.jdbc.internal.dto.Column;
48 import org.openhab.persistence.jdbc.internal.dto.ItemsVO;
49 import org.openhab.persistence.jdbc.internal.exceptions.JdbcException;
50 import org.openhab.persistence.jdbc.internal.exceptions.JdbcSQLException;
51 import org.osgi.framework.BundleContext;
52 import org.osgi.framework.Constants;
53 import org.osgi.service.component.annotations.Activate;
54 import org.osgi.service.component.annotations.Component;
55 import org.osgi.service.component.annotations.Deactivate;
56 import org.osgi.service.component.annotations.Reference;
57 import org.slf4j.Logger;
58 import org.slf4j.LoggerFactory;
59
60 /**
61  * This is the implementation of the JDBC {@link PersistenceService}.
62  *
63  * @author Helmut Lehmeyer - Initial contribution
64  * @author Kai Kreuzer - Migration to 3.x
65  */
66 @NonNullByDefault
67 @Component(service = { PersistenceService.class,
68         QueryablePersistenceService.class }, configurationPid = "org.openhab.jdbc", //
69         property = Constants.SERVICE_PID + "=org.openhab.jdbc")
70 @ConfigurableService(category = "persistence", label = "JDBC Persistence Service", description_uri = JdbcPersistenceServiceConstants.CONFIG_URI)
71 public class JdbcPersistenceService extends JdbcMapper implements ModifiablePersistenceService {
72
73     private final Logger logger = LoggerFactory.getLogger(JdbcPersistenceService.class);
74
75     private final ItemRegistry itemRegistry;
76
77     private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1,
78             new NamedThreadFactory(JdbcPersistenceServiceConstants.SERVICE_ID));
79
80     @Activate
81     public JdbcPersistenceService(final @Reference ItemRegistry itemRegistry,
82             final @Reference TimeZoneProvider timeZoneProvider) {
83         super(timeZoneProvider);
84         this.itemRegistry = itemRegistry;
85     }
86
87     /**
88      * Called by the SCR to activate the component with its configuration read
89      * from CAS
90      *
91      * @param bundleContext
92      *            BundleContext of the Bundle that defines this component
93      * @param configuration
94      *            Configuration properties for this component obtained from the
95      *            ConfigAdmin service
96      */
97     @Activate
98     public void activate(BundleContext bundleContext, Map<Object, Object> configuration) {
99         logger.debug("JDBC::activate: persistence service activated");
100         updateConfig(configuration);
101     }
102
103     /**
104      * Called by the SCR to deactivate the component when either the
105      * configuration is removed or mandatory references are no longer satisfied
106      * or the component has simply been stopped.
107      *
108      * @param reason
109      *            Reason code for the deactivation:<br>
110      *            <ul>
111      *            <li>0 – Unspecified
112      *            <li>1 – The component was disabled
113      *            <li>2 – A reference became unsatisfied
114      *            <li>3 – A configuration was changed
115      *            <li>4 – A configuration was deleted
116      *            <li>5 – The component was disposed
117      *            <li>6 – The bundle was stopped
118      *            </ul>
119      */
120     @Deactivate
121     public void deactivate(final int reason) {
122         logger.debug("JDBC::deactivate:  persistence bundle stopping. Disconnecting from database. reason={}", reason);
123         // closeConnection();
124         initialized = false;
125     }
126
127     @Override
128     public String getId() {
129         logger.debug("JDBC::getName: returning name 'jdbc' for queryable persistence service.");
130         return JdbcPersistenceServiceConstants.SERVICE_ID;
131     }
132
133     @Override
134     public String getLabel(@Nullable Locale locale) {
135         return JdbcPersistenceServiceConstants.SERVICE_LABEL;
136     }
137
138     @Override
139     public void store(Item item) {
140         scheduler.execute(() -> internalStore(item, null, item.getState()));
141     }
142
143     @Override
144     public void store(Item item, @Nullable String alias) {
145         // alias is not supported
146         scheduler.execute(() -> internalStore(item, null, item.getState()));
147     }
148
149     @Override
150     public void store(Item item, ZonedDateTime date, State state) {
151         scheduler.execute(() -> internalStore(item, date, state));
152     }
153
154     private synchronized void internalStore(Item item, @Nullable ZonedDateTime date, State state) {
155         // Do not store undefined/uninitialized data
156         if (state instanceof UnDefType) {
157             logger.debug("JDBC::store: ignore Item '{}' because it is UnDefType", item.getName());
158             return;
159         }
160         if (!checkDBAccessability()) {
161             logger.warn(
162                     "JDBC::store: No connection to database. Cannot persist state '{}' for item '{}'! Will retry connecting to database when error count:{} equals errReconnectThreshold:{}",
163                     state, item, errCnt, conf.getErrReconnectThreshold());
164             return;
165         }
166         try {
167             long timerStart = System.currentTimeMillis();
168             storeItemValue(item, state, date);
169             if (logger.isDebugEnabled()) {
170                 logger.debug("JDBC: Stored item '{}' as '{}' in SQL database at {} in {} ms.", item.getName(), state,
171                         new Date(), System.currentTimeMillis() - timerStart);
172             }
173         } catch (JdbcException e) {
174             logger.warn("JDBC::store: Unable to store item", e);
175         }
176     }
177
178     @Override
179     public Set<PersistenceItemInfo> getItemInfo() {
180         return getItems();
181     }
182
183     /**
184      * Queries the {@link PersistenceService} for data with a given filter
185      * criteria
186      *
187      * @param filter
188      *            the filter to apply to the query
189      * @return a time series of items
190      */
191     @Override
192     public Iterable<HistoricItem> query(FilterCriteria filter) {
193         if (!checkDBAccessability()) {
194             logger.warn("JDBC::query: database not connected, query aborted for item '{}'", filter.getItemName());
195             return List.of();
196         }
197
198         // Get the item name from the filter
199         // Also get the Item object so we can determine the type
200         Item item = null;
201         String itemName = filter.getItemName();
202         if (itemName == null) {
203             logger.warn("Item name is missing in filter {}", filter);
204             return List.of();
205         }
206         logger.debug("JDBC::query: item is {}", itemName);
207         try {
208             item = itemRegistry.getItem(itemName);
209         } catch (ItemNotFoundException e1) {
210             logger.error("JDBC::query: unable to get item for itemName: '{}'. Ignore and give up!", itemName);
211             return List.of();
212         }
213
214         if (item instanceof GroupItem) {
215             // For Group Item is BaseItem needed to get correct Type of Value.
216             item = GroupItem.class.cast(item).getBaseItem();
217             logger.debug("JDBC::query: item is instanceof GroupItem '{}'", itemName);
218             if (item == null) {
219                 logger.debug("JDBC::query: BaseItem of GroupItem is null. Ignore and give up!");
220                 return List.of();
221             }
222             if (item instanceof GroupItem) {
223                 logger.debug("JDBC::query: BaseItem of GroupItem is a GroupItem too. Ignore and give up!");
224                 return List.of();
225             }
226         }
227
228         String table = itemNameToTableNameMap.get(itemName);
229         if (table == null) {
230             logger.debug("JDBC::query: unable to find table for item with name: '{}', no data in database.", itemName);
231             return List.of();
232         }
233
234         try {
235             long timerStart = System.currentTimeMillis();
236             List<HistoricItem> items = getHistItemFilterQuery(filter, conf.getNumberDecimalcount(), table, item);
237             if (logger.isDebugEnabled()) {
238                 logger.debug("JDBC: Query for item '{}' returned {} rows in {} ms", itemName, items.size(),
239                         System.currentTimeMillis() - timerStart);
240             }
241             // Success
242             errCnt = 0;
243             return items;
244         } catch (JdbcSQLException e) {
245             logger.warn("JDBC::query: Unable to query item", e);
246             return List.of();
247         }
248     }
249
250     public void updateConfig(Map<Object, Object> configuration) {
251         logger.debug("JDBC::updateConfig");
252
253         conf = new JdbcConfiguration(configuration);
254         if (conf.valid && checkDBAccessability()) {
255             namingStrategy = new NamingStrategy(conf);
256             try {
257                 checkDBSchema();
258                 // connection has been established ... initialization completed!
259                 initialized = true;
260             } catch (JdbcSQLException e) {
261                 logger.error("Failed to check database schema", e);
262                 initialized = false;
263             }
264         } else {
265             initialized = false;
266         }
267
268         logger.debug("JDBC::updateConfig: configuration complete for service={}.", getId());
269     }
270
271     @Override
272     public List<PersistenceStrategy> getDefaultStrategies() {
273         return List.of(PersistenceStrategy.Globals.CHANGE);
274     }
275
276     @Override
277     public boolean remove(FilterCriteria filter) throws IllegalArgumentException {
278         if (!checkDBAccessability()) {
279             logger.warn("JDBC::remove: database not connected, remove aborted for item '{}'", filter.getItemName());
280             return false;
281         }
282
283         // Get the item name from the filter
284         // Also get the Item object so we can determine the type
285         String itemName = filter.getItemName();
286         logger.debug("JDBC::remove: item is {}", itemName);
287         if (itemName == null) {
288             throw new IllegalArgumentException("Item name must not be null");
289         }
290
291         String table = itemNameToTableNameMap.get(itemName);
292         if (table == null) {
293             logger.debug("JDBC::remove: unable to find table for item with name: '{}', no data in database.", itemName);
294             return false;
295         }
296
297         try {
298             long timerStart = System.currentTimeMillis();
299             deleteItemValues(filter, table);
300             if (logger.isDebugEnabled()) {
301                 logger.debug("JDBC: Deleted values for item '{}' in SQL database at {} in {} ms.", itemName, new Date(),
302                         System.currentTimeMillis() - timerStart);
303             }
304             return true;
305         } catch (JdbcSQLException e) {
306             logger.debug("JDBC::remove: Unable to remove values for item", e);
307             return false;
308         }
309     }
310
311     /**
312      * Get a list of names of persisted items.
313      */
314     public Collection<String> getItemNames() {
315         return itemNameToTableNameMap.keySet();
316     }
317
318     /**
319      * Get a map of item names to table names.
320      */
321     public Map<String, String> getItemNameToTableNameMap() {
322         return itemNameToTableNameMap;
323     }
324
325     /**
326      * Check schema of specific item table for integrity issues.
327      *
328      * @param tableName for which columns should be checked
329      * @param itemName that corresponds to table
330      * @return Collection of strings, each describing an identified issue
331      * @throws JdbcSQLException on SQL errors
332      */
333     public Collection<String> getSchemaIssues(String tableName, String itemName) throws JdbcSQLException {
334         List<String> issues = new ArrayList<>();
335
336         if (!checkDBAccessability()) {
337             logger.warn("JDBC::getSchemaIssues: database not connected");
338             return issues;
339         }
340
341         Item item;
342         try {
343             item = itemRegistry.getItem(itemName);
344         } catch (ItemNotFoundException e) {
345             return issues;
346         }
347         JdbcBaseDAO dao = conf.getDBDAO();
348         String timeDataType = dao.sqlTypes.get("tablePrimaryKey");
349         if (timeDataType == null) {
350             return issues;
351         }
352         String valueDataType = dao.getDataType(item);
353         List<Column> columns = getTableColumns(tableName);
354         for (Column column : columns) {
355             String columnName = column.getColumnName();
356             if ("time".equalsIgnoreCase(columnName)) {
357                 if (!"time".equals(columnName)) {
358                     issues.add("Column name 'time' expected, but is '" + columnName + "'");
359                 }
360                 if (!timeDataType.equalsIgnoreCase(column.getColumnType())
361                         && !timeDataType.equalsIgnoreCase(column.getColumnTypeAlias())) {
362                     issues.add("Column type '" + timeDataType + "' expected, but is '"
363                             + column.getColumnType().toUpperCase() + "'");
364                 }
365                 if (column.getIsNullable()) {
366                     issues.add("Column 'time' expected to be NOT NULL, but is nullable");
367                 }
368             } else if ("value".equalsIgnoreCase(columnName)) {
369                 if (!"value".equals(columnName)) {
370                     issues.add("Column name 'value' expected, but is '" + columnName + "'");
371                 }
372                 if (!valueDataType.equalsIgnoreCase(column.getColumnType())
373                         && !valueDataType.equalsIgnoreCase(column.getColumnTypeAlias())) {
374                     issues.add("Column type '" + valueDataType + "' expected, but is '"
375                             + column.getColumnType().toUpperCase() + "'");
376                 }
377                 if (!column.getIsNullable()) {
378                     issues.add("Column 'value' expected to be nullable, but is NOT NULL");
379                 }
380             } else {
381                 issues.add("Column '" + columnName + "' not expected");
382             }
383         }
384         return issues;
385     }
386
387     /**
388      * Fix schema issues.
389      *
390      * @param tableName for which columns should be repaired
391      * @param itemName that corresponds to table
392      * @return true if table was altered, otherwise false
393      * @throws JdbcSQLException on SQL errors
394      */
395     public boolean fixSchemaIssues(String tableName, String itemName) throws JdbcSQLException {
396         if (!checkDBAccessability()) {
397             logger.warn("JDBC::fixSchemaIssues: database not connected");
398             return false;
399         }
400
401         Item item;
402         try {
403             item = itemRegistry.getItem(itemName);
404         } catch (ItemNotFoundException e) {
405             return false;
406         }
407         JdbcBaseDAO dao = conf.getDBDAO();
408         String timeDataType = dao.sqlTypes.get("tablePrimaryKey");
409         if (timeDataType == null) {
410             return false;
411         }
412         String valueDataType = dao.getDataType(item);
413         List<Column> columns = getTableColumns(tableName);
414         boolean isFixed = false;
415         for (Column column : columns) {
416             String columnName = column.getColumnName();
417             if ("time".equalsIgnoreCase(columnName)) {
418                 if (!"time".equals(columnName)
419                         || (!timeDataType.equalsIgnoreCase(column.getColumnType())
420                                 && !timeDataType.equalsIgnoreCase(column.getColumnTypeAlias()))
421                         || column.getIsNullable()) {
422                     alterTableColumn(tableName, "time", timeDataType, false);
423                     isFixed = true;
424                 }
425             } else if ("value".equalsIgnoreCase(columnName)) {
426                 if (!"value".equals(columnName)
427                         || (!valueDataType.equalsIgnoreCase(column.getColumnType())
428                                 && !valueDataType.equalsIgnoreCase(column.getColumnTypeAlias()))
429                         || !column.getIsNullable()) {
430                     alterTableColumn(tableName, "value", valueDataType, true);
431                     isFixed = true;
432                 }
433             }
434         }
435         return isFixed;
436     }
437
438     /**
439      * Get a list of all items with corresponding tables and an {@link ItemTableCheckEntryStatus} indicating
440      * its condition.
441      *
442      * @return list of {@link ItemTableCheckEntry}
443      */
444     public List<ItemTableCheckEntry> getCheckedEntries() throws JdbcSQLException {
445         List<ItemTableCheckEntry> entries = new ArrayList<>();
446
447         if (!checkDBAccessability()) {
448             logger.warn("JDBC::getCheckedEntries: database not connected");
449             return entries;
450         }
451
452         var orphanTables = getItemTables().stream().map(ItemsVO::getTableName).collect(Collectors.toSet());
453         for (Entry<String, String> entry : itemNameToTableNameMap.entrySet()) {
454             String itemName = entry.getKey();
455             String tableName = entry.getValue();
456             entries.add(getCheckedEntry(itemName, tableName, orphanTables.contains(tableName)));
457             orphanTables.remove(tableName);
458         }
459         for (String orphanTable : orphanTables) {
460             entries.add(new ItemTableCheckEntry("", orphanTable, ItemTableCheckEntryStatus.ORPHAN_TABLE));
461         }
462         return entries;
463     }
464
465     private ItemTableCheckEntry getCheckedEntry(String itemName, String tableName, boolean tableExists) {
466         boolean itemExists;
467         try {
468             itemRegistry.getItem(itemName);
469             itemExists = true;
470         } catch (ItemNotFoundException e) {
471             itemExists = false;
472         }
473
474         ItemTableCheckEntryStatus status;
475         if (!tableExists) {
476             if (itemExists) {
477                 status = ItemTableCheckEntryStatus.TABLE_MISSING;
478             } else {
479                 status = ItemTableCheckEntryStatus.ITEM_AND_TABLE_MISSING;
480             }
481         } else if (itemExists) {
482             status = ItemTableCheckEntryStatus.VALID;
483         } else {
484             status = ItemTableCheckEntryStatus.ITEM_MISSING;
485         }
486         return new ItemTableCheckEntry(itemName, tableName, status);
487     }
488
489     /**
490      * Clean up inconsistent item: Remove from index and drop table.
491      * Tables with any rows are skipped, unless force is set.
492      *
493      * @param itemName Name of item to clean
494      * @param force If true, non-empty tables will be dropped too
495      * @return true if item was cleaned up
496      * @throws JdbcSQLException
497      */
498     public boolean cleanupItem(String itemName, boolean force) throws JdbcSQLException {
499         if (!checkDBAccessability()) {
500             logger.warn("JDBC::cleanupItem: database not connected");
501             return false;
502         }
503
504         String tableName = itemNameToTableNameMap.get(itemName);
505         if (tableName == null) {
506             return false;
507         }
508         ItemTableCheckEntry entry = getCheckedEntry(itemName, tableName, ifTableExists(tableName));
509         return cleanupItem(entry, force);
510     }
511
512     /**
513      * Clean up inconsistent item: Remove from index and drop table.
514      * Tables with any rows are skipped.
515      *
516      * @param entry
517      * @return true if item was cleaned up
518      * @throws JdbcSQLException
519      */
520     public boolean cleanupItem(ItemTableCheckEntry entry) throws JdbcSQLException {
521         return cleanupItem(entry, false);
522     }
523
524     private boolean cleanupItem(ItemTableCheckEntry entry, boolean force) throws JdbcSQLException {
525         if (!checkDBAccessability()) {
526             logger.warn("JDBC::cleanupItem: database not connected");
527             return false;
528         }
529
530         ItemTableCheckEntryStatus status = entry.getStatus();
531         String tableName = entry.getTableName();
532         switch (status) {
533             case ITEM_MISSING:
534                 if (!force && getRowCount(tableName) > 0) {
535                     return false;
536                 }
537                 dropTable(tableName);
538                 // Fall through to remove from index.
539             case TABLE_MISSING:
540             case ITEM_AND_TABLE_MISSING:
541                 if (!conf.getTableUseRealCaseSensitiveItemNames()) {
542                     ItemsVO itemsVo = new ItemsVO();
543                     itemsVo.setItemName(entry.getItemName());
544                     itemsVo.setItemsManageTable(conf.getItemsManageTable());
545                     deleteItemsEntry(itemsVo);
546                 }
547                 itemNameToTableNameMap.remove(entry.getItemName());
548                 return true;
549             case ORPHAN_TABLE:
550             case VALID:
551             default:
552                 // Nothing to clean.
553                 return false;
554         }
555     }
556 }