<name>openHAB Add-ons :: Bundles :: Persistence Service :: MongoDB</name>
+ <properties>
+ <bnd.importpackage>!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</bnd.importpackage>
+ </properties>
+
<dependencies>
- <!-- https://mvnrepository.com/artifact/org.mongodb/mongo-java-driver -->
<dependency>
<groupId>org.mongodb</groupId>
- <artifactId>mongo-java-driver</artifactId>
- <version>2.13.1</version>
+ <artifactId>mongodb-driver-sync</artifactId>
+ <version>4.11.1</version>
+ <scope>compile</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mongodb</groupId>
+ <artifactId>bson</artifactId>
+ <version>4.11.1</version>
+ <scope>compile</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mongodb</groupId>
+ <artifactId>mongodb-driver-core</artifactId>
+ <version>4.11.1</version>
+ <scope>compile</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.xerial.snappy</groupId>
+ <artifactId>snappy-java</artifactId>
+ <version>1.1.10.3</version>
+ <scope>compile</scope>
+ </dependency>
+ <dependency>
+ <groupId>com.github.luben</groupId>
+ <artifactId>zstd-jni</artifactId>
+ <version>1.5.5-3</version>
+ <scope>compile</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mongodb</groupId>
+ <artifactId>bson-record-codec</artifactId>
+ <version>4.11.1</version>
+ <scope>compile</scope>
+ </dependency>
+
+ <!-- Test dependencies -->
+ <dependency>
+ <groupId>de.bwaldvogel</groupId>
+ <artifactId>mongo-java-server</artifactId>
+ <version>1.44.0</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.testcontainers</groupId>
+ <artifactId>mongodb</artifactId>
+ <version>1.19.4</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.commons</groupId>
+ <artifactId>commons-lang3</artifactId>
+ <version>3.14.0</version>
+ <scope>test</scope>
</dependency>
</dependencies>
</project>
--- /dev/null
+/**
+ * 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
+ }
+}
import java.text.DateFormat;
import java.time.ZonedDateTime;
+import java.util.Date;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.persistence.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();
}
}
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;
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);
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<PersistenceItemInfo> getItemInfo() {
return Collections.emptySet();
* @return true if connection has been established, false otherwise
*/
private synchronized boolean isConnected() {
- if (cl == null) {
+ MongoClient localCl = cl;
+ if (localCl == null) {
return false;
}
// 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;
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();
*
* @return The collection object when collection creation was successful. Null otherwise.
*/
- private @Nullable DBCollection connectToCollection(String collectionName) {
+ private @Nullable MongoCollection<Document> connectToCollection(String collectionName) {
try {
@Nullable
MongoClient db = getDatabase();
return null;
}
- DBCollection mongoCollection = db.getDB(this.db).getCollection(collectionName);
+ MongoCollection<Document> 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;
* 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;
@Override
public Iterable<HistoricItem> query(FilterCriteria filter) {
- if (!initialized) {
+ MongoCollection<Document> 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<HistoricItem> items = new ArrayList<>();
+
+ logger.debug("Query: {}", query);
+
+ Integer sortDir = (filter.getOrdering() == Ordering.ASCENDING) ? 1 : -1;
+ MongoCursor<Document> 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<PersistenceStrategy> 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<Document> 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<Document> 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<Document> collection = getCollection(realItemName);
+ return collection;
+ }
- if (item == null) {
- logger.warn("Item {} not found", realItemName);
- return Collections.emptyList();
+ @Nullable
+ private MongoCollection<Document> getCollection(String realItemName) {
+ String collectionName = collectionPerItem ? realItemName : this.collection;
+ @Nullable
+ MongoCollection<Document> collection = connectToCollection(collectionName);
+
+ if (collection == null) {
+ // Logging is done in connectToCollection()
+ logger.warn("Failed to connect to collection {}", collectionName);
}
- List<HistoricItem> 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<Document> 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<PersistenceStrategy> getDefaultStrategies() {
- return Collections.emptyList();
+ logger.debug("Query: {}", query);
+
+ DeleteResult result = collection.deleteMany(query);
+
+ logger.debug("Deleted {} documents", result.getDeletedCount());
+ return true;
}
}
--- /dev/null
+/**
+ * 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<Item, Document, State> 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<Class<? extends State>, Function<State, Object>> 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<Class<? extends Item>, BiFunction<Item, Document, State>> 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");
+ }
+ }
+}
--- /dev/null
+/**
+ * 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<String, Object> 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 <T> The type of the item to create. This must be a subclass of GenericItem.
+ * @param <S> 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 extends GenericItem, S extends State> T createItem(Class<T> 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<Arguments> 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<Arguments> 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<Arguments> 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<String, Object> 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<ILoggingEvent> 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<ILoggingEvent> setupLogger(Class<?> loggerClass, Level level) {
+ Logger logger = (Logger) LoggerFactory.getLogger(loggerClass);
+ ListAppender<ILoggingEvent> 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<Document> 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<PersistenceTestItem> createTestData(MongoDBPersistenceService service, String... itemNames) {
+ // Prepare a list to store the test data for verification
+ List<PersistenceTestItem> 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;
+ }
+}
--- /dev/null
+/**
+ * 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<String, MongoDBContainer> 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 "";
+ }
+ }
+}
--- /dev/null
+/**
+ * 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<ILoggingEvent> listAppender = DataCreationHelper.setupLogger(MongoDBPersistenceService.class,
+ Level.DEBUG);
+
+ // Execution
+ setupResult.service.activate(setupResult.bundleContext, setupResult.config);
+
+ // Verification
+ List<ILoggingEvent> 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<ILoggingEvent> listAppender = DataCreationHelper.setupLogger(MongoDBPersistenceService.class,
+ Level.DEBUG);
+
+ // Execution
+ setupResult.service.deactivate(1);
+
+ // Verification
+ List<ILoggingEvent> 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<Document> collection = database.getCollection("testCollection");
+ List<Document> documents = (ArrayList<Document>) 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<Document> collection = database.getCollection("testCollection");
+ List<Document> documents = (ArrayList<Document>) 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<Document> collection = database.getCollection("testCollection");
+ List<Document> documents = (ArrayList<Document>) 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<Document> collection = database.getCollection("testCollection");
+ List<Document> documents = (ArrayList<Document>) 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<Document> collection = database.getCollection("testCollection");
+ List<Document> documents = (ArrayList<Document>) 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<HistoricItem> result1 = service.query(filter1);
+
+ FilterCriteria filter2 = DataCreationHelper.createFilterCriteria("TestItem2");
+ Iterable<HistoricItem> 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<String, Object> 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<HistoricItem> result1 = service.query(filter1);
+
+ FilterCriteria filter2 = DataCreationHelper.createFilterCriteria("TestItem2");
+ Iterable<HistoricItem> 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<Document> 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<HistoricItem> 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<Document> 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<HistoricItem> 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<Document> collection = database.getCollection("testCollection");
+ List<Document> documents = (ArrayList<Document>) 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<HistoricItem> 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<Document> 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<HistoricItem> 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<Document> collection = database.getCollection("testCollection");
+
+ NumberItem item = DataCreationHelper.createNumberItem("Number:Energy", "TestItem",
+ new QuantityType<>("10.1 kWh"));
+
+ // Execution
+ service.store(item, null);
+
+ // Verification
+ List<Document> documents = (ArrayList<Document>) 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<Document> 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<HistoricItem> 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<Document> collection = database.getCollection("TestItem");
+ List<Document> documents = (ArrayList<Document>) 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<Document> collection = database.getCollection("TestItem");
+ List<Document> documents = (ArrayList<Document>) 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<Document> collection = database.getCollection("testcollection");
+
+ List<Document> documents = (ArrayList<Document>) 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<PersistenceTestItem> 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<Document> collection = database.getCollection("testcollection");
+
+ // Query the database for all data points
+ List<Document> documents = (ArrayList<Document>) collection.find().into(new ArrayList<>());
+
+ // Create a set of the returned data points
+ Set<PersistenceTestItem> 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<PersistenceTestItem> 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();
+ }
+ }
+}
--- /dev/null
+/**
+ * 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;
+ }
+}
--- /dev/null
+/**
+ * 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<String, Object> config;
+ public ItemRegistry itemRegistry;
+ public String dbname;
+
+ public SetupResult(MongoDBPersistenceService service, MongoDatabase database, BundleContext bundleContext,
+ Map<String, Object> config, ItemRegistry itemRegistry, String dbname) {
+ this.service = service;
+ this.database = database;
+ this.dbname = dbname;
+ this.bundleContext = bundleContext;
+ this.config = config;
+ this.itemRegistry = itemRegistry;
+ }
+}
--- /dev/null
+/**
+ * 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<Object, Document, Pair<Object, Object>> handler = HandleTypes.get(expectedValue.getClass());
+ if (handler == null) {
+ throw new IllegalArgumentException("Unsupported type: " + expectedValue.getClass());
+ }
+ Pair<Object, Object> 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<HistoricItem> result, int startState, int increment, int totalSize) {
+ List<HistoricItem> 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<HistoricItem> result, Object expectedState) {
+ List<HistoricItem> 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<Class<?>, BiFunction<Object, Document, Pair<Object, Object>>> 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<Object, Object> handleGeneric(Object ev, Document doc) {
+ Object value = doc.get(MongoDBFields.FIELD_VALUE);
+ return Pair.of(ev, value != null ? value : new Object());
+ }
+
+ private static Pair<Object, Object> 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<Object, Object> 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<Object, Object> 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<Object, Object> 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<Object, Object> 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<Object, Object> 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());
+ }
+}