]> git.basschouten.com Git - openhab-addons.git/blob
a224aa2efaacca787b9abb5cfcb2421fafdd5396
[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     @Override
155     public void store(Item item, ZonedDateTime date, State state, @Nullable String alias) {
156         // alias is not supported
157         scheduler.execute(() -> internalStore(item, null, item.getState()));
158     }
159
160     private synchronized void internalStore(Item item, @Nullable ZonedDateTime date, State state) {
161         // Do not store undefined/uninitialized data
162         if (state instanceof UnDefType) {
163             logger.debug("JDBC::store: ignore Item '{}' because it is UnDefType", item.getName());
164             return;
165         }
166         if (!checkDBAccessability()) {
167             logger.warn(
168                     "JDBC::store: No connection to database. Cannot persist state '{}' for item '{}'! Will retry connecting to database when error count:{} equals errReconnectThreshold:{}",
169                     state, item, errCnt, conf.getErrReconnectThreshold());
170             return;
171         }
172         try {
173             long timerStart = System.currentTimeMillis();
174             storeItemValue(item, state, date);
175             if (logger.isDebugEnabled()) {
176                 logger.debug("JDBC: Stored item '{}' as '{}' in SQL database at {} in {} ms.", item.getName(), state,
177                         new Date(), System.currentTimeMillis() - timerStart);
178             }
179         } catch (JdbcException e) {
180             logger.warn("JDBC::store: Unable to store item", e);
181         }
182     }
183
184     @Override
185     public Set<PersistenceItemInfo> getItemInfo() {
186         return getItems();
187     }
188
189     /**
190      * Queries the {@link PersistenceService} for data with a given filter
191      * criteria
192      *
193      * @param filter
194      *            the filter to apply to the query
195      * @return a time series of items
196      */
197     @Override
198     public Iterable<HistoricItem> query(FilterCriteria filter) {
199         if (!checkDBAccessability()) {
200             logger.warn("JDBC::query: database not connected, query aborted for item '{}'", filter.getItemName());
201             return List.of();
202         }
203
204         // Get the item name from the filter
205         // Also get the Item object so we can determine the type
206         Item item = null;
207         String itemName = filter.getItemName();
208         if (itemName == null) {
209             logger.warn("Item name is missing in filter {}", filter);
210             return List.of();
211         }
212         logger.debug("JDBC::query: item is {}", itemName);
213         try {
214             item = itemRegistry.getItem(itemName);
215         } catch (ItemNotFoundException e1) {
216             logger.error("JDBC::query: unable to get item for itemName: '{}'. Ignore and give up!", itemName);
217             return List.of();
218         }
219
220         if (item instanceof GroupItem) {
221             // For Group Item is BaseItem needed to get correct Type of Value.
222             item = GroupItem.class.cast(item).getBaseItem();
223             logger.debug("JDBC::query: item is instanceof GroupItem '{}'", itemName);
224             if (item == null) {
225                 logger.debug("JDBC::query: BaseItem of GroupItem is null. Ignore and give up!");
226                 return List.of();
227             }
228             if (item instanceof GroupItem) {
229                 logger.debug("JDBC::query: BaseItem of GroupItem is a GroupItem too. Ignore and give up!");
230                 return List.of();
231             }
232         }
233
234         String table = itemNameToTableNameMap.get(itemName);
235         if (table == null) {
236             logger.debug("JDBC::query: unable to find table for item with name: '{}', no data in database.", itemName);
237             return List.of();
238         }
239
240         try {
241             long timerStart = System.currentTimeMillis();
242             List<HistoricItem> items = getHistItemFilterQuery(filter, conf.getNumberDecimalcount(), table, item);
243             if (logger.isDebugEnabled()) {
244                 logger.debug("JDBC: Query for item '{}' returned {} rows in {} ms", itemName, items.size(),
245                         System.currentTimeMillis() - timerStart);
246             }
247             // Success
248             errCnt = 0;
249             return items;
250         } catch (JdbcSQLException e) {
251             logger.warn("JDBC::query: Unable to query item", e);
252             return List.of();
253         }
254     }
255
256     public void updateConfig(Map<Object, Object> configuration) {
257         logger.debug("JDBC::updateConfig");
258
259         conf = new JdbcConfiguration(configuration);
260         if (conf.valid && checkDBAccessability()) {
261             namingStrategy = new NamingStrategy(conf);
262             try {
263                 checkDBSchema();
264                 // connection has been established ... initialization completed!
265                 initialized = true;
266             } catch (JdbcSQLException e) {
267                 logger.error("Failed to check database schema", e);
268                 initialized = false;
269             }
270         } else {
271             initialized = false;
272         }
273
274         logger.debug("JDBC::updateConfig: configuration complete for service={}.", getId());
275     }
276
277     @Override
278     public List<PersistenceStrategy> getDefaultStrategies() {
279         return List.of(PersistenceStrategy.Globals.CHANGE);
280     }
281
282     @Override
283     public boolean remove(FilterCriteria filter) throws IllegalArgumentException {
284         if (!checkDBAccessability()) {
285             logger.warn("JDBC::remove: database not connected, remove aborted for item '{}'", filter.getItemName());
286             return false;
287         }
288
289         // Get the item name from the filter
290         // Also get the Item object so we can determine the type
291         String itemName = filter.getItemName();
292         logger.debug("JDBC::remove: item is {}", itemName);
293         if (itemName == null) {
294             throw new IllegalArgumentException("Item name must not be null");
295         }
296
297         String table = itemNameToTableNameMap.get(itemName);
298         if (table == null) {
299             logger.debug("JDBC::remove: unable to find table for item with name: '{}', no data in database.", itemName);
300             return false;
301         }
302
303         try {
304             long timerStart = System.currentTimeMillis();
305             deleteItemValues(filter, table);
306             if (logger.isDebugEnabled()) {
307                 logger.debug("JDBC: Deleted values for item '{}' in SQL database at {} in {} ms.", itemName, new Date(),
308                         System.currentTimeMillis() - timerStart);
309             }
310             return true;
311         } catch (JdbcSQLException e) {
312             logger.debug("JDBC::remove: Unable to remove values for item", e);
313             return false;
314         }
315     }
316
317     /**
318      * Get a list of names of persisted items.
319      */
320     public Collection<String> getItemNames() {
321         return itemNameToTableNameMap.keySet();
322     }
323
324     /**
325      * Get a map of item names to table names.
326      */
327     public Map<String, String> getItemNameToTableNameMap() {
328         return itemNameToTableNameMap;
329     }
330
331     /**
332      * Check schema of specific item table for integrity issues.
333      *
334      * @param tableName for which columns should be checked
335      * @param itemName that corresponds to table
336      * @return Collection of strings, each describing an identified issue
337      * @throws JdbcSQLException on SQL errors
338      */
339     public Collection<String> getSchemaIssues(String tableName, String itemName) throws JdbcSQLException {
340         List<String> issues = new ArrayList<>();
341
342         if (!checkDBAccessability()) {
343             logger.warn("JDBC::getSchemaIssues: database not connected");
344             return issues;
345         }
346
347         Item item;
348         try {
349             item = itemRegistry.getItem(itemName);
350         } catch (ItemNotFoundException e) {
351             return issues;
352         }
353         JdbcBaseDAO dao = conf.getDBDAO();
354         String timeDataType = dao.sqlTypes.get("tablePrimaryKey");
355         if (timeDataType == null) {
356             return issues;
357         }
358         String valueDataType = dao.getDataType(item);
359         List<Column> columns = getTableColumns(tableName);
360         for (Column column : columns) {
361             String columnName = column.getColumnName();
362             if ("time".equalsIgnoreCase(columnName)) {
363                 if (!"time".equals(columnName)) {
364                     issues.add("Column name 'time' expected, but is '" + columnName + "'");
365                 }
366                 if (!timeDataType.equalsIgnoreCase(column.getColumnType())
367                         && !timeDataType.equalsIgnoreCase(column.getColumnTypeAlias())) {
368                     issues.add("Column type '" + timeDataType + "' expected, but is '"
369                             + column.getColumnType().toUpperCase() + "'");
370                 }
371                 if (column.getIsNullable()) {
372                     issues.add("Column 'time' expected to be NOT NULL, but is nullable");
373                 }
374             } else if ("value".equalsIgnoreCase(columnName)) {
375                 if (!"value".equals(columnName)) {
376                     issues.add("Column name 'value' expected, but is '" + columnName + "'");
377                 }
378                 if (!valueDataType.equalsIgnoreCase(column.getColumnType())
379                         && !valueDataType.equalsIgnoreCase(column.getColumnTypeAlias())) {
380                     issues.add("Column type '" + valueDataType + "' expected, but is '"
381                             + column.getColumnType().toUpperCase() + "'");
382                 }
383                 if (!column.getIsNullable()) {
384                     issues.add("Column 'value' expected to be nullable, but is NOT NULL");
385                 }
386             } else {
387                 issues.add("Column '" + columnName + "' not expected");
388             }
389         }
390         return issues;
391     }
392
393     /**
394      * Fix schema issues.
395      *
396      * @param tableName for which columns should be repaired
397      * @param itemName that corresponds to table
398      * @return true if table was altered, otherwise false
399      * @throws JdbcSQLException on SQL errors
400      */
401     public boolean fixSchemaIssues(String tableName, String itemName) throws JdbcSQLException {
402         if (!checkDBAccessability()) {
403             logger.warn("JDBC::fixSchemaIssues: database not connected");
404             return false;
405         }
406
407         Item item;
408         try {
409             item = itemRegistry.getItem(itemName);
410         } catch (ItemNotFoundException e) {
411             return false;
412         }
413         JdbcBaseDAO dao = conf.getDBDAO();
414         String timeDataType = dao.sqlTypes.get("tablePrimaryKey");
415         if (timeDataType == null) {
416             return false;
417         }
418         String valueDataType = dao.getDataType(item);
419         List<Column> columns = getTableColumns(tableName);
420         boolean isFixed = false;
421         for (Column column : columns) {
422             String columnName = column.getColumnName();
423             if ("time".equalsIgnoreCase(columnName)) {
424                 if (!"time".equals(columnName)
425                         || (!timeDataType.equalsIgnoreCase(column.getColumnType())
426                                 && !timeDataType.equalsIgnoreCase(column.getColumnTypeAlias()))
427                         || column.getIsNullable()) {
428                     alterTableColumn(tableName, "time", timeDataType, false);
429                     isFixed = true;
430                 }
431             } else if ("value".equalsIgnoreCase(columnName)) {
432                 if (!"value".equals(columnName)
433                         || (!valueDataType.equalsIgnoreCase(column.getColumnType())
434                                 && !valueDataType.equalsIgnoreCase(column.getColumnTypeAlias()))
435                         || !column.getIsNullable()) {
436                     alterTableColumn(tableName, "value", valueDataType, true);
437                     isFixed = true;
438                 }
439             }
440         }
441         return isFixed;
442     }
443
444     /**
445      * Get a list of all items with corresponding tables and an {@link ItemTableCheckEntryStatus} indicating
446      * its condition.
447      *
448      * @return list of {@link ItemTableCheckEntry}
449      */
450     public List<ItemTableCheckEntry> getCheckedEntries() throws JdbcSQLException {
451         List<ItemTableCheckEntry> entries = new ArrayList<>();
452
453         if (!checkDBAccessability()) {
454             logger.warn("JDBC::getCheckedEntries: database not connected");
455             return entries;
456         }
457
458         var orphanTables = getItemTables().stream().map(ItemsVO::getTableName).collect(Collectors.toSet());
459         for (Entry<String, String> entry : itemNameToTableNameMap.entrySet()) {
460             String itemName = entry.getKey();
461             String tableName = entry.getValue();
462             entries.add(getCheckedEntry(itemName, tableName, orphanTables.contains(tableName)));
463             orphanTables.remove(tableName);
464         }
465         for (String orphanTable : orphanTables) {
466             entries.add(new ItemTableCheckEntry("", orphanTable, ItemTableCheckEntryStatus.ORPHAN_TABLE));
467         }
468         return entries;
469     }
470
471     private ItemTableCheckEntry getCheckedEntry(String itemName, String tableName, boolean tableExists) {
472         boolean itemExists;
473         try {
474             itemRegistry.getItem(itemName);
475             itemExists = true;
476         } catch (ItemNotFoundException e) {
477             itemExists = false;
478         }
479
480         ItemTableCheckEntryStatus status;
481         if (!tableExists) {
482             if (itemExists) {
483                 status = ItemTableCheckEntryStatus.TABLE_MISSING;
484             } else {
485                 status = ItemTableCheckEntryStatus.ITEM_AND_TABLE_MISSING;
486             }
487         } else if (itemExists) {
488             status = ItemTableCheckEntryStatus.VALID;
489         } else {
490             status = ItemTableCheckEntryStatus.ITEM_MISSING;
491         }
492         return new ItemTableCheckEntry(itemName, tableName, status);
493     }
494
495     /**
496      * Clean up inconsistent item: Remove from index and drop table.
497      * Tables with any rows are skipped, unless force is set.
498      *
499      * @param itemName Name of item to clean
500      * @param force If true, non-empty tables will be dropped too
501      * @return true if item was cleaned up
502      * @throws JdbcSQLException
503      */
504     public boolean cleanupItem(String itemName, boolean force) throws JdbcSQLException {
505         if (!checkDBAccessability()) {
506             logger.warn("JDBC::cleanupItem: database not connected");
507             return false;
508         }
509
510         String tableName = itemNameToTableNameMap.get(itemName);
511         if (tableName == null) {
512             return false;
513         }
514         ItemTableCheckEntry entry = getCheckedEntry(itemName, tableName, ifTableExists(tableName));
515         return cleanupItem(entry, force);
516     }
517
518     /**
519      * Clean up inconsistent item: Remove from index and drop table.
520      * Tables with any rows are skipped.
521      *
522      * @param entry
523      * @return true if item was cleaned up
524      * @throws JdbcSQLException
525      */
526     public boolean cleanupItem(ItemTableCheckEntry entry) throws JdbcSQLException {
527         return cleanupItem(entry, false);
528     }
529
530     private boolean cleanupItem(ItemTableCheckEntry entry, boolean force) throws JdbcSQLException {
531         if (!checkDBAccessability()) {
532             logger.warn("JDBC::cleanupItem: database not connected");
533             return false;
534         }
535
536         ItemTableCheckEntryStatus status = entry.getStatus();
537         String tableName = entry.getTableName();
538         switch (status) {
539             case ITEM_MISSING:
540                 if (!force && getRowCount(tableName) > 0) {
541                     return false;
542                 }
543                 dropTable(tableName);
544                 // Fall through to remove from index.
545             case TABLE_MISSING:
546             case ITEM_AND_TABLE_MISSING:
547                 if (!conf.getTableUseRealCaseSensitiveItemNames()) {
548                     ItemsVO itemsVo = new ItemsVO();
549                     itemsVo.setItemName(entry.getItemName());
550                     itemsVo.setItemsManageTable(conf.getItemsManageTable());
551                     deleteItemsEntry(itemsVo);
552                 }
553                 itemNameToTableNameMap.remove(entry.getItemName());
554                 return true;
555             case ORPHAN_TABLE:
556             case VALID:
557             default:
558                 // Nothing to clean.
559                 return false;
560         }
561     }
562 }