From 956b8e47d5fc44a807306ef19d5e6ee0a915f0e8 Mon Sep 17 00:00:00 2001 From: ulbi Date: Sat, 17 Feb 2024 10:58:14 +0100 Subject: [PATCH] [mongodb] Upgrade DB driver, add more type handlings, fix QuantityType handling (#16333) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * #16308 #16310 Upgraded MongoDB driver, added initial unit tests * #16308 #16310 Refactored the MongoDBPersistence adding helper, fixing type handling for HSBType, RawType and QuantityType * #16308 Added backwardcompatibility for the old way of writting the data where possible * #16308 Added test for larger ImageItems and the limit of 16 MB Signed-off-by: René Ulbricht --- .../org.openhab.persistence.mongodb/pom.xml | 60 +- .../mongodb/internal/MongoDBFields.java | 37 + .../mongodb/internal/MongoDBItem.java | 4 +- .../internal/MongoDBPersistenceService.java | 418 ++++---- .../internal/MongoDBTypeConversions.java | 255 +++++ .../mongodb/internal/DataCreationHelper.java | 444 ++++++++ .../internal/DatabaseTestContainer.java | 105 ++ .../MongoDBPersistenceServiceTest.java | 992 ++++++++++++++++++ .../mongodb/internal/PersistenceTestItem.java | 56 + .../mongodb/internal/SetupResult.java | 46 + .../mongodb/internal/VerificationHelper.java | 206 ++++ 11 files changed, 2427 insertions(+), 196 deletions(-) create mode 100644 bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBFields.java create mode 100644 bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBTypeConversions.java create mode 100644 bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/DataCreationHelper.java create mode 100644 bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/DatabaseTestContainer.java create mode 100644 bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/MongoDBPersistenceServiceTest.java create mode 100644 bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/PersistenceTestItem.java create mode 100644 bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/SetupResult.java create mode 100644 bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/VerificationHelper.java diff --git a/bundles/org.openhab.persistence.mongodb/pom.xml b/bundles/org.openhab.persistence.mongodb/pom.xml index a70513d250..3564805dc3 100644 --- a/bundles/org.openhab.persistence.mongodb/pom.xml +++ b/bundles/org.openhab.persistence.mongodb/pom.xml @@ -14,12 +14,66 @@ openHAB Add-ons :: Bundles :: Persistence Service :: MongoDB + + !sun.nio.ch;!org.bson.codecs.kotlin*;!jnr.unixsocket*;!javax.annotation*;!com.google*;!io.netty*;com.oracle*;resolution:=optional;com.aayushatharva*;resolution:=optional;com.mongodb.crypt*;resolution:=optional;com.amazon*;resolution:=optional;software.amazon*;resolution:=optional + + - org.mongodb - mongo-java-driver - 2.13.1 + mongodb-driver-sync + 4.11.1 + compile + + + org.mongodb + bson + 4.11.1 + compile + + + org.mongodb + mongodb-driver-core + 4.11.1 + compile + + + org.xerial.snappy + snappy-java + 1.1.10.3 + compile + + + com.github.luben + zstd-jni + 1.5.5-3 + compile + + + org.mongodb + bson-record-codec + 4.11.1 + compile + + + + + de.bwaldvogel + mongo-java-server + 1.44.0 + test + + + org.testcontainers + mongodb + 1.19.4 + test + + + org.apache.commons + commons-lang3 + 3.14.0 + test diff --git a/bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBFields.java b/bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBFields.java new file mode 100644 index 0000000000..52990d1660 --- /dev/null +++ b/bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBFields.java @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2010-2024 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.mongodb.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * This class defines constant field names used in MongoDB documents. + * These field names are used to ensure consistent access to document properties. + * + * @author René Ulbricht - Initial contribution + */ +@NonNullByDefault +public final class MongoDBFields { + public static final String FIELD_ID = "_id"; + public static final String FIELD_ITEM = "item"; + public static final String FIELD_REALNAME = "realName"; + public static final String FIELD_TIMESTAMP = "timestamp"; + public static final String FIELD_VALUE = "value"; + public static final String FIELD_UNIT = "unit"; + public static final String FIELD_VALUE_DATA = "value.data"; + public static final String FIELD_VALUE_TYPE = "value.type"; + + private MongoDBFields() { + // Private constructor to prevent instantiation + } +} diff --git a/bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBItem.java b/bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBItem.java index ef23fdf78f..555e5af6db 100644 --- a/bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBItem.java +++ b/bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBItem.java @@ -14,6 +14,7 @@ package org.openhab.persistence.mongodb.internal; import java.text.DateFormat; import java.time.ZonedDateTime; +import java.util.Date; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.core.persistence.HistoricItem; @@ -54,6 +55,7 @@ public class MongoDBItem implements HistoricItem { @Override public String toString() { - return DateFormat.getDateTimeInstance().format(timestamp) + ": " + name + " -> " + state.toString(); + Date date = Date.from(timestamp.toInstant()); + return DateFormat.getDateTimeInstance().format(date) + ": " + name + " -> " + state.toString(); } } diff --git a/bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBPersistenceService.java b/bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBPersistenceService.java index 532dd2e6b4..3ad12dbe61 100644 --- a/bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBPersistenceService.java +++ b/bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBPersistenceService.java @@ -22,28 +22,19 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import org.bson.Document; import org.bson.types.ObjectId; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.items.Item; import org.openhab.core.items.ItemNotFoundException; import org.openhab.core.items.ItemRegistry; -import org.openhab.core.library.items.ContactItem; -import org.openhab.core.library.items.DateTimeItem; -import org.openhab.core.library.items.DimmerItem; import org.openhab.core.library.items.NumberItem; -import org.openhab.core.library.items.RollershutterItem; -import org.openhab.core.library.items.SwitchItem; -import org.openhab.core.library.types.DateTimeType; -import org.openhab.core.library.types.DecimalType; -import org.openhab.core.library.types.OnOffType; -import org.openhab.core.library.types.OpenClosedType; -import org.openhab.core.library.types.PercentType; -import org.openhab.core.library.types.StringType; +import org.openhab.core.library.types.QuantityType; import org.openhab.core.persistence.FilterCriteria; -import org.openhab.core.persistence.FilterCriteria.Operator; import org.openhab.core.persistence.FilterCriteria.Ordering; import org.openhab.core.persistence.HistoricItem; +import org.openhab.core.persistence.ModifiablePersistenceService; import org.openhab.core.persistence.PersistenceItemInfo; import org.openhab.core.persistence.PersistenceService; import org.openhab.core.persistence.QueryablePersistenceService; @@ -59,29 +50,23 @@ import org.osgi.service.component.annotations.Reference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.mongodb.BasicDBObject; -import com.mongodb.DBCollection; -import com.mongodb.DBCursor; -import com.mongodb.DBObject; -import com.mongodb.MongoClient; -import com.mongodb.MongoClientURI; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoCursor; +import com.mongodb.client.result.DeleteResult; /** * This is the implementation of the MongoDB {@link PersistenceService}. * * @author Thorsten Hoeger - Initial contribution * @author Stephan Brunner - Query fixes, Cleanup + * @author René Ulbricht - Fixes type handling, driver update and cleanup */ @NonNullByDefault -@Component(service = { PersistenceService.class, - QueryablePersistenceService.class }, configurationPid = "org.openhab.mongodb", configurationPolicy = ConfigurationPolicy.REQUIRE) -public class MongoDBPersistenceService implements QueryablePersistenceService { - - private static final String FIELD_ID = "_id"; - private static final String FIELD_ITEM = "item"; - private static final String FIELD_REALNAME = "realName"; - private static final String FIELD_TIMESTAMP = "timestamp"; - private static final String FIELD_VALUE = "value"; +@Component(service = { PersistenceService.class, QueryablePersistenceService.class, + ModifiablePersistenceService.class }, configurationPid = "org.openhab.mongodb", configurationPolicy = ConfigurationPolicy.REQUIRE) +public class MongoDBPersistenceService implements ModifiablePersistenceService { private final Logger logger = LoggerFactory.getLogger(MongoDBPersistenceService.class); @@ -150,72 +135,6 @@ public class MongoDBPersistenceService implements QueryablePersistenceService { return "MongoDB"; } - @Override - public void store(Item item, @Nullable String alias) { - // Don't log undefined/uninitialized data - if (item.getState() instanceof UnDefType) { - return; - } - - // If we've not initialized the bundle, then return - if (!initialized) { - logger.warn("MongoDB not initialized"); - return; - } - - // Connect to mongodb server if we're not already connected - // If we can't connect, log. - if (!tryConnectToDatabase()) { - logger.warn( - "mongodb: No connection to database. Cannot persist item '{}'! Will retry connecting to database next time.", - item); - return; - } - - String realItemName = item.getName(); - String collectionName = collectionPerItem ? realItemName : this.collection; - - @Nullable - DBCollection collection = connectToCollection(collectionName); - - if (collection == null) { - // Logging is done in connectToCollection() - return; - } - - String name = (alias != null) ? alias : realItemName; - Object value = this.convertValue(item.getState()); - - DBObject obj = new BasicDBObject(); - obj.put(FIELD_ID, new ObjectId()); - obj.put(FIELD_ITEM, name); - obj.put(FIELD_REALNAME, realItemName); - obj.put(FIELD_TIMESTAMP, new Date()); - obj.put(FIELD_VALUE, value); - collection.save(obj); - - logger.debug("MongoDB save {}={}", name, value); - } - - private Object convertValue(State state) { - Object value; - if (state instanceof PercentType type) { - value = type.toBigDecimal().doubleValue(); - } else if (state instanceof DateTimeType type) { - value = Date.from(type.getZonedDateTime().toInstant()); - } else if (state instanceof DecimalType type) { - value = type.toBigDecimal().doubleValue(); - } else { - value = state.toString(); - } - return value; - } - - @Override - public void store(Item item) { - store(item, null); - } - @Override public Set getItemInfo() { return Collections.emptySet(); @@ -228,7 +147,8 @@ public class MongoDBPersistenceService implements QueryablePersistenceService { * @return true if connection has been established, false otherwise */ private synchronized boolean isConnected() { - if (cl == null) { + MongoClient localCl = cl; + if (localCl == null) { return false; } @@ -236,7 +156,7 @@ public class MongoDBPersistenceService implements QueryablePersistenceService { // Network problems may cause failure sometimes, // even if the connection object was successfully created before. try { - cl.getAddress(); + localCl.listDatabaseNames().first(); return true; } catch (Exception ex) { return false; @@ -257,14 +177,17 @@ public class MongoDBPersistenceService implements QueryablePersistenceService { logger.debug("Connect MongoDB"); disconnectFromDatabase(); - this.cl = new MongoClient(new MongoClientURI(this.url)); + this.cl = MongoClients.create(this.url); + MongoClient localCl = this.cl; - // The mongo always succeeds in creating the connection. + // The MongoDB driver always succeeds in creating the connection. // We have to actually force it to test the connection to try to connect to the server. - cl.getAddress(); - - logger.debug("Connect MongoDB ... done"); - return true; + if (localCl != null) { + localCl.listDatabaseNames().first(); + logger.debug("Connect MongoDB ... done"); + return true; + } + return false; } catch (Exception e) { logger.error("Failed to connect to database {}: {}", this.url, e.getMessage(), e); disconnectFromDatabase(); @@ -286,7 +209,7 @@ public class MongoDBPersistenceService implements QueryablePersistenceService { * * @return The collection object when collection creation was successful. Null otherwise. */ - private @Nullable DBCollection connectToCollection(String collectionName) { + private @Nullable MongoCollection connectToCollection(String collectionName) { try { @Nullable MongoClient db = getDatabase(); @@ -296,10 +219,10 @@ public class MongoDBPersistenceService implements QueryablePersistenceService { return null; } - DBCollection mongoCollection = db.getDB(this.db).getCollection(collectionName); + MongoCollection mongoCollection = db.getDatabase(this.db).getCollection(collectionName); - BasicDBObject idx = new BasicDBObject(); - idx.append(FIELD_ITEM, 1).append(FIELD_TIMESTAMP, 1); + Document idx = new Document(); + idx.append(MongoDBFields.FIELD_ITEM, 1).append(MongoDBFields.FIELD_TIMESTAMP, 1); mongoCollection.createIndex(idx); return mongoCollection; @@ -313,8 +236,9 @@ public class MongoDBPersistenceService implements QueryablePersistenceService { * Disconnects from the database */ private synchronized void disconnectFromDatabase() { - if (this.cl != null) { - this.cl.close(); + MongoClient localCl = cl; + if (localCl != null) { + localCl.close(); } cl = null; @@ -322,132 +246,242 @@ public class MongoDBPersistenceService implements QueryablePersistenceService { @Override public Iterable query(FilterCriteria filter) { - if (!initialized) { + MongoCollection collection = prepareCollection(filter); + // If collection creation failed, return nothing. + if (collection == null) { + // Logging is done in connectToCollection() return Collections.emptyList(); } - if (!tryConnectToDatabase()) { + Document query = createQuery(filter); + if (query == null) { return Collections.emptyList(); } + @Nullable String realItemName = filter.getItemName(); if (realItemName == null) { logger.warn("Item name is missing in filter {}", filter); - return List.of(); + return Collections.emptyList(); + } + + Item item = getItem(realItemName); + if (item == null) { + logger.warn("Item {} not found", realItemName); + return Collections.emptyList(); + } + List items = new ArrayList<>(); + + logger.debug("Query: {}", query); + + Integer sortDir = (filter.getOrdering() == Ordering.ASCENDING) ? 1 : -1; + MongoCursor cursor = null; + try { + cursor = collection.find(query).sort(new Document(MongoDBFields.FIELD_TIMESTAMP, sortDir)) + .skip(filter.getPageNumber() * filter.getPageSize()).limit(filter.getPageSize()).iterator(); + + while (cursor.hasNext()) { + Document obj = cursor.next(); + + final State state = MongoDBTypeConversions.getStateFromDocument(item, obj); + + items.add(new MongoDBItem(realItemName, state, ZonedDateTime + .ofInstant(obj.getDate(MongoDBFields.FIELD_TIMESTAMP).toInstant(), ZoneId.systemDefault()))); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + return items; + } + + private @Nullable Item getItem(String itemName) { + try { + return itemRegistry.getItem(itemName); + } catch (ItemNotFoundException e1) { + logger.error("Unable to get item type for {}", itemName); + } + return null; + } + + @Override + public List getDefaultStrategies() { + return Collections.emptyList(); + } + + @Override + public void store(Item item, @Nullable String alias) { + store(item, new Date(), item.getState(), alias); + } + + @Override + public void store(Item item) { + store(item, null); + } + + @Override + public void store(Item item, ZonedDateTime date, State state) { + store(item, date, state, null); + } + + @Override + public void store(Item item, ZonedDateTime date, State state, @Nullable String alias) { + Date dateConverted = Date.from(date.toInstant()); + store(item, dateConverted, state, alias); + } + + private void store(Item item, Date date, State state, @Nullable String alias) { + // Don't log undefined/uninitialized data + if (state instanceof UnDefType) { + return; + } + + // If we've not initialized the bundle, then return + if (!initialized) { + logger.warn("MongoDB not initialized"); + return; + } + + // Connect to mongodb server if we're not already connected + // If we can't connect, log. + if (!tryConnectToDatabase()) { + logger.warn( + "mongodb: No connection to database. Cannot persist item '{}'! Will retry connecting to database next time.", + item); + return; } + String realItemName = item.getName(); String collectionName = collectionPerItem ? realItemName : this.collection; + @Nullable - DBCollection collection = connectToCollection(collectionName); + MongoCollection collection = connectToCollection(collectionName); - // If collection creation failed, return nothing. if (collection == null) { // Logging is done in connectToCollection() - return Collections.emptyList(); + return; + } + + String name = (alias != null) ? alias : realItemName; + Object value = MongoDBTypeConversions.convertValue(state); + + Document obj = new Document(); + obj.put(MongoDBFields.FIELD_ID, new ObjectId()); + obj.put(MongoDBFields.FIELD_ITEM, name); + obj.put(MongoDBFields.FIELD_REALNAME, realItemName); + obj.put(MongoDBFields.FIELD_TIMESTAMP, date); + obj.put(MongoDBFields.FIELD_VALUE, value); + if (item instanceof NumberItem && state instanceof QuantityType) { + obj.put(MongoDBFields.FIELD_UNIT, ((QuantityType) state).getUnit().toString()); + } + try { + collection.insertOne(obj); + } catch (org.bson.BsonMaximumSizeExceededException e) { + logger.error("Document size exceeds maximum size of 16MB. Item {} not persisted.", name); + throw e; + } + logger.debug("MongoDB save {}={}", name, value); + } + + @Nullable + public MongoCollection prepareCollection(FilterCriteria filter) { + if (!initialized || !tryConnectToDatabase()) { + return null; + } + + String realItemName = filter.getItemName(); + if (realItemName == null) { + logger.warn("Item name is missing in filter {}", filter); + return null; } @Nullable - Item item = getItem(realItemName); + MongoCollection collection = getCollection(realItemName); + return collection; + } - if (item == null) { - logger.warn("Item {} not found", realItemName); - return Collections.emptyList(); + @Nullable + private MongoCollection getCollection(String realItemName) { + String collectionName = collectionPerItem ? realItemName : this.collection; + @Nullable + MongoCollection collection = connectToCollection(collectionName); + + if (collection == null) { + // Logging is done in connectToCollection() + logger.warn("Failed to connect to collection {}", collectionName); } - List items = new ArrayList<>(); - BasicDBObject query = new BasicDBObject(); - if (filter.getItemName() != null) { - query.put(FIELD_ITEM, filter.getItemName()); + return collection; + } + + @Nullable + private Document createQuery(FilterCriteria filter) { + String realItemName = filter.getItemName(); + Document query = new Document(); + query.put(MongoDBFields.FIELD_ITEM, realItemName); + + if (!addStateToQuery(filter, query) || !addDateToQuery(filter, query)) { + return null; } + + return query; + } + + private boolean addStateToQuery(FilterCriteria filter, Document query) { State filterState = filter.getState(); - if (filterState != null && filter.getOperator() != null) { - @Nullable - String op = convertOperator(filter.getOperator()); + if (filterState != null) { + String op = MongoDBTypeConversions.convertOperator(filter.getOperator()); if (op == null) { logger.error("Failed to convert operator {} to MongoDB operator", filter.getOperator()); - return Collections.emptyList(); + return false; } - Object value = convertValue(filterState); - query.put(FIELD_VALUE, new BasicDBObject(op, value)); + Object value = MongoDBTypeConversions.convertValue(filterState); + query.put(MongoDBFields.FIELD_VALUE, new Document(op, value)); } - BasicDBObject dateQueries = new BasicDBObject(); - if (filter.getBeginDate() != null) { - dateQueries.put("$gte", Date.from(filter.getBeginDate().toInstant())); + return true; + } + + private boolean addDateToQuery(FilterCriteria filter, Document query) { + Document dateQueries = new Document(); + ZonedDateTime beginDate = filter.getBeginDate(); + if (beginDate != null) { + dateQueries.put("$gte", Date.from(beginDate.toInstant())); } - if (filter.getEndDate() != null) { - dateQueries.put("$lte", Date.from(filter.getEndDate().toInstant())); + ZonedDateTime endDate = filter.getEndDate(); + if (endDate != null) { + dateQueries.put("$lte", Date.from(endDate.toInstant())); } if (!dateQueries.isEmpty()) { - query.put(FIELD_TIMESTAMP, dateQueries); + query.put(MongoDBFields.FIELD_TIMESTAMP, dateQueries); } - logger.debug("Query: {}", query); - - Integer sortDir = (filter.getOrdering() == Ordering.ASCENDING) ? 1 : -1; - DBCursor cursor = collection.find(query).sort(new BasicDBObject(FIELD_TIMESTAMP, sortDir)) - .skip(filter.getPageNumber() * filter.getPageSize()).limit(filter.getPageSize()); - - while (cursor.hasNext()) { - BasicDBObject obj = (BasicDBObject) cursor.next(); - - final State state; - if (item instanceof NumberItem) { - state = new DecimalType(obj.getDouble(FIELD_VALUE)); - } else if (item instanceof DimmerItem) { - state = new PercentType(obj.getInt(FIELD_VALUE)); - } else if (item instanceof SwitchItem) { - state = OnOffType.valueOf(obj.getString(FIELD_VALUE)); - } else if (item instanceof ContactItem) { - state = OpenClosedType.valueOf(obj.getString(FIELD_VALUE)); - } else if (item instanceof RollershutterItem) { - state = new PercentType(obj.getInt(FIELD_VALUE)); - } else if (item instanceof DateTimeItem) { - state = new DateTimeType( - ZonedDateTime.ofInstant(obj.getDate(FIELD_VALUE).toInstant(), ZoneId.systemDefault())); - } else { - state = new StringType(obj.getString(FIELD_VALUE)); - } - - items.add(new MongoDBItem(realItemName, state, - ZonedDateTime.ofInstant(obj.getDate(FIELD_TIMESTAMP).toInstant(), ZoneId.systemDefault()))); - } - - return items; + return true; } - private @Nullable String convertOperator(Operator operator) { - switch (operator) { - case EQ: - return "$eq"; - case GT: - return "$gt"; - case GTE: - return "$gte"; - case LT: - return "$lt"; - case LTE: - return "$lte"; - case NEQ: - return "$neq"; - default: - return null; + @Override + public boolean remove(FilterCriteria filter) { + MongoCollection collection = prepareCollection(filter); + // If collection creation failed, return nothing. + if (collection == null) { + // Logging is done in connectToCollection() + return false; } - } - private @Nullable Item getItem(String itemName) { - try { - return itemRegistry.getItem(itemName); - } catch (ItemNotFoundException e1) { - logger.error("Unable to get item type for {}", itemName); + Document query = createQuery(filter); + if (query == null) { + return false; } - return null; - } - @Override - public List getDefaultStrategies() { - return Collections.emptyList(); + logger.debug("Query: {}", query); + + DeleteResult result = collection.deleteMany(query); + + logger.debug("Deleted {} documents", result.getDeletedCount()); + return true; } } diff --git a/bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBTypeConversions.java b/bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBTypeConversions.java new file mode 100644 index 0000000000..eb18986105 --- /dev/null +++ b/bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBTypeConversions.java @@ -0,0 +1,255 @@ +/** + * Copyright (c) 2010-2024 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.mongodb.internal; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Date; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Function; + +import javax.measure.Unit; + +import org.bson.Document; +import org.bson.types.Binary; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.items.GenericItem; +import org.openhab.core.items.Item; +import org.openhab.core.library.items.CallItem; +import org.openhab.core.library.items.ColorItem; +import org.openhab.core.library.items.ContactItem; +import org.openhab.core.library.items.DateTimeItem; +import org.openhab.core.library.items.DimmerItem; +import org.openhab.core.library.items.ImageItem; +import org.openhab.core.library.items.LocationItem; +import org.openhab.core.library.items.NumberItem; +import org.openhab.core.library.items.PlayerItem; +import org.openhab.core.library.items.RollershutterItem; +import org.openhab.core.library.items.StringItem; +import org.openhab.core.library.items.SwitchItem; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.HSBType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.OpenClosedType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.types.PlayPauseType; +import org.openhab.core.library.types.PointType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.RawType; +import org.openhab.core.library.types.StringListType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.persistence.FilterCriteria.Operator; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; +import org.openhab.core.types.util.UnitUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class handles the conversion of types between openHAB and MongoDB. + * It provides methods to convert openHAB states to MongoDB compatible types and vice versa. + * It also provides a method to convert openHAB filter operators to MongoDB query operators. + * + * @author René Ulbricht - Initial contribution + */ +@NonNullByDefault +public class MongoDBTypeConversions { + + /** + * Converts a MongoDB document to an openHAB state. + * + * @param item The openHAB item that the state belongs to. + * @param doc The MongoDB document to convert. + * @return The openHAB state. + * @throws IllegalArgumentException If the item type is not supported. + */ + public static State getStateFromDocument(Item item, Document doc) { + BiFunction converter = ITEM_STATE_CONVERTERS.get(item.getClass()); + if (converter != null) { + return converter.apply(item, doc); + } else { + throw new IllegalArgumentException("Unsupported item type: " + item.getClass().getName()); + } + } + + /** + * Converts an openHAB filter operator to a MongoDB query operator. + * + * @param operator The openHAB filter operator to convert. + * @return The MongoDB query operator, or null if the operator is not supported. + */ + public static @Nullable String convertOperator(Operator operator) { + return switch (operator) { + case EQ -> "$eq"; + case GT -> "$gt"; + case GTE -> "$gte"; + case LT -> "$lt"; + case LTE -> "$lte"; + case NEQ -> "$neq"; + default -> null; + }; + } + + /** + * Converts an openHAB state to a MongoDB compatible type. + * + * @param state The openHAB state to convert. + * @return The MongoDB compatible type. + */ + public static Object convertValue(State state) { + return STATE_CONVERTERS.getOrDefault(state.getClass(), State::toString).apply(state); + } + + private static final Logger logger = LoggerFactory.getLogger(MongoDBTypeConversions.class); + + /** + * A map of converters that convert openHAB states to MongoDB compatible types. + * Each converter is a function that takes an openHAB state and returns an object that can be stored in MongoDB. + */ + private static final Map, Function> STATE_CONVERTERS = Map.of( // + HSBType.class, State::toString, // + QuantityType.class, state -> ((QuantityType) state).toBigDecimal().doubleValue(), // + PercentType.class, state -> ((PercentType) state).intValue(), // + DateTimeType.class, state -> ((DateTimeType) state).getZonedDateTime().toString(), // + StringListType.class, State::toString, // + DecimalType.class, state -> ((DecimalType) state).toBigDecimal().doubleValue(), // + RawType.class, MongoDBTypeConversions::handleRawType// + ); + + private static Object handleRawType(State state) { + RawType rawType = (RawType) state; + Document doc = new Document(); + doc.put(MongoDBFields.FIELD_VALUE_TYPE, rawType.getMimeType()); + doc.put(MongoDBFields.FIELD_VALUE_DATA, rawType.getBytes()); + return doc; + } + + /** + * A map of converters that convert MongoDB documents to openHAB states. + * Each converter is a function that takes an openHAB item and a MongoDB document and returns an openHAB state. + */ + + private static final Map, BiFunction> ITEM_STATE_CONVERTERS = // + Map.ofEntries( // + Map.entry(NumberItem.class, MongoDBTypeConversions::handleNumberItem), + Map.entry(ColorItem.class, MongoDBTypeConversions::handleColorItem), + Map.entry(DimmerItem.class, MongoDBTypeConversions::handleDimmerItem), + Map.entry(SwitchItem.class, + (Item item, Document doc) -> OnOffType.valueOf(doc.getString(MongoDBFields.FIELD_VALUE))), + Map.entry(ContactItem.class, + (Item item, Document doc) -> OpenClosedType + .valueOf(doc.getString(MongoDBFields.FIELD_VALUE))), + Map.entry(RollershutterItem.class, MongoDBTypeConversions::handleRollershutterItem), + Map.entry(DateTimeItem.class, MongoDBTypeConversions::handleDateTimeItem), + Map.entry(LocationItem.class, + (Item item, Document doc) -> new PointType(doc.getString(MongoDBFields.FIELD_VALUE))), + Map.entry(PlayerItem.class, + (Item item, Document doc) -> PlayPauseType + .valueOf(doc.getString(MongoDBFields.FIELD_VALUE))), + Map.entry(CallItem.class, + (Item item, Document doc) -> new StringListType(doc.getString(MongoDBFields.FIELD_VALUE))), + Map.entry(ImageItem.class, MongoDBTypeConversions::handleImageItem), // + Map.entry(StringItem.class, + (Item item, Document doc) -> new StringType(doc.getString(MongoDBFields.FIELD_VALUE))), + Map.entry(GenericItem.class, + (Item item, Document doc) -> new StringType(doc.getString(MongoDBFields.FIELD_VALUE)))// + ); + + private static State handleNumberItem(Item item, Document doc) { + NumberItem numberItem = (NumberItem) item; + Unit unit = numberItem.getUnit(); + Object value = doc.get(MongoDBFields.FIELD_VALUE); + if (value == null) { + return UnDefType.UNDEF; + } + if (doc.containsKey(MongoDBFields.FIELD_UNIT)) { + String unitString = doc.getString(MongoDBFields.FIELD_UNIT); + Unit docUnit = UnitUtils.parseUnit(unitString); + if (docUnit != null) { + unit = docUnit; + } + } + if (value instanceof String) { + return new QuantityType<>(value.toString()); + } + if (unit != null) { + return new QuantityType<>(((Number) value).doubleValue(), unit); + } else { + return new DecimalType(((Number) value).doubleValue()); + } + } + + private static State handleColorItem(Item item, Document doc) { + Object value = doc.get(MongoDBFields.FIELD_VALUE); + if (value instanceof String) { + return new HSBType(value.toString()); + } else { + logger.warn("HSBType ({}) value is not a valid string: {}", doc.getString(MongoDBFields.FIELD_REALNAME), + value); + return new HSBType("0,0,0"); + } + } + + private static State handleDimmerItem(Item item, Document doc) { + Object value = doc.get(MongoDBFields.FIELD_VALUE); + if (value == null) { + return UnDefType.UNDEF; + } + if (value instanceof Integer) { + return new PercentType((Integer) value); + } else { + return new PercentType(((Number) value).intValue()); + } + } + + private static State handleRollershutterItem(Item item, Document doc) { + Object value = doc.get(MongoDBFields.FIELD_VALUE); + if (value == null) { + return UnDefType.UNDEF; + } + if (value instanceof Integer) { + return new PercentType((Integer) value); + } else { + return new PercentType(((Number) value).intValue()); + } + } + + private static State handleDateTimeItem(Item item, Document doc) { + Object value = doc.get(MongoDBFields.FIELD_VALUE); + if (value == null) { + return UnDefType.UNDEF; + } + if (value instanceof String) { + return new DateTimeType(ZonedDateTime.parse(doc.getString(MongoDBFields.FIELD_VALUE))); + } else { + return new DateTimeType(ZonedDateTime.ofInstant(((Date) value).toInstant(), ZoneId.systemDefault())); + } + } + + private static State handleImageItem(Item item, Document doc) { + Object value = doc.get(MongoDBFields.FIELD_VALUE); + if (value instanceof Document) { + Document fieldValue = (Document) value; + String type = fieldValue.getString(MongoDBFields.FIELD_VALUE_TYPE); + Binary data = fieldValue.get(MongoDBFields.FIELD_VALUE_DATA, Binary.class); + return new RawType(data.getData(), type); + } else { + logger.warn("ImageItem ({}) value is not a Document: {}", doc.getString(MongoDBFields.FIELD_REALNAME), + value); + return new RawType(new byte[0], "application/octet-stream"); + } + } +} diff --git a/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/DataCreationHelper.java b/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/DataCreationHelper.java new file mode 100644 index 0000000000..83ecd75b40 --- /dev/null +++ b/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/DataCreationHelper.java @@ -0,0 +1,444 @@ +/** + * Copyright (c) 2010-2024 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.mongodb.internal; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.Hashtable; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.UUID; +import java.util.stream.Stream; + +import org.bson.Document; +import org.bson.types.ObjectId; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.junit.jupiter.params.provider.Arguments; +import org.mockito.Mockito; +import org.openhab.core.i18n.UnitProvider; +import org.openhab.core.internal.i18n.I18nProviderImpl; +import org.openhab.core.items.GenericItem; +import org.openhab.core.items.Item; +import org.openhab.core.items.ItemRegistry; +import org.openhab.core.library.items.CallItem; +import org.openhab.core.library.items.ColorItem; +import org.openhab.core.library.items.ContactItem; +import org.openhab.core.library.items.DateTimeItem; +import org.openhab.core.library.items.DimmerItem; +import org.openhab.core.library.items.ImageItem; +import org.openhab.core.library.items.LocationItem; +import org.openhab.core.library.items.NumberItem; +import org.openhab.core.library.items.PlayerItem; +import org.openhab.core.library.items.RollershutterItem; +import org.openhab.core.library.items.StringItem; +import org.openhab.core.library.items.SwitchItem; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.HSBType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.OpenClosedType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.types.PlayPauseType; +import org.openhab.core.library.types.PointType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.RawType; +import org.openhab.core.library.types.StringListType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.unit.SIUnits; +import org.openhab.core.persistence.FilterCriteria; +import org.openhab.core.types.State; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.ComponentContext; +import org.slf4j.LoggerFactory; +import org.testcontainers.DockerClientFactory; + +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import de.bwaldvogel.mongo.backend.memory.MemoryBackend; + +/** + * This class provides helper methods to create test items. + * + * @author René Ulbricht - Initial contribution + */ +@NonNullByDefault +public class DataCreationHelper { + + protected static final UnitProvider UNIT_PROVIDER; + static { + ComponentContext context = Mockito.mock(ComponentContext.class); + BundleContext bundleContext = Mockito.mock(BundleContext.class); + Hashtable properties = new Hashtable<>(); + properties.put("measurementSystem", SIUnits.MEASUREMENT_SYSTEM_NAME); + when(context.getProperties()).thenReturn(properties); + when(context.getBundleContext()).thenReturn(bundleContext); + UNIT_PROVIDER = new I18nProviderImpl(context); + } + + /** + * Creates a NumberItem with a given name and value. + * + * @param name The name of the NumberItem. + * @param value The value of the NumberItem. + * @return The created NumberItem. + */ + public static NumberItem createNumberItem(String name, Number value) { + return createItem(NumberItem.class, name, new DecimalType(value)); + } + + /** + * Creates a StringItem with a given name and value. + * + * @param name The name of the StringItem. + * @param value The value of the StringItem. + * @return The created StringItem. + */ + public static StringItem createStringItem(String name, String value) { + return createItem(StringItem.class, name, new StringType(value)); + } + + /** + * Creates an instance of a NumberItem with a unit type and sets its state. + * + * @param itemType The Class object representing the type of the item to create. + * @param unitType The string representation of the unit type to set on the new item. + * @param name The name to give to the new item. + * @param state The state to set on the new item. + * @return The newly created item. + * @throws RuntimeException if an error occurs while creating the item or setting its state. + */ + public static NumberItem createNumberItem(String unitType, String name, State state) { + NumberItem item = new NumberItem(unitType, name, UNIT_PROVIDER); + item.setState(state); + return item; + } + + /** + * Creates an instance of a specific GenericItem subclass and sets its state. + * + * @param The type of the item to create. This must be a subclass of GenericItem. + * @param The type of the state to set. This must be a subclass of State. + * @param itemType The Class object representing the type of the item to create. + * @param name The name to give to the new item. + * @param state The state to set on the new item. + * @return The newly created item. + * @throws RuntimeException if an error occurs while creating the item or setting its state. + */ + public static T createItem(Class itemType, String name, S state) { + try { + if (state == null) { + throw new IllegalArgumentException("State must not be null"); + } + T item = itemType.getDeclaredConstructor(String.class).newInstance(name); + if (item == null) { + throw new RuntimeException("Could not create item"); + } + item.setState(state); + return item; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static RawType createFakeImage(int size) { + byte[] data = new byte[size]; + for (int i = 0; i < size; i++) { + data[i] = (byte) (i % 256); + } + return new RawType(data, "image/png"); + } + + /** + * Provides a stream of arguments for parameterized tests. To test various image sizes + * + * @return A stream of arguments for parameterized tests. + */ + public static Stream provideOpenhabImageItemsInDifferentSizes() { + return Stream.of( + Arguments.of(DataCreationHelper.createItem(ImageItem.class, "ImageItem1kB", createFakeImage(1024))), + Arguments.of( + DataCreationHelper.createItem(ImageItem.class, "ImageItem1MB", createFakeImage(1024 * 1024))), + Arguments.of(DataCreationHelper.createItem(ImageItem.class, "ImageItem10MB", + createFakeImage(10 * 1024 * 1024))), + Arguments.of(DataCreationHelper.createItem(ImageItem.class, "ImageItem20MB", + createFakeImage(20 * 1024 * 1024)))); + } + + /** + * Provides a stream of arguments for parameterized tests. Each argument is an instance of a specific + * GenericItem subclass with a set state. + * + * @return A stream of arguments for parameterized tests. + */ + public static Stream provideOpenhabItemTypes() { + return Stream.of( + Arguments.of( + DataCreationHelper.createItem(StringItem.class, "StringItem", new StringType("StringValue"))), + Arguments.of(DataCreationHelper.createItem(NumberItem.class, "NumberItem", new DecimalType(123.45))), + Arguments.of(DataCreationHelper.createItem(DimmerItem.class, "DimmerItem", new PercentType(50))), + Arguments.of(DataCreationHelper.createItem(SwitchItem.class, "SwitchItemON", OnOffType.ON)), + Arguments.of(DataCreationHelper.createItem(SwitchItem.class, "SwitchItemOFF", OnOffType.OFF)), + Arguments.of(DataCreationHelper.createItem(ContactItem.class, "ContactItemOPEN", OpenClosedType.OPEN)), + Arguments.of( + DataCreationHelper.createItem(ContactItem.class, "ContactItemCLOSED", OpenClosedType.CLOSED)), + Arguments.of(DataCreationHelper.createItem(RollershutterItem.class, "RollershutterItem", + new PercentType(30))), + Arguments.of(DataCreationHelper.createItem(DateTimeItem.class, "DateTimeItem", + new DateTimeType(ZonedDateTime.now()))), + Arguments.of(DataCreationHelper.createItem(ColorItem.class, "ColorItem", new HSBType("180,100,100"))), + Arguments.of( + DataCreationHelper.createItem(LocationItem.class, "LocationItem", new PointType("51.0,0.0"))), + Arguments.of(DataCreationHelper.createItem(PlayerItem.class, "PlayerItem", PlayPauseType.PLAY)), + Arguments.of(DataCreationHelper.createItem(CallItem.class, "CallItem", + new StringListType("+49 123 456 789"))), + Arguments.of(DataCreationHelper.createItem(ImageItem.class, "ImageItem", + new RawType(new byte[] { 0x00, 0x01, 0x02 }, "image/png"))), + Arguments.of(DataCreationHelper.createNumberItem("Number:Energy", "NumberItemCelcius", + new QuantityType<>("25.00 MWh"))), + Arguments.of(DataCreationHelper.createNumberItem("Number:Temperature", "NumberItemCelcius", + new QuantityType<>("25.00 °F")))); + } + + /** + * Provides a stream of arguments to be used for parameterized tests. + * + * Each argument is a DatabaseTestContainer instance. Some instances use a MemoryBackend, + * while others use a MongoDBContainer with a specific MongoDB version. + * In case there is no Docker available, only the MemoryBackend is used. + * + * @return A stream of Arguments, each containing a DatabaseTestContainer instance. + */ + public static Stream provideDatabaseBackends() { + if (DockerClientFactory.instance().isDockerAvailable()) { + // If Docker is available, create a stream of Arguments with all backends + return Stream.of( + // Create a DatabaseTestContainer with a MemoryBackend + Arguments.of(new DatabaseTestContainer(new MemoryBackend())), + // Create DatabaseTestContainers with MongoDBContainers of specific versions + Arguments.of(new DatabaseTestContainer("mongo:3.6")), + Arguments.of(new DatabaseTestContainer("mongo:4.4")), + Arguments.of(new DatabaseTestContainer("mongo:5.0")), + Arguments.of(new DatabaseTestContainer("mongo:6.0"))); + } else { + // If Docker is not available, create a stream of Arguments with only the MemoryBackend + return Stream.of(Arguments.of(new DatabaseTestContainer(new MemoryBackend()))); + } + } + + /** + * Creates a Document for a given item name, value, and timestamp. + * + * @param itemName The name of the item. + * @param value The value of the item. + * @param timestamp The timestamp of the item. + * @return The created Document. + */ + public static Document createDocument(String itemName, double value, LocalDate timestamp) { + Document obj = new Document(); + obj.put(MongoDBFields.FIELD_ID, new ObjectId()); + obj.put(MongoDBFields.FIELD_ITEM, itemName); + obj.put(MongoDBFields.FIELD_REALNAME, itemName); + obj.put(MongoDBFields.FIELD_TIMESTAMP, timestamp); + obj.put(MongoDBFields.FIELD_VALUE, value); + return obj; + } + + /** + * Creates a FilterCriteria for a given item name. + * + * @param itemName The name of the item. + * @return The created FilterCriteria. + */ + public static FilterCriteria createFilterCriteria(String itemName) { + return createFilterCriteria(itemName, null, null); + } + + /** + * Creates a FilterCriteria for a given item name, begin date, and end date. + * + * @param itemName The name of the item. + * @param beginDate The begin date of the FilterCriteria. + * @param endDate The end date of the FilterCriteria. + * @return The created FilterCriteria. + */ + public static FilterCriteria createFilterCriteria(String itemName, @Nullable ZonedDateTime beginDate, + @Nullable ZonedDateTime endDate) { + FilterCriteria filter = new FilterCriteria(); + filter.setItemName(itemName); + filter.setPageSize(10); + filter.setPageNumber(0); + filter.setOrdering(FilterCriteria.Ordering.ASCENDING); + if (beginDate != null) { + filter.setBeginDate(beginDate); + } + if (endDate != null) { + filter.setEndDate(endDate); + } + return filter; + } + + /** + * Sets up a MongoDB instance for testing. + * + * @param collectionName The name of the MongoDB collection to be used for testing. + * @param dbContainer The container running the MongoDB instance. + * @return A SetupResult object containing the MongoDBPersistenceService, the database, the bundle context, the + * configuration map, the item registry, and the database name. + */ + public static SetupResult setupMongoDB(@Nullable String collectionName, DatabaseTestContainer dbContainer) { + // Start the database container + dbContainer.start(); + + // Mock the ItemRegistry and BundleContext + ItemRegistry itemRegistry = Mockito.mock(ItemRegistry.class); + BundleContext bundleContext = Mockito.mock(BundleContext.class); + + // When getService is called on the bundleContext, return the mocked itemRegistry + when(bundleContext.getService(any())).thenReturn(itemRegistry); + + // Create a new MongoDBPersistenceService instance + MongoDBPersistenceService service = new MongoDBPersistenceService(itemRegistry); + + // Create a configuration map for the MongoDBPersistenceService + Map config = new HashMap<>(); + config.put("url", dbContainer.getConnectionString()); + String dbname = UUID.randomUUID().toString(); + config.put("database", dbname); + if (collectionName != null) { + config.put("collection", collectionName); + } + + // Create a MongoClient connected to the mock server + MongoClient mongoClient = MongoClients.create(dbContainer.getConnectionString()); + + // Create a database and collection + MongoDatabase database = mongoClient.getDatabase(dbname); + + // Setup logger to capture log events + Logger logger = (Logger) LoggerFactory.getLogger(MongoDBPersistenceService.class); + ListAppender listAppender = new ListAppender<>(); + listAppender.start(); + logger.addAppender(listAppender); + logger.setLevel(Level.WARN); + + // Return a SetupResult object containing the service, database, bundle context, config, item registry, and + // database name + return new SetupResult(service, database, bundleContext, config, itemRegistry, dbname); + } + + /** + * Sets up a logger to capture log events. + * + * @param loggerClass The class that the logger is for. + * @param level The level of the logger. + * @return The list appender attached to the logger. + */ + public static ListAppender setupLogger(Class loggerClass, Level level) { + Logger logger = (Logger) LoggerFactory.getLogger(loggerClass); + ListAppender listAppender = new ListAppender<>(); + listAppender.start(); + logger.addAppender(listAppender); + logger.setLevel(level); // Set log level + return listAppender; + } + + private static Object convertValue(State state) { + Object value; + if (state instanceof PercentType) { + PercentType type = (PercentType) state; + value = type.toBigDecimal().doubleValue(); + } else if (state instanceof DateTimeType) { + DateTimeType type = (DateTimeType) state; + value = Date.from(type.getZonedDateTime().toInstant()); + } else if (state instanceof DecimalType) { + DecimalType type = (DecimalType) state; + value = type.toBigDecimal().doubleValue(); + } else { + value = state.toString(); + } + return value; + } + + /** + * Stores the old data of an item into a MongoDB collection. + * + * @param collection The MongoDB collection where the data will be stored. + * @param realItemName The real name of the item. + * @param state The state of the item. + */ + public static void storeOldData(MongoCollection collection, String realItemName, State state) { + // use the old way to store data + Object value = convertValue(state); + + Document obj = new Document(); + obj.put(MongoDBFields.FIELD_ID, new ObjectId()); + obj.put(MongoDBFields.FIELD_ITEM, realItemName); + obj.put(MongoDBFields.FIELD_REALNAME, realItemName); + obj.put(MongoDBFields.FIELD_TIMESTAMP, new Date()); + obj.put(MongoDBFields.FIELD_VALUE, value); + collection.insertOne(obj); + } + + public static List createTestData(MongoDBPersistenceService service, String... itemNames) { + // Prepare a list to store the test data for verification + List testDataList = new ArrayList<>(); + + // Prepare a random number generator + Random random = new Random(); + + // Prepare the start date + ZonedDateTime startDate = ZonedDateTime.now(); + + // Iterate over the 50 days + for (int day = 0; day < 50; day++) { + // Calculate the current date + ZonedDateTime currentDate = startDate.plusDays(day); + + // Generate a random number of values for each item + for (String itemName : itemNames) { + int numValues = 2 + random.nextInt(4); // Random number between 2 and 5 + + for (int valueIndex = 0; valueIndex < numValues; valueIndex++) { + // Generate a random value between 0.0 and 10.0 + double value = 10.0 * random.nextDouble(); + + // Create the item + Item item = DataCreationHelper.createNumberItem(itemName, value); + + // Store the data + service.store(item, currentDate, new DecimalType(value)); + + // Add the data to the test data list for verification + testDataList.add(new PersistenceTestItem(itemName, currentDate, value)); + } + } + } + + return testDataList; + } +} diff --git a/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/DatabaseTestContainer.java b/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/DatabaseTestContainer.java new file mode 100644 index 0000000000..66c22336db --- /dev/null +++ b/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/DatabaseTestContainer.java @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2010-2024 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.mongodb.internal; + +import java.net.InetSocketAddress; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.testcontainers.containers.MongoDBContainer; + +import de.bwaldvogel.mongo.MongoServer; +import de.bwaldvogel.mongo.backend.memory.MemoryBackend; + +/** + * This class provides a container for MongoDB for testing purposes. + * It uses the Testcontainers library to manage the MongoDB container. + * It also provides an in-memory MongoDB server for testing. + * + * @author René Ulbricht - Initial contribution + */ +@NonNullByDefault +public class DatabaseTestContainer { + // A map to store MongoDBContainer instances for different MongoDB versions. + private static final Map mongoDBContainers = new HashMap<>(); + + // The MongoDBContainer instance for this DatabaseTestContainer. + private @Nullable MongoDBContainer mongoDBContainer; + + // The MongoServer instance for this DatabaseTestContainer. + private @Nullable MongoServer server; + + // The InetSocketAddress instance for this DatabaseTestContainer. + private @Nullable InetSocketAddress serverAddress; + + /** + * Creates a new DatabaseTestContainer for a given MongoDB version. + * If a MongoDBContainer for the given version already exists, it is reused. + * + * @param mongoDBVersion The version of MongoDB to use. + */ + public DatabaseTestContainer(String mongoDBVersion) { + server = null; + serverAddress = null; + mongoDBContainer = mongoDBContainers.computeIfAbsent(mongoDBVersion, MongoDBContainer::new); + } + + /** + * Creates a new DatabaseTestContainer for an in-memory MongoDB server. + */ + public DatabaseTestContainer(MemoryBackend memoryBackend) { + mongoDBContainer = null; + server = new MongoServer(memoryBackend); + if (server != null) { + serverAddress = server.bind(); + } + } + + /** + * Starts the MongoDB container or the in-memory MongoDB server. + */ + public void start() { + if (mongoDBContainer != null && !mongoDBContainer.isRunning()) { + mongoDBContainer.start(); + } + } + + /** + * Don't do anything. + */ + public void stop() { + } + + /** + * Returns the connection string for connecting to the MongoDB container or the in-memory MongoDB server. + * + * @return The connection string. + */ + public String getConnectionString() { + @Nullable + MongoDBContainer lc_mongoDBContainer = this.mongoDBContainer; + @Nullable + InetSocketAddress lc_serverAddress = this.serverAddress; + @Nullable + MongoServer lc_server = this.server; + if (lc_mongoDBContainer != null) { + return lc_mongoDBContainer.getConnectionString(); + } else if (lc_server != null && lc_serverAddress != null) { + return String.format("mongodb://%s:%s", lc_serverAddress.getHostName(), lc_serverAddress.getPort()); + } else { + return ""; + } + } +} diff --git a/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/MongoDBPersistenceServiceTest.java b/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/MongoDBPersistenceServiceTest.java new file mode 100644 index 0000000000..afd122c42a --- /dev/null +++ b/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/MongoDBPersistenceServiceTest.java @@ -0,0 +1,992 @@ +/** + * Copyright (c) 2010-2024 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.mongodb.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.text.DateFormat; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.bson.Document; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mockito; +import org.openhab.core.items.GenericItem; +import org.openhab.core.items.ItemNotFoundException; +import org.openhab.core.library.items.ColorItem; +import org.openhab.core.library.items.DateTimeItem; +import org.openhab.core.library.items.ImageItem; +import org.openhab.core.library.items.NumberItem; +import org.openhab.core.library.items.StringItem; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.HSBType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.RawType; +import org.openhab.core.persistence.FilterCriteria; +import org.openhab.core.persistence.HistoricItem; +import org.osgi.framework.BundleContext; + +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import de.bwaldvogel.mongo.backend.memory.MemoryBackend; + +/** + * This is the implementation of the test for MongoDB {@link PersistenceService}. + * + * @author René Ulbricht - Initial contribution + */ +public class MongoDBPersistenceServiceTest { + + /** + * Tests the activate method of MongoDBPersistenceService. + * + * This test checks if the activate method correctly logs the MongoDB URL, database, and collection. + * It uses different database backends provided by the provideDatabaseBackends method. + * + * @param dbContainer The container running the MongoDB instance. + */ + @ParameterizedTest + @MethodSource("org.openhab.persistence.mongodb.internal.DataCreationHelper#provideDatabaseBackends") + public void testActivate(DatabaseTestContainer dbContainer) { + try { + // Preparation + SetupResult setupResult = DataCreationHelper.setupMongoDB("testCollection", dbContainer); + + // Set up logger + ListAppender listAppender = DataCreationHelper.setupLogger(MongoDBPersistenceService.class, + Level.DEBUG); + + // Execution + setupResult.service.activate(setupResult.bundleContext, setupResult.config); + + // Verification + List logsList = listAppender.list; + VerificationHelper.verifyLogMessage(logsList.get(0), "MongoDB URL " + dbContainer.getConnectionString(), + Level.DEBUG); + VerificationHelper.verifyLogMessage(logsList.get(1), "MongoDB database " + setupResult.dbname, Level.DEBUG); + VerificationHelper.verifyLogMessage(logsList.get(2), "MongoDB collection testCollection", Level.DEBUG); + } finally { + dbContainer.stop(); + } + } + + /** + * Tests the deactivate method of MongoDBPersistenceService. + * + * This test checks if the deactivate method correctly logs a message when the MongoDB persistence bundle is + * stopping. + * It uses different database backends provided by the provideDatabaseBackends method. + * + * @param dbContainer The container running the MongoDB instance. + */ + @ParameterizedTest + @MethodSource("org.openhab.persistence.mongodb.internal.DataCreationHelper#provideDatabaseBackends") + public void testDeactivate(DatabaseTestContainer dbContainer) { + try { + // Preparation + SetupResult setupResult = DataCreationHelper.setupMongoDB("testCollection", dbContainer); + + setupResult.service.activate(setupResult.bundleContext, setupResult.config); + + // Set up logger + ListAppender listAppender = DataCreationHelper.setupLogger(MongoDBPersistenceService.class, + Level.DEBUG); + + // Execution + setupResult.service.deactivate(1); + + // Verification + List logsList = listAppender.list; + VerificationHelper.verifyLogMessage(logsList.get(0), + "MongoDB persistence bundle stopping. Disconnecting from database.", Level.DEBUG); + } finally { + dbContainer.stop(); + } + } + + /** + * Tests the getId method of MongoDBPersistenceService. + * + * This test checks if the getId method correctly returns the ID of the MongoDBPersistenceService, which should be + * "mongodb". + */ + @Test + public void testGetId() { + // Preparation + DatabaseTestContainer dbContainer = new DatabaseTestContainer(new MemoryBackend()); + try { + SetupResult setupResult = DataCreationHelper.setupMongoDB(null, dbContainer); + MongoDBPersistenceService service = setupResult.service; + + // Execution + String id = service.getId(); + + // Verification + assertEquals("mongodb", id); + } finally { + dbContainer.stop(); + } + } + + /** + * Tests the getLabel method of MongoDBPersistenceService. + * + * This test checks if the getLabel method correctly returns the label of the MongoDBPersistenceService, which + * should be "MongoDB". + */ + @Test + public void testGetLabel() { + // Preparation + DatabaseTestContainer dbContainer = new DatabaseTestContainer(new MemoryBackend()); + try { + SetupResult setupResult = DataCreationHelper.setupMongoDB(null, dbContainer); + MongoDBPersistenceService service = setupResult.service; + + // Execution + String label = service.getLabel(null); + + // Verification + assertEquals("MongoDB", label); + } finally { + dbContainer.stop(); + } + } + + /** + * Tests the store method of MongoDBPersistenceService with a NumberItem. + * + * This test checks if the store method correctly stores a NumberItem in the MongoDB database. + * It uses different database backends provided by the provideDatabaseBackends method. + * + * @param dbContainer The container running the MongoDB instance. + */ + @ParameterizedTest + @MethodSource("org.openhab.persistence.mongodb.internal.DataCreationHelper#provideDatabaseBackends") + public void testStoreNumber(DatabaseTestContainer dbContainer) { + try { + // Preparation + SetupResult setupResult = DataCreationHelper.setupMongoDB("testCollection", dbContainer); + MongoDBPersistenceService service = setupResult.service; + MongoDatabase database = setupResult.database; + + service.activate(setupResult.bundleContext, setupResult.config); + + NumberItem item = DataCreationHelper.createNumberItem("TestItem", 10.1); + + // Execution + service.store(item, null); + + // Verification + MongoCollection collection = database.getCollection("testCollection"); + List documents = (ArrayList) collection.find().into(new ArrayList<>()); + + assertEquals(1, documents.size()); // Assert that there is only one document + + Document insertedDocument = documents.get(0); // Get the first (and only) document + + VerificationHelper.verifyDocument(insertedDocument, "TestItem", 10.1); + } finally { + dbContainer.stop(); + } + } + + /** + * Tests the store method of MongoDBPersistenceService with a StringItem. + * + * This test checks if the store method correctly stores a StringItem in the MongoDB database. + * It uses different database backends provided by the provideDatabaseBackends method. + * + * @param dbContainer The container running the MongoDB instance. + */ + @ParameterizedTest + @MethodSource("org.openhab.persistence.mongodb.internal.DataCreationHelper#provideDatabaseBackends") + public void testStoreString(DatabaseTestContainer dbContainer) { + try { + // Preparation + SetupResult setupResult = DataCreationHelper.setupMongoDB("testCollection", dbContainer); + MongoDBPersistenceService service = setupResult.service; + MongoDatabase database = setupResult.database; + + service.activate(setupResult.bundleContext, setupResult.config); + + StringItem item = DataCreationHelper.createStringItem("TestItem", "TestValue"); + + // Execution + service.store(item, null); + + // Verification + MongoCollection collection = database.getCollection("testCollection"); + List documents = (ArrayList) collection.find().into(new ArrayList<>()); + + assertEquals(1, documents.size()); // Assert that there is only one document + + Document insertedDocument = documents.get(0); // Get the first (and only) document + + VerificationHelper.verifyDocument(insertedDocument, "TestItem", "TestValue"); + } finally { + dbContainer.stop(); + } + } + + /** + * Tests the store method of MongoDBPersistenceService with multiple items in a single collection. + * + * This test checks if the store method correctly stores multiple items in the same MongoDB collection. + * It uses different database backends provided by the provideDatabaseBackends method. + * + * @param dbContainer The container running the MongoDB instance. + */ + @ParameterizedTest + @MethodSource("org.openhab.persistence.mongodb.internal.DataCreationHelper#provideDatabaseBackends") + public void testStoreSingleCollection(DatabaseTestContainer dbContainer) { + try { + // Preparation + SetupResult setupResult = DataCreationHelper.setupMongoDB("testCollection", dbContainer); + MongoDBPersistenceService service = setupResult.service; + MongoDatabase database = setupResult.database; + + service.activate(setupResult.bundleContext, setupResult.config); + + StringItem strItem1 = DataCreationHelper.createStringItem("TestItem", "TestValue"); + StringItem strItem2 = DataCreationHelper.createStringItem("SecondTestItem", "SecondTestValue"); + + // Execution + service.store(strItem1, null); + service.store(strItem2, null); + + // Verification + MongoCollection collection = database.getCollection("testCollection"); + List documents = (ArrayList) collection.find().into(new ArrayList<>()); + + assertEquals(2, documents.size()); // Assert that there are two documents + + Document insertedDocument1 = documents.get(0); // Get the first document + VerificationHelper.verifyDocument(insertedDocument1, "TestItem", "TestValue"); + + Document insertedDocument2 = documents.get(1); // Get the second document + VerificationHelper.verifyDocument(insertedDocument2, "SecondTestItem", "SecondTestValue"); + } finally { + dbContainer.stop(); + } + } + + /** + * Tests the store method of MongoDBPersistenceService with multiple items. + * + * This test checks if the store method correctly stores multiple items in the same MongoDB collection. + * It uses different database backends provided by the provideDatabaseBackends method. + * + * @param dbContainer The container running the MongoDB instance. + */ + @ParameterizedTest + @MethodSource("org.openhab.persistence.mongodb.internal.DataCreationHelper#provideDatabaseBackends") + public void testStoreMultipleItemsSingleCollection(DatabaseTestContainer dbContainer) { + try { + // Preparation + SetupResult setupResult = DataCreationHelper.setupMongoDB("testCollection", dbContainer); + MongoDBPersistenceService service = setupResult.service; + MongoDatabase database = setupResult.database; + + service.activate(setupResult.bundleContext, setupResult.config); + + StringItem strItem1 = DataCreationHelper.createStringItem("TestItem1", "TestValue1"); + StringItem strItem2 = DataCreationHelper.createStringItem("TestItem2", "TestValue2"); + + // Execution + service.store(strItem1, null); + service.store(strItem2, null); + + // Verification + MongoCollection collection = database.getCollection("testCollection"); + List documents = (ArrayList) collection.find().into(new ArrayList<>()); + + assertEquals(2, documents.size()); // Assert that there are two documents + + Document insertedDocument1 = documents.get(0); // Get the first document + VerificationHelper.verifyDocument(insertedDocument1, "TestItem1", "TestValue1"); + + Document insertedDocument2 = documents.get(1); // Get the second document + VerificationHelper.verifyDocument(insertedDocument2, "TestItem2", "TestValue2"); + } finally { + dbContainer.stop(); + } + } + + /** + * Tests the store method of MongoDBPersistenceService with a StringItem and an alias. + * + * This test checks if the store method correctly stores a StringItem with an alias in the MongoDB database. + * It uses different database backends provided by the provideDatabaseBackends method. + * + * @param dbContainer The container running the MongoDB instance. + */ + @ParameterizedTest + @MethodSource("org.openhab.persistence.mongodb.internal.DataCreationHelper#provideDatabaseBackends") + public void testStoreStringWithAlias(DatabaseTestContainer dbContainer) { + try { + // Preparation + SetupResult setupResult = DataCreationHelper.setupMongoDB("testCollection", dbContainer); + MongoDBPersistenceService service = setupResult.service; + MongoDatabase database = setupResult.database; + + service.activate(setupResult.bundleContext, setupResult.config); + + StringItem item = DataCreationHelper.createStringItem("TestItem", "TestValue"); + + // Execution + service.store(item, "AliasName"); + + // Verification + MongoCollection collection = database.getCollection("testCollection"); + List documents = (ArrayList) collection.find().into(new ArrayList<>()); + + assertEquals(1, documents.size()); // Assert that there is only one document + + Document insertedDocument = documents.get(0); // Get the first (and only) document + + VerificationHelper.verifyDocumentWithAlias(insertedDocument, "AliasName", "TestItem", "TestValue"); + } finally { + dbContainer.stop(); + } + } + + /** + * Tests the query method of MongoDBPersistenceService with NumberItems in a single collection. + * + * This test checks if the query method correctly retrieves NumberItems from a single MongoDB collection. + * It uses different database backends provided by the provideDatabaseBackends method. + * + * @param dbContainer The container running the MongoDB instance. + */ + @ParameterizedTest + @MethodSource("org.openhab.persistence.mongodb.internal.DataCreationHelper#provideDatabaseBackends") + public void testQueryNumberItemsInOneCollection(DatabaseTestContainer dbContainer) { + try { + // Preparation + SetupResult setupResult = DataCreationHelper.setupMongoDB("testCollection", dbContainer); + MongoDBPersistenceService service = setupResult.service; + + // Add items to the ItemRegistry + NumberItem itemReg1 = DataCreationHelper.createNumberItem("TestItem", 0); + NumberItem itemReg2 = DataCreationHelper.createNumberItem("TestItem2", 0); + try { + Mockito.when(setupResult.itemRegistry.getItem("TestItem")).thenReturn(itemReg1); + Mockito.when(setupResult.itemRegistry.getItem("TestItem2")).thenReturn(itemReg2); + } catch (ItemNotFoundException e) { + } + + service.activate(setupResult.bundleContext, setupResult.config); + + // Store some items + for (int i = 0; i < 10; i++) { + NumberItem item1 = DataCreationHelper.createNumberItem("TestItem", i); + NumberItem item2 = DataCreationHelper.createNumberItem("TestItem2", i * 2); + service.store(item1, null); + service.store(item2, null); + } + + // Execution + FilterCriteria filter1 = DataCreationHelper.createFilterCriteria("TestItem"); + Iterable result1 = service.query(filter1); + + FilterCriteria filter2 = DataCreationHelper.createFilterCriteria("TestItem2"); + Iterable result2 = service.query(filter2); + + // Verification + VerificationHelper.verifyQueryResult(result1, 0, 1, 10); + VerificationHelper.verifyQueryResult(result2, 0, 2, 10); + } finally { + dbContainer.stop(); + } + } + + /** + * Tests the query method of MongoDBPersistenceService with NumberItems in multiple collections. + * + * This test checks if the query method correctly retrieves NumberItems from multiple MongoDB collections. + * It uses different database backends provided by the provideDatabaseBackends method. + * + * @param dbContainer The container running the MongoDB instance. + */ + @ParameterizedTest + @MethodSource("org.openhab.persistence.mongodb.internal.DataCreationHelper#provideDatabaseBackends") + public void testQueryNumberItemsInMultipleCollections(DatabaseTestContainer dbContainer) { + try { + // Preparation + SetupResult setupResult = DataCreationHelper.setupMongoDB(null, dbContainer); + MongoDBPersistenceService service = setupResult.service; + BundleContext bundleContext = setupResult.bundleContext; + Map config = setupResult.config; + + try { + Mockito.when(setupResult.itemRegistry.getItem("TestItem")) + .thenReturn(DataCreationHelper.createNumberItem("TestItem", 0)); + Mockito.when(setupResult.itemRegistry.getItem("TestItem2")) + .thenReturn(DataCreationHelper.createNumberItem("TestItem2", 0)); + } catch (ItemNotFoundException e) { + } + + service.activate(bundleContext, config); + + // Store some items + for (int i = 0; i < 10; i++) { + NumberItem item1 = DataCreationHelper.createNumberItem("TestItem", i); + NumberItem item2 = DataCreationHelper.createNumberItem("TestItem2", i * 2); + service.store(item1, null); + service.store(item2, null); + } + + // Execution + FilterCriteria filter1 = DataCreationHelper.createFilterCriteria("TestItem"); + Iterable result1 = service.query(filter1); + + FilterCriteria filter2 = DataCreationHelper.createFilterCriteria("TestItem2"); + Iterable result2 = service.query(filter2); + + // Verification + VerificationHelper.verifyQueryResult(result1, 0, 1, 10); + VerificationHelper.verifyQueryResult(result2, 0, 2, 10); + } finally { + dbContainer.stop(); + } + } + + /** + * Tests the query method of MongoDBPersistenceService with NumberItems in a single collection and a time range. + * + * This test checks if the query method correctly retrieves NumberItems from a single MongoDB collection within a + * specified time range. + * It uses different database backends provided by the provideDatabaseBackends method. + * + * @param dbContainer The container running the MongoDB instance. + */ + @ParameterizedTest + @MethodSource("org.openhab.persistence.mongodb.internal.DataCreationHelper#provideDatabaseBackends") + public void testQueryNumberItemsInOneCollectionTimeRange(DatabaseTestContainer dbContainer) { + try { + // Preparation + SetupResult setupResult = DataCreationHelper.setupMongoDB("testCollection", dbContainer); + MongoDBPersistenceService service = setupResult.service; + MongoDatabase database = setupResult.database; + + try { + Mockito.when(setupResult.itemRegistry.getItem("TestItem")) + .thenReturn(DataCreationHelper.createNumberItem("TestItem", 0)); + Mockito.when(setupResult.itemRegistry.getItem("TestItem2")) + .thenReturn(DataCreationHelper.createNumberItem("TestItem2", 0)); + } catch (ItemNotFoundException e) { + } + + service.activate(setupResult.bundleContext, setupResult.config); + + // Get the collection + MongoCollection collection = database.getCollection("testCollection"); + + // Store items directly to the database with defined timestamps + for (int i = 0; i < 10; i++) { + Document obj = DataCreationHelper.createDocument("TestItem", i, LocalDate.now().minusDays(i)); + collection.insertOne(obj); + + Document obj2 = DataCreationHelper.createDocument("TestItem2", i * 2, LocalDate.now().minusDays(i)); + collection.insertOne(obj2); + } + + // Execution + FilterCriteria filter1 = DataCreationHelper.createFilterCriteria("TestItem", + ZonedDateTime.now().minusDays(5), null); + Iterable result1 = service.query(filter1); + + // Verification + VerificationHelper.verifyQueryResult(result1, 4, -1, 5); + } finally { + dbContainer.stop(); + } + } + + /** + * Tests the query method of MongoDBPersistenceService with NumberItems in a single collection and a state equals + * filter. + * + * This test checks if the query method correctly retrieves NumberItems from a single MongoDB collection that match + * a specified state. + * It uses different database backends provided by the provideDatabaseBackends method. + * + * @param dbContainer The container running the MongoDB instance. + */ + @ParameterizedTest + @MethodSource("org.openhab.persistence.mongodb.internal.DataCreationHelper#provideDatabaseBackends") + public void testQueryNumberItemsInOneCollectionStateEquals(DatabaseTestContainer dbContainer) { + try { + // Preparation + SetupResult setupResult = DataCreationHelper.setupMongoDB("testCollection", dbContainer); + MongoDBPersistenceService service = setupResult.service; + MongoDatabase database = setupResult.database; + + try { + Mockito.when(setupResult.itemRegistry.getItem("TestItem")) + .thenReturn(DataCreationHelper.createNumberItem("TestItem", 0)); + } catch (ItemNotFoundException e) { + } + + service.activate(setupResult.bundleContext, setupResult.config); + + // Get the collection + MongoCollection collection = database.getCollection("testCollection"); + + // Store items directly to the database with defined timestamps + for (int i = 0; i < 10; i++) { + Document obj = DataCreationHelper.createDocument("TestItem", i, LocalDate.now().minusDays(i)); + collection.insertOne(obj); + } + + Document obj = DataCreationHelper.createDocument("TestItem", 4.0, LocalDate.now()); + collection.insertOne(obj); + + // Execution + FilterCriteria filter1 = DataCreationHelper.createFilterCriteria("TestItem", null, null); + filter1.setState(new DecimalType(4.0)); + filter1.setOperator(FilterCriteria.Operator.EQ); + + Iterable result1 = service.query(filter1); + + // Verification + VerificationHelper.verifyQueryResult(result1, 4, 0, 2); + } finally { + dbContainer.stop(); + } + } + + /** + * Tests the store method of the MongoDBPersistenceService with all types of openHAB items. + * Each item is stored in the collection in the MongoDB database. + * + * @param item The item to store in the database. + */ + @ParameterizedTest + @MethodSource("org.openhab.persistence.mongodb.internal.DataCreationHelper#provideOpenhabItemTypes") + public void testStoreAllOpenhabItemTypesSingleCollection(GenericItem item) { + // Preparation + DatabaseTestContainer dbContainer = new DatabaseTestContainer(new MemoryBackend()); + try { + SetupResult setupResult = DataCreationHelper.setupMongoDB("testCollection", dbContainer); + MongoDBPersistenceService service = setupResult.service; + MongoDatabase database = setupResult.database; + + service.activate(setupResult.bundleContext, setupResult.config); + + // Execution + service.store(item, null); + + // Verification + MongoCollection collection = database.getCollection("testCollection"); + List documents = (ArrayList) collection.find().into(new ArrayList<>()); + + assertEquals(1, documents.size()); // Assert that there is only one document + + Document insertedDocument = documents.get(0); // Get the first (and only) document + + VerificationHelper.verifyDocument(insertedDocument, item.getName(), item.getState()); + } finally { + dbContainer.stop(); + } + } + + /** + * Tests the store and query method for various image sizes of the MongoDBPersistenceService + * Each item is queried with the type from one collection in the MongoDB database. + * + * @param item The item to store in the database. + */ + @ParameterizedTest + @MethodSource("org.openhab.persistence.mongodb.internal.DataCreationHelper#provideOpenhabImageItemsInDifferentSizes") + public void testStoreAndQueryyLargerImages(ImageItem item) { + // Preparation + DatabaseTestContainer dbContainer = new DatabaseTestContainer(new MemoryBackend()); + try { + SetupResult setupResult = DataCreationHelper.setupMongoDB("testCollection", dbContainer); + MongoDBPersistenceService service = setupResult.service; + + service.activate(setupResult.bundleContext, setupResult.config); + try { + Mockito.when(setupResult.itemRegistry.getItem(item.getName())).thenReturn(item); + } catch (ItemNotFoundException e) { + } + try { + service.store(item, null); + } catch (org.bson.BsonMaximumSizeExceededException e) { + if (item.getName().equals("ImageItem20MB")) { + // this is expected + return; + } else { + throw e; + } + } + + // Execution + FilterCriteria filter = DataCreationHelper.createFilterCriteria(item.getName()); + Iterable result = service.query(filter); + // Verification + + VerificationHelper.verifyQueryResult(result, item.getState()); + } finally { + dbContainer.stop(); + } + } + + /** + * Tests the old way of storing data and query method of the MongoDBPersistenceService with all types of openHAB + * items. + * Each item is queried with the type from one collection in the MongoDB database. + * + * @param item The item to store in the database. + */ + @ParameterizedTest + @MethodSource("org.openhab.persistence.mongodb.internal.DataCreationHelper#provideOpenhabItemTypes") + public void testOldDataQueryAllOpenhabItemTypesSingleCollection(GenericItem item) { + // Preparation + DatabaseTestContainer dbContainer = new DatabaseTestContainer(new MemoryBackend()); + try { + SetupResult setupResult = DataCreationHelper.setupMongoDB("testCollection", dbContainer); + MongoDBPersistenceService service = setupResult.service; + MongoDatabase database = setupResult.database; + + service.activate(setupResult.bundleContext, setupResult.config); + try { + Mockito.when(setupResult.itemRegistry.getItem(item.getName())).thenReturn(item); + } catch (ItemNotFoundException e) { + } + MongoCollection collection = database.getCollection("testCollection"); + DataCreationHelper.storeOldData(collection, item.getName(), item.getState()); + // after storing, we have to adjust the expected values for ImageItems, ColorItems as well as DateTimeItems + if (item instanceof ImageItem) { + item.setState(new RawType(new byte[0], "application/octet-stream")); + } else if (item instanceof ColorItem) { + item.setState(new HSBType("0,0,0")); + } + + // Execution + FilterCriteria filter = DataCreationHelper.createFilterCriteria(item.getName()); + Iterable result = service.query(filter); + // Verification + + if (item instanceof DateTimeItem) { + // verify just the date part + assertEquals(((DateTimeType) item.getState()).getZonedDateTime().toLocalDate(), + ((DateTimeType) result.iterator().next().getState()).getZonedDateTime().toLocalDate()); + } else { + VerificationHelper.verifyQueryResult(result, item.getState()); + } + } finally { + dbContainer.stop(); + } + } + + /** + * Tests the writting of NumberItems including units + * Each item should be written to the database with the unit information + * + * @param item The item to store in the database. + */ + @Test + public void testStoreNumberItemWithUnit() { + // Preparation + DatabaseTestContainer dbContainer = new DatabaseTestContainer(new MemoryBackend()); + try { + SetupResult setupResult = DataCreationHelper.setupMongoDB("testCollection", dbContainer); + MongoDBPersistenceService service = setupResult.service; + MongoDatabase database = setupResult.database; + + service.activate(setupResult.bundleContext, setupResult.config); + MongoCollection collection = database.getCollection("testCollection"); + + NumberItem item = DataCreationHelper.createNumberItem("Number:Energy", "TestItem", + new QuantityType<>("10.1 kWh")); + + // Execution + service.store(item, null); + + // Verification + List documents = (ArrayList) collection.find().into(new ArrayList<>()); + + assertEquals(1, documents.size()); // Assert that there is only one document + + Document insertedDocument = documents.get(0); // Get the first (and only) document + + assertEquals(10.1, insertedDocument.get(MongoDBFields.FIELD_VALUE)); + assertEquals("kWh", insertedDocument.get(MongoDBFields.FIELD_UNIT)); + } finally { + dbContainer.stop(); + } + } + + /** + * Tests the reading of NumberItems including units + * Each item should be written to the database with the unit information + * + * @param item The item to store in the database. + */ + @Test + public void testQueryNumberItemWithUnit() { + // Preparation + DatabaseTestContainer dbContainer = new DatabaseTestContainer(new MemoryBackend()); + try { + SetupResult setupResult = DataCreationHelper.setupMongoDB("testCollection", dbContainer); + MongoDBPersistenceService service = setupResult.service; + MongoDatabase database = setupResult.database; + + service.activate(setupResult.bundleContext, setupResult.config); + MongoCollection collection = database.getCollection("testCollection"); + + NumberItem item = DataCreationHelper.createNumberItem("Number:Energy", "TestItem", + new QuantityType<>("10.1 MWh")); + try { + Mockito.when(setupResult.itemRegistry.getItem("TestItem")).thenReturn(item); + } catch (ItemNotFoundException e) { + } + + Document obj = new Document(); + obj.put(MongoDBFields.FIELD_ID, new ObjectId()); + obj.put(MongoDBFields.FIELD_ITEM, "TestItem"); + obj.put(MongoDBFields.FIELD_REALNAME, "TestItem"); + obj.put(MongoDBFields.FIELD_TIMESTAMP, new Date()); + obj.put(MongoDBFields.FIELD_VALUE, 201.5); + obj.put(MongoDBFields.FIELD_UNIT, "Wh"); + collection.insertOne(obj); + + // Execution + FilterCriteria filter = DataCreationHelper.createFilterCriteria("TestItem"); + Iterable result = service.query(filter); + VerificationHelper.verifyQueryResult(result, new QuantityType<>("201.5 Wh")); + } finally { + dbContainer.stop(); + } + } + + /** + * Tests the toString of a MongoDBItem + * + * + * @param item The item to store in the database. + */ + @Test + public void testHistoricItemToString() { + // Preparation + ZonedDateTime now = ZonedDateTime.now(); + HistoricItem item = new MongoDBItem("TestItem", new DecimalType(10.1), now); + + // Execution + String result = item.toString(); + + // Verification + // Jan 29, 2024, 8:43:26 PM: TestItem -> 10.1 + String expected = DateFormat.getDateTimeInstance().format(Date.from(now.toInstant())) + ": TestItem -> 10.1"; + assertEquals(expected, result); + } + + /* + * Test the store method which stores a item state as well as a timestampe (ZonedDateTime) and check the result in + * the database + */ + @Test + public void testStoreItemWithTimestamp() { + // Preparation + DatabaseTestContainer dbContainer = new DatabaseTestContainer(new MemoryBackend()); + try { + SetupResult setupResult = DataCreationHelper.setupMongoDB(null, dbContainer); + MongoDBPersistenceService service = setupResult.service; + MongoDatabase database = setupResult.database; + + service.activate(setupResult.bundleContext, setupResult.config); + try { + Mockito.when(setupResult.itemRegistry.getItem("TestItem")) + .thenReturn(DataCreationHelper.createNumberItem("TestItem", 0)); + } catch (ItemNotFoundException e) { + } + + // Execution + NumberItem item = DataCreationHelper.createNumberItem("TestItem", 10.1); + DecimalType historicState = new DecimalType(11110.1); + ZonedDateTime now = ZonedDateTime.now(); + service.store(item, now, historicState); + + // Verification + MongoCollection collection = database.getCollection("TestItem"); + List documents = (ArrayList) collection.find().into(new ArrayList<>()); + + assertEquals(1, documents.size()); // Assert that there is only one document + + Document insertedDocument = documents.get(0); // Get the first (and only) document + + VerificationHelper.verifyDocument(insertedDocument, "TestItem", historicState); + assertEquals(Date.from(now.toInstant()), insertedDocument.get(MongoDBFields.FIELD_TIMESTAMP)); + } finally { + dbContainer.stop(); + } + } + + /* + * Test the store method which stores a item state as well as a timestampe (ZonedDateTime) and check the result in + * the database + */ + @Test + public void testStoreItemWithTimestampAndAlias() { + // Preparation + DatabaseTestContainer dbContainer = new DatabaseTestContainer(new MemoryBackend()); + try { + SetupResult setupResult = DataCreationHelper.setupMongoDB(null, dbContainer); + MongoDBPersistenceService service = setupResult.service; + MongoDatabase database = setupResult.database; + + service.activate(setupResult.bundleContext, setupResult.config); + try { + Mockito.when(setupResult.itemRegistry.getItem("TestItem")) + .thenReturn(DataCreationHelper.createNumberItem("TestItem", 0)); + } catch (ItemNotFoundException e) { + } + + // Execution + NumberItem item = DataCreationHelper.createNumberItem("TestItem", 10.1); + DecimalType historicState = new DecimalType(11110.1); + ZonedDateTime now = ZonedDateTime.now(); + service.store(item, now, historicState, "AliasName"); + + // Verification + MongoCollection collection = database.getCollection("TestItem"); + List documents = (ArrayList) collection.find().into(new ArrayList<>()); + + assertEquals(1, documents.size()); // Assert that there is only one document + + Document insertedDocument = documents.get(0); // Get the first (and only) document + + VerificationHelper.verifyDocumentWithAlias(insertedDocument, "AliasName", "TestItem", historicState); + assertEquals(Date.from(now.toInstant()), insertedDocument.get(MongoDBFields.FIELD_TIMESTAMP)); + } finally { + dbContainer.stop(); + } + } + + /* + * Test the remove method to remove one item from the database + */ + @Test + public void testremoveOneItem() { + // Preparation + DatabaseTestContainer dbContainer = new DatabaseTestContainer(new MemoryBackend()); + try { + SetupResult setupResult = DataCreationHelper.setupMongoDB("testcollection", dbContainer); + MongoDBPersistenceService service = setupResult.service; + MongoDatabase database = setupResult.database; + + service.activate(setupResult.bundleContext, setupResult.config); + + for (double i = 0; i < 10.00; i += 0.3) { + service.store(DataCreationHelper.createNumberItem("TestItem", i)); + } + service.store(DataCreationHelper.createNumberItem("TestItemOther", 10.1)); + + // Execution + service.remove(DataCreationHelper.createFilterCriteria("TestItem", null, null)); + + // Verification + MongoCollection collection = database.getCollection("testcollection"); + + List documents = (ArrayList) collection.find().into(new ArrayList<>()); + + assertEquals(1, documents.size()); // Assert that there is the other document + + VerificationHelper.verifyDocument(documents.get(0), "TestItemOther", 10.1); + } finally { + dbContainer.stop(); + } + } + + /* + * Test the remove method to remove values of a given timerange for one item + */ + @Test + public void testremoveATimeRangeFromOneItem() { + // Preparation + DatabaseTestContainer dbContainer = new DatabaseTestContainer(new MemoryBackend()); + try { + SetupResult setupResult = DataCreationHelper.setupMongoDB("testcollection", dbContainer); + MongoDBPersistenceService service = setupResult.service; + MongoDatabase database = setupResult.database; + + service.activate(setupResult.bundleContext, setupResult.config); + + List testDataList = DataCreationHelper.createTestData(service, "TestItem", + "TestItemOther"); + + // Execution + // Calculate the start and end dates + ZonedDateTime startDate = ZonedDateTime.now().plusDays(3).truncatedTo(ChronoUnit.DAYS); + ZonedDateTime endDate = ZonedDateTime.now().plusDays(17).truncatedTo(ChronoUnit.DAYS).plusDays(1) + .minusNanos(1); + + // Create the filter and remove the data + service.remove(DataCreationHelper.createFilterCriteria("TestItem", startDate, endDate)); + + // Verification + MongoCollection collection = database.getCollection("testcollection"); + + // Query the database for all data points + List documents = (ArrayList) collection.find().into(new ArrayList<>()); + + // Create a set of the returned data points + Set returnedData = documents.stream() + .map(doc -> new PersistenceTestItem(doc.getString(MongoDBFields.FIELD_ITEM), + ZonedDateTime.ofInstant(doc.getDate(MongoDBFields.FIELD_TIMESTAMP).toInstant(), + ZoneId.systemDefault()), + doc.getDouble(MongoDBFields.FIELD_VALUE))) + .collect(Collectors.toSet()); + + // Create a set of the expected data points + Set expectedData = testDataList + .stream().filter(testData -> !(testData.itemName.equals("TestItem") + && testData.date.isAfter(startDate) && testData.date.isBefore(endDate))) + .collect(Collectors.toSet()); + + for (PersistenceTestItem expectedItem : expectedData) { + // Assert that this item is in the returned data + assertTrue(returnedData.contains(expectedItem), + "Expected item not found in returned data: " + expectedItem); + } + + // Iterate over the returned data + for (PersistenceTestItem returnedItem : returnedData) { + // Assert that this item is in the expected data + assertTrue(expectedData.contains(returnedItem), + "Unexpected item found in returned data: " + returnedItem); + } + } finally { + dbContainer.stop(); + } + } +} diff --git a/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/PersistenceTestItem.java b/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/PersistenceTestItem.java new file mode 100644 index 0000000000..e616591922 --- /dev/null +++ b/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/PersistenceTestItem.java @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2010-2024 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.mongodb.internal; + +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Objects; + +/** + * This class provides helper methods to store generated data for persistence tests. + * + * @author René Ulbricht - Initial contribution + */ +public class PersistenceTestItem { + public final String itemName; + public final ZonedDateTime date; + public final double value; + + public PersistenceTestItem(String itemName, ZonedDateTime date, double value) { + this.itemName = itemName; + this.date = date.truncatedTo(ChronoUnit.MILLIS); + this.value = value; + } + + @Override + public String toString() { + return "PersistenceTestItem{" + "item='" + itemName + '\'' + ", date=" + date + ", value=" + value + '}'; + } + + @Override + public int hashCode() { + return Objects.hash(itemName, date, value); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + PersistenceTestItem other = (PersistenceTestItem) obj; + return other.itemName.equals(itemName) && other.date.equals(date) && other.value == value; + } +} diff --git a/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/SetupResult.java b/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/SetupResult.java new file mode 100644 index 0000000000..39df3b0ffd --- /dev/null +++ b/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/SetupResult.java @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2010-2024 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.mongodb.internal; + +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.items.ItemRegistry; +import org.osgi.framework.BundleContext; + +import com.mongodb.client.MongoDatabase; + +/** + * This class provides helper methods to create test items. + * + * @author René Ulbricht - Initial contribution + */ +@NonNullByDefault +public class SetupResult { + public MongoDBPersistenceService service; + public MongoDatabase database; + public BundleContext bundleContext; + public Map config; + public ItemRegistry itemRegistry; + public String dbname; + + public SetupResult(MongoDBPersistenceService service, MongoDatabase database, BundleContext bundleContext, + Map config, ItemRegistry itemRegistry, String dbname) { + this.service = service; + this.database = database; + this.dbname = dbname; + this.bundleContext = bundleContext; + this.config = config; + this.itemRegistry = itemRegistry; + } +} diff --git a/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/VerificationHelper.java b/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/VerificationHelper.java new file mode 100644 index 0000000000..09e4ea6f11 --- /dev/null +++ b/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/VerificationHelper.java @@ -0,0 +1,206 @@ +/** + * Copyright (c) 2010-2024 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.mongodb.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; + +import org.apache.commons.lang3.tuple.Pair; +import org.bson.Document; +import org.bson.json.JsonWriterSettings; +import org.bson.types.Binary; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.HSBType; +import org.openhab.core.library.types.IncreaseDecreaseType; +import org.openhab.core.library.types.NextPreviousType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.OpenClosedType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.types.PlayPauseType; +import org.openhab.core.library.types.PointType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.RawType; +import org.openhab.core.library.types.RewindFastforwardType; +import org.openhab.core.library.types.StopMoveType; +import org.openhab.core.library.types.StringListType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.types.UpDownType; +import org.openhab.core.persistence.HistoricItem; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; + +/** + * This is a helper class for verifying various aspects of the MongoDB persistence service. + * It provides methods for verifying log messages, MongoDB documents, and query results. + * Each verification method checks if the actual value matches the expected value and throws an + * AssertionError if they do not match. + * + * @author René Ulbricht - Initial contribution + */ +@NonNullByDefault +public class VerificationHelper { + + /** + * Verifies a log message. + * + * @param logEvent The log event to verify. + * @param expectedMessage The expected message of the log event. + * @param expectedLevel The expected level of the log event. + */ + public static void verifyLogMessage(ILoggingEvent logEvent, String expectedMessage, Level expectedLevel) { + assertEquals(expectedMessage, logEvent.getFormattedMessage()); + assertEquals(expectedLevel, logEvent.getLevel()); + } + + /** + * Verifies a document. + * + * @param document The document to verify. + * @param expectedItem The expected item of the document. + * @param expectedValue The expected value of the document. + */ + public static void verifyDocument(Document document, String expectedItem, Object expectedValue) { + verifyDocumentWithAlias(document, expectedItem, expectedItem, expectedValue); + } + + /** + * Verifies a document with an alias. + * + * @param document The document to verify. + * @param expectedAlias The expected alias of the document. + * @param expectedRealName The expected real name of the document. + * @param expectedValue The expected value of the document. Can be a String or a Double. + */ + public static void verifyDocumentWithAlias(Document document, String expectedAlias, String expectedRealName, + Object expectedValue) { + assertEquals(expectedAlias, document.get(MongoDBFields.FIELD_ITEM)); + assertEquals(expectedRealName, document.get(MongoDBFields.FIELD_REALNAME)); + + // Use the map to handle the expected value + BiFunction> handler = HandleTypes.get(expectedValue.getClass()); + if (handler == null) { + throw new IllegalArgumentException("Unsupported type: " + expectedValue.getClass()); + } + Pair values = handler.apply(expectedValue, document); + + JsonWriterSettings jsonWriterSettings = JsonWriterSettings.builder().indent(true).build(); + assertEquals(values.getLeft(), values.getRight(), + "Document: (" + expectedValue.getClass().getSimpleName() + ") " + document.toJson(jsonWriterSettings)); + + assertNotNull(document.get("_id")); + assertNotNull(document.get("timestamp")); + } + + /** + * Verifies the result of a query. + * + * @param result The result of the query. + * @param startState The state of the first item in the result. + * @param increment The increment for the expected state. + */ + public static void verifyQueryResult(Iterable result, int startState, int increment, int totalSize) { + List resultList = new ArrayList<>(); + result.forEach(resultList::add); + + assertEquals(totalSize, resultList.size()); + + int expectedState = startState; + for (HistoricItem item : resultList) { + assertEquals(expectedState, ((DecimalType) item.getState()).intValue()); + expectedState += increment; + } + } + + public static void verifyQueryResult(Iterable result, Object expectedState) { + List resultList = new ArrayList<>(); + result.forEach(resultList::add); + + assertEquals(1, resultList.size()); + + assertEquals(expectedState, resultList.get(0).getState()); + } + + // Define a map from types to functions that handle those types + private static final Map, BiFunction>> HandleTypes = Map.ofEntries( + Map.entry(Double.class, VerificationHelper::handleGeneric), + Map.entry(String.class, VerificationHelper::handleGeneric), + Map.entry(HSBType.class, VerificationHelper::handleToString), + Map.entry(DecimalType.class, VerificationHelper::handleDecimalType), + Map.entry(DateTimeType.class, VerificationHelper::handleDateTimeType), + Map.entry(IncreaseDecreaseType.class, VerificationHelper::handleToString), + Map.entry(RewindFastforwardType.class, VerificationHelper::handleToString), + Map.entry(NextPreviousType.class, VerificationHelper::handleToString), + Map.entry(OnOffType.class, VerificationHelper::handleToString), + Map.entry(OpenClosedType.class, VerificationHelper::handleToString), + Map.entry(PercentType.class, VerificationHelper::handlePercentType), + Map.entry(PlayPauseType.class, VerificationHelper::handleToString), + Map.entry(PointType.class, VerificationHelper::handleToString), + Map.entry(StopMoveType.class, VerificationHelper::handleToString), + Map.entry(StringListType.class, VerificationHelper::handleToString), + Map.entry(StringType.class, VerificationHelper::handleGeneric), + Map.entry(UpDownType.class, VerificationHelper::handleToString), + Map.entry(QuantityType.class, VerificationHelper::handleQuantityType), + Map.entry(RawType.class, VerificationHelper::handleRawType)); + + private static Pair handleGeneric(Object ev, Document doc) { + Object value = doc.get(MongoDBFields.FIELD_VALUE); + return Pair.of(ev, value != null ? value : new Object()); + } + + private static Pair handleToString(Object ev, Document doc) { + Object value = doc.get(MongoDBFields.FIELD_VALUE); + return Pair.of(ev.toString(), value != null ? value : new Object()); + } + + private static Pair handleDecimalType(Object ev, Document doc) { + Double value = doc.getDouble(MongoDBFields.FIELD_VALUE); + return Pair.of(((DecimalType) ev).doubleValue(), value != null ? value : new Object()); + } + + private static Pair handleDateTimeType(Object ev, Document doc) { + String value = doc.getString(MongoDBFields.FIELD_VALUE); + return Pair.of(((DateTimeType) ev).getZonedDateTime().toString(), value != null ? value : new Object()); + } + + private static Pair handlePercentType(Object ev, Document doc) { + Integer value = doc.getInteger(MongoDBFields.FIELD_VALUE); + return Pair.of(((PercentType) ev).intValue(), value != null ? value : new Object()); + } + + private static Pair handleQuantityType(Object ev, Document doc) { + Double value = doc.getDouble(MongoDBFields.FIELD_VALUE); + String unit = doc.getString(MongoDBFields.FIELD_UNIT); + if (value != null && unit != null) { + QuantityType quantityType = (QuantityType) ev; + return Pair.of(quantityType.doubleValue() + "--" + quantityType.getUnit(), value + "--" + unit); + } + return Pair.of(new Object(), new Object()); + } + + private static Pair handleRawType(Object ev, Document doc) { + RawType rawType = (RawType) ev; + Document expectedDoc = new Document(); + expectedDoc.put(MongoDBFields.FIELD_VALUE_TYPE, rawType.getMimeType()); + expectedDoc.put(MongoDBFields.FIELD_VALUE_DATA, new Binary(rawType.getBytes())); + Object value = doc.get(MongoDBFields.FIELD_VALUE); + return Pair.of(expectedDoc, value != null ? value : new Object()); + } +} -- 2.47.3