]> git.basschouten.com Git - openhab-addons.git/commitdiff
[mongodb] Upgrade DB driver, add more type handlings, fix QuantityType handling ...
authorulbi <rene_ulbricht@outlook.com>
Sat, 17 Feb 2024 09:58:14 +0000 (10:58 +0100)
committerGitHub <noreply@github.com>
Sat, 17 Feb 2024 09:58:14 +0000 (10:58 +0100)
* #16308 #16310 Upgraded MongoDB driver, added initial unit tests
* #16308 #16310 Refactored the MongoDBPersistence adding helper, fixing type handling for HSBType, RawType and QuantityType
* #16308 Added backwardcompatibility for the old way of writting the data where possible
* #16308 Added test for larger ImageItems and the limit of 16 MB

Signed-off-by: René Ulbricht <rene_ulbricht@outlook.com>
bundles/org.openhab.persistence.mongodb/pom.xml
bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBFields.java [new file with mode: 0644]
bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBItem.java
bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBPersistenceService.java
bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBTypeConversions.java [new file with mode: 0644]
bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/DataCreationHelper.java [new file with mode: 0644]
bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/DatabaseTestContainer.java [new file with mode: 0644]
bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/MongoDBPersistenceServiceTest.java [new file with mode: 0644]
bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/PersistenceTestItem.java [new file with mode: 0644]
bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/SetupResult.java [new file with mode: 0644]
bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/VerificationHelper.java [new file with mode: 0644]

index a70513d250c2c8c1c694c22eb2460b42e768151b..3564805dc3d7ca517089fb4cde07a88da3994a20 100644 (file)
 
   <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>
diff --git a/bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBFields.java b/bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBFields.java
new file mode 100644 (file)
index 0000000..52990d1
--- /dev/null
@@ -0,0 +1,37 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.persistence.mongodb.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * This class defines constant field names used in MongoDB documents.
+ * These field names are used to ensure consistent access to document properties.
+ *
+ * @author René Ulbricht - Initial contribution
+ */
+@NonNullByDefault
+public final class MongoDBFields {
+    public static final String FIELD_ID = "_id";
+    public static final String FIELD_ITEM = "item";
+    public static final String FIELD_REALNAME = "realName";
+    public static final String FIELD_TIMESTAMP = "timestamp";
+    public static final String FIELD_VALUE = "value";
+    public static final String FIELD_UNIT = "unit";
+    public static final String FIELD_VALUE_DATA = "value.data";
+    public static final String FIELD_VALUE_TYPE = "value.type";
+
+    private MongoDBFields() {
+        // Private constructor to prevent instantiation
+    }
+}
index ef23fdf78f52282c39140f1abc2029af8f4879ec..555e5af6db791a52d8ac045eb95214fb641756c8 100644 (file)
@@ -14,6 +14,7 @@ package org.openhab.persistence.mongodb.internal;
 
 import java.text.DateFormat;
 import java.time.ZonedDateTime;
+import java.util.Date;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.openhab.core.persistence.HistoricItem;
@@ -54,6 +55,7 @@ public class MongoDBItem implements HistoricItem {
 
     @Override
     public String toString() {
-        return DateFormat.getDateTimeInstance().format(timestamp) + ": " + name + " -> " + state.toString();
+        Date date = Date.from(timestamp.toInstant());
+        return DateFormat.getDateTimeInstance().format(date) + ": " + name + " -> " + state.toString();
     }
 }
index 532dd2e6b44991e2967766bec63608f04f568589..3ad12dbe61bf8bdc5dcd70c7874a0a2a6e7f4d42 100644 (file)
@@ -22,28 +22,19 @@ import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 
+import org.bson.Document;
 import org.bson.types.ObjectId;
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
 import org.openhab.core.items.Item;
 import org.openhab.core.items.ItemNotFoundException;
 import org.openhab.core.items.ItemRegistry;
-import org.openhab.core.library.items.ContactItem;
-import org.openhab.core.library.items.DateTimeItem;
-import org.openhab.core.library.items.DimmerItem;
 import org.openhab.core.library.items.NumberItem;
-import org.openhab.core.library.items.RollershutterItem;
-import org.openhab.core.library.items.SwitchItem;
-import org.openhab.core.library.types.DateTimeType;
-import org.openhab.core.library.types.DecimalType;
-import org.openhab.core.library.types.OnOffType;
-import org.openhab.core.library.types.OpenClosedType;
-import org.openhab.core.library.types.PercentType;
-import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.types.QuantityType;
 import org.openhab.core.persistence.FilterCriteria;
-import org.openhab.core.persistence.FilterCriteria.Operator;
 import org.openhab.core.persistence.FilterCriteria.Ordering;
 import org.openhab.core.persistence.HistoricItem;
+import org.openhab.core.persistence.ModifiablePersistenceService;
 import org.openhab.core.persistence.PersistenceItemInfo;
 import org.openhab.core.persistence.PersistenceService;
 import org.openhab.core.persistence.QueryablePersistenceService;
@@ -59,29 +50,23 @@ import org.osgi.service.component.annotations.Reference;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import com.mongodb.BasicDBObject;
-import com.mongodb.DBCollection;
-import com.mongodb.DBCursor;
-import com.mongodb.DBObject;
-import com.mongodb.MongoClient;
-import com.mongodb.MongoClientURI;
+import com.mongodb.client.MongoClient;
+import com.mongodb.client.MongoClients;
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.MongoCursor;
+import com.mongodb.client.result.DeleteResult;
 
 /**
  * This is the implementation of the MongoDB {@link PersistenceService}.
  *
  * @author Thorsten Hoeger - Initial contribution
  * @author Stephan Brunner - Query fixes, Cleanup
+ * @author René Ulbricht - Fixes type handling, driver update and cleanup
  */
 @NonNullByDefault
-@Component(service = { PersistenceService.class,
-        QueryablePersistenceService.class }, configurationPid = "org.openhab.mongodb", configurationPolicy = ConfigurationPolicy.REQUIRE)
-public class MongoDBPersistenceService implements QueryablePersistenceService {
-
-    private static final String FIELD_ID = "_id";
-    private static final String FIELD_ITEM = "item";
-    private static final String FIELD_REALNAME = "realName";
-    private static final String FIELD_TIMESTAMP = "timestamp";
-    private static final String FIELD_VALUE = "value";
+@Component(service = { PersistenceService.class, QueryablePersistenceService.class,
+        ModifiablePersistenceService.class }, configurationPid = "org.openhab.mongodb", configurationPolicy = ConfigurationPolicy.REQUIRE)
+public class MongoDBPersistenceService implements ModifiablePersistenceService {
 
     private final Logger logger = LoggerFactory.getLogger(MongoDBPersistenceService.class);
 
@@ -150,72 +135,6 @@ public class MongoDBPersistenceService implements QueryablePersistenceService {
         return "MongoDB";
     }
 
-    @Override
-    public void store(Item item, @Nullable String alias) {
-        // Don't log undefined/uninitialized data
-        if (item.getState() instanceof UnDefType) {
-            return;
-        }
-
-        // If we've not initialized the bundle, then return
-        if (!initialized) {
-            logger.warn("MongoDB not initialized");
-            return;
-        }
-
-        // Connect to mongodb server if we're not already connected
-        // If we can't connect, log.
-        if (!tryConnectToDatabase()) {
-            logger.warn(
-                    "mongodb: No connection to database. Cannot persist item '{}'! Will retry connecting to database next time.",
-                    item);
-            return;
-        }
-
-        String realItemName = item.getName();
-        String collectionName = collectionPerItem ? realItemName : this.collection;
-
-        @Nullable
-        DBCollection collection = connectToCollection(collectionName);
-
-        if (collection == null) {
-            // Logging is done in connectToCollection()
-            return;
-        }
-
-        String name = (alias != null) ? alias : realItemName;
-        Object value = this.convertValue(item.getState());
-
-        DBObject obj = new BasicDBObject();
-        obj.put(FIELD_ID, new ObjectId());
-        obj.put(FIELD_ITEM, name);
-        obj.put(FIELD_REALNAME, realItemName);
-        obj.put(FIELD_TIMESTAMP, new Date());
-        obj.put(FIELD_VALUE, value);
-        collection.save(obj);
-
-        logger.debug("MongoDB save {}={}", name, value);
-    }
-
-    private Object convertValue(State state) {
-        Object value;
-        if (state instanceof PercentType type) {
-            value = type.toBigDecimal().doubleValue();
-        } else if (state instanceof DateTimeType type) {
-            value = Date.from(type.getZonedDateTime().toInstant());
-        } else if (state instanceof DecimalType type) {
-            value = type.toBigDecimal().doubleValue();
-        } else {
-            value = state.toString();
-        }
-        return value;
-    }
-
-    @Override
-    public void store(Item item) {
-        store(item, null);
-    }
-
     @Override
     public Set<PersistenceItemInfo> getItemInfo() {
         return Collections.emptySet();
@@ -228,7 +147,8 @@ public class MongoDBPersistenceService implements QueryablePersistenceService {
      * @return true if connection has been established, false otherwise
      */
     private synchronized boolean isConnected() {
-        if (cl == null) {
+        MongoClient localCl = cl;
+        if (localCl == null) {
             return false;
         }
 
@@ -236,7 +156,7 @@ public class MongoDBPersistenceService implements QueryablePersistenceService {
         // Network problems may cause failure sometimes,
         // even if the connection object was successfully created before.
         try {
-            cl.getAddress();
+            localCl.listDatabaseNames().first();
             return true;
         } catch (Exception ex) {
             return false;
@@ -257,14 +177,17 @@ public class MongoDBPersistenceService implements QueryablePersistenceService {
             logger.debug("Connect MongoDB");
             disconnectFromDatabase();
 
-            this.cl = new MongoClient(new MongoClientURI(this.url));
+            this.cl = MongoClients.create(this.url);
+            MongoClient localCl = this.cl;
 
-            // The mongo always succeeds in creating the connection.
+            // The MongoDB driver always succeeds in creating the connection.
             // We have to actually force it to test the connection to try to connect to the server.
-            cl.getAddress();
-
-            logger.debug("Connect MongoDB ... done");
-            return true;
+            if (localCl != null) {
+                localCl.listDatabaseNames().first();
+                logger.debug("Connect MongoDB ... done");
+                return true;
+            }
+            return false;
         } catch (Exception e) {
             logger.error("Failed to connect to database {}: {}", this.url, e.getMessage(), e);
             disconnectFromDatabase();
@@ -286,7 +209,7 @@ public class MongoDBPersistenceService implements QueryablePersistenceService {
      *
      * @return The collection object when collection creation was successful. Null otherwise.
      */
-    private @Nullable DBCollection connectToCollection(String collectionName) {
+    private @Nullable MongoCollection<Document> connectToCollection(String collectionName) {
         try {
             @Nullable
             MongoClient db = getDatabase();
@@ -296,10 +219,10 @@ public class MongoDBPersistenceService implements QueryablePersistenceService {
                 return null;
             }
 
-            DBCollection mongoCollection = db.getDB(this.db).getCollection(collectionName);
+            MongoCollection<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;
@@ -313,8 +236,9 @@ public class MongoDBPersistenceService implements QueryablePersistenceService {
      * Disconnects from the database
      */
     private synchronized void disconnectFromDatabase() {
-        if (this.cl != null) {
-            this.cl.close();
+        MongoClient localCl = cl;
+        if (localCl != null) {
+            localCl.close();
         }
 
         cl = null;
@@ -322,132 +246,242 @@ public class MongoDBPersistenceService implements QueryablePersistenceService {
 
     @Override
     public Iterable<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;
     }
 }
diff --git a/bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBTypeConversions.java b/bundles/org.openhab.persistence.mongodb/src/main/java/org/openhab/persistence/mongodb/internal/MongoDBTypeConversions.java
new file mode 100644 (file)
index 0000000..eb18986
--- /dev/null
@@ -0,0 +1,255 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.persistence.mongodb.internal;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Date;
+import java.util.Map;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+import javax.measure.Unit;
+
+import org.bson.Document;
+import org.bson.types.Binary;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.items.GenericItem;
+import org.openhab.core.items.Item;
+import org.openhab.core.library.items.CallItem;
+import org.openhab.core.library.items.ColorItem;
+import org.openhab.core.library.items.ContactItem;
+import org.openhab.core.library.items.DateTimeItem;
+import org.openhab.core.library.items.DimmerItem;
+import org.openhab.core.library.items.ImageItem;
+import org.openhab.core.library.items.LocationItem;
+import org.openhab.core.library.items.NumberItem;
+import org.openhab.core.library.items.PlayerItem;
+import org.openhab.core.library.items.RollershutterItem;
+import org.openhab.core.library.items.StringItem;
+import org.openhab.core.library.items.SwitchItem;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.HSBType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.OpenClosedType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.PlayPauseType;
+import org.openhab.core.library.types.PointType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.RawType;
+import org.openhab.core.library.types.StringListType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.persistence.FilterCriteria.Operator;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+import org.openhab.core.types.util.UnitUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This class handles the conversion of types between openHAB and MongoDB.
+ * It provides methods to convert openHAB states to MongoDB compatible types and vice versa.
+ * It also provides a method to convert openHAB filter operators to MongoDB query operators.
+ *
+ * @author René Ulbricht - Initial contribution
+ */
+@NonNullByDefault
+public class MongoDBTypeConversions {
+
+    /**
+     * Converts a MongoDB document to an openHAB state.
+     *
+     * @param item The openHAB item that the state belongs to.
+     * @param doc The MongoDB document to convert.
+     * @return The openHAB state.
+     * @throws IllegalArgumentException If the item type is not supported.
+     */
+    public static State getStateFromDocument(Item item, Document doc) {
+        BiFunction<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");
+        }
+    }
+}
diff --git a/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/DataCreationHelper.java b/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/DataCreationHelper.java
new file mode 100644 (file)
index 0000000..83ecd75
--- /dev/null
@@ -0,0 +1,444 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.persistence.mongodb.internal;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import java.time.LocalDate;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Hashtable;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.UUID;
+import java.util.stream.Stream;
+
+import org.bson.Document;
+import org.bson.types.ObjectId;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.junit.jupiter.params.provider.Arguments;
+import org.mockito.Mockito;
+import org.openhab.core.i18n.UnitProvider;
+import org.openhab.core.internal.i18n.I18nProviderImpl;
+import org.openhab.core.items.GenericItem;
+import org.openhab.core.items.Item;
+import org.openhab.core.items.ItemRegistry;
+import org.openhab.core.library.items.CallItem;
+import org.openhab.core.library.items.ColorItem;
+import org.openhab.core.library.items.ContactItem;
+import org.openhab.core.library.items.DateTimeItem;
+import org.openhab.core.library.items.DimmerItem;
+import org.openhab.core.library.items.ImageItem;
+import org.openhab.core.library.items.LocationItem;
+import org.openhab.core.library.items.NumberItem;
+import org.openhab.core.library.items.PlayerItem;
+import org.openhab.core.library.items.RollershutterItem;
+import org.openhab.core.library.items.StringItem;
+import org.openhab.core.library.items.SwitchItem;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.HSBType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.OpenClosedType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.PlayPauseType;
+import org.openhab.core.library.types.PointType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.RawType;
+import org.openhab.core.library.types.StringListType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.persistence.FilterCriteria;
+import org.openhab.core.types.State;
+import org.osgi.framework.BundleContext;
+import org.osgi.service.component.ComponentContext;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.DockerClientFactory;
+
+import com.mongodb.client.MongoClient;
+import com.mongodb.client.MongoClients;
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.MongoDatabase;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import de.bwaldvogel.mongo.backend.memory.MemoryBackend;
+
+/**
+ * This class provides helper methods to create test items.
+ * 
+ * @author René Ulbricht - Initial contribution
+ */
+@NonNullByDefault
+public class DataCreationHelper {
+
+    protected static final UnitProvider UNIT_PROVIDER;
+    static {
+        ComponentContext context = Mockito.mock(ComponentContext.class);
+        BundleContext bundleContext = Mockito.mock(BundleContext.class);
+        Hashtable<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;
+    }
+}
diff --git a/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/DatabaseTestContainer.java b/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/DatabaseTestContainer.java
new file mode 100644 (file)
index 0000000..66c2233
--- /dev/null
@@ -0,0 +1,105 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.persistence.mongodb.internal;
+
+import java.net.InetSocketAddress;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.testcontainers.containers.MongoDBContainer;
+
+import de.bwaldvogel.mongo.MongoServer;
+import de.bwaldvogel.mongo.backend.memory.MemoryBackend;
+
+/**
+ * This class provides a container for MongoDB for testing purposes.
+ * It uses the Testcontainers library to manage the MongoDB container.
+ * It also provides an in-memory MongoDB server for testing.
+ * 
+ * @author René Ulbricht - Initial contribution
+ */
+@NonNullByDefault
+public class DatabaseTestContainer {
+    // A map to store MongoDBContainer instances for different MongoDB versions.
+    private static final Map<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 "";
+        }
+    }
+}
diff --git a/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/MongoDBPersistenceServiceTest.java b/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/MongoDBPersistenceServiceTest.java
new file mode 100644 (file)
index 0000000..afd122c
--- /dev/null
@@ -0,0 +1,992 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.persistence.mongodb.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.text.DateFormat;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.bson.Document;
+import org.bson.types.ObjectId;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.Mockito;
+import org.openhab.core.items.GenericItem;
+import org.openhab.core.items.ItemNotFoundException;
+import org.openhab.core.library.items.ColorItem;
+import org.openhab.core.library.items.DateTimeItem;
+import org.openhab.core.library.items.ImageItem;
+import org.openhab.core.library.items.NumberItem;
+import org.openhab.core.library.items.StringItem;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.HSBType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.RawType;
+import org.openhab.core.persistence.FilterCriteria;
+import org.openhab.core.persistence.HistoricItem;
+import org.osgi.framework.BundleContext;
+
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.MongoDatabase;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import de.bwaldvogel.mongo.backend.memory.MemoryBackend;
+
+/**
+ * This is the implementation of the test for MongoDB {@link PersistenceService}.
+ *
+ * @author René Ulbricht - Initial contribution
+ */
+public class MongoDBPersistenceServiceTest {
+
+    /**
+     * Tests the activate method of MongoDBPersistenceService.
+     *
+     * This test checks if the activate method correctly logs the MongoDB URL, database, and collection.
+     * It uses different database backends provided by the provideDatabaseBackends method.
+     *
+     * @param dbContainer The container running the MongoDB instance.
+     */
+    @ParameterizedTest
+    @MethodSource("org.openhab.persistence.mongodb.internal.DataCreationHelper#provideDatabaseBackends")
+    public void testActivate(DatabaseTestContainer dbContainer) {
+        try {
+            // Preparation
+            SetupResult setupResult = DataCreationHelper.setupMongoDB("testCollection", dbContainer);
+
+            // Set up logger
+            ListAppender<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();
+        }
+    }
+}
diff --git a/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/PersistenceTestItem.java b/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/PersistenceTestItem.java
new file mode 100644 (file)
index 0000000..e616591
--- /dev/null
@@ -0,0 +1,56 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.persistence.mongodb.internal;
+
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.Objects;
+
+/**
+ * This class provides helper methods to store generated data for persistence tests.
+ * 
+ * @author René Ulbricht - Initial contribution
+ */
+public class PersistenceTestItem {
+    public final String itemName;
+    public final ZonedDateTime date;
+    public final double value;
+
+    public PersistenceTestItem(String itemName, ZonedDateTime date, double value) {
+        this.itemName = itemName;
+        this.date = date.truncatedTo(ChronoUnit.MILLIS);
+        this.value = value;
+    }
+
+    @Override
+    public String toString() {
+        return "PersistenceTestItem{" + "item='" + itemName + '\'' + ", date=" + date + ", value=" + value + '}';
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(itemName, date, value);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (obj == null || getClass() != obj.getClass()) {
+            return false;
+        }
+        PersistenceTestItem other = (PersistenceTestItem) obj;
+        return other.itemName.equals(itemName) && other.date.equals(date) && other.value == value;
+    }
+}
diff --git a/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/SetupResult.java b/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/SetupResult.java
new file mode 100644 (file)
index 0000000..39df3b0
--- /dev/null
@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.persistence.mongodb.internal;
+
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.items.ItemRegistry;
+import org.osgi.framework.BundleContext;
+
+import com.mongodb.client.MongoDatabase;
+
+/**
+ * This class provides helper methods to create test items.
+ * 
+ * @author René Ulbricht - Initial contribution
+ */
+@NonNullByDefault
+public class SetupResult {
+    public MongoDBPersistenceService service;
+    public MongoDatabase database;
+    public BundleContext bundleContext;
+    public Map<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;
+    }
+}
diff --git a/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/VerificationHelper.java b/bundles/org.openhab.persistence.mongodb/src/test/java/org/openhab/persistence/mongodb/internal/VerificationHelper.java
new file mode 100644 (file)
index 0000000..09e4ea6
--- /dev/null
@@ -0,0 +1,206 @@
+/**
+ * Copyright (c) 2010-2024 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.persistence.mongodb.internal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.function.BiFunction;
+
+import org.apache.commons.lang3.tuple.Pair;
+import org.bson.Document;
+import org.bson.json.JsonWriterSettings;
+import org.bson.types.Binary;
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.HSBType;
+import org.openhab.core.library.types.IncreaseDecreaseType;
+import org.openhab.core.library.types.NextPreviousType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.OpenClosedType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.PlayPauseType;
+import org.openhab.core.library.types.PointType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.RawType;
+import org.openhab.core.library.types.RewindFastforwardType;
+import org.openhab.core.library.types.StopMoveType;
+import org.openhab.core.library.types.StringListType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.types.UpDownType;
+import org.openhab.core.persistence.HistoricItem;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+
+/**
+ * This is a helper class for verifying various aspects of the MongoDB persistence service.
+ * It provides methods for verifying log messages, MongoDB documents, and query results.
+ * Each verification method checks if the actual value matches the expected value and throws an
+ * AssertionError if they do not match.
+ *
+ * @author René Ulbricht - Initial contribution
+ */
+@NonNullByDefault
+public class VerificationHelper {
+
+    /**
+     * Verifies a log message.
+     *
+     * @param logEvent The log event to verify.
+     * @param expectedMessage The expected message of the log event.
+     * @param expectedLevel The expected level of the log event.
+     */
+    public static void verifyLogMessage(ILoggingEvent logEvent, String expectedMessage, Level expectedLevel) {
+        assertEquals(expectedMessage, logEvent.getFormattedMessage());
+        assertEquals(expectedLevel, logEvent.getLevel());
+    }
+
+    /**
+     * Verifies a document.
+     *
+     * @param document The document to verify.
+     * @param expectedItem The expected item of the document.
+     * @param expectedValue The expected value of the document.
+     */
+    public static void verifyDocument(Document document, String expectedItem, Object expectedValue) {
+        verifyDocumentWithAlias(document, expectedItem, expectedItem, expectedValue);
+    }
+
+    /**
+     * Verifies a document with an alias.
+     *
+     * @param document The document to verify.
+     * @param expectedAlias The expected alias of the document.
+     * @param expectedRealName The expected real name of the document.
+     * @param expectedValue The expected value of the document. Can be a String or a Double.
+     */
+    public static void verifyDocumentWithAlias(Document document, String expectedAlias, String expectedRealName,
+            Object expectedValue) {
+        assertEquals(expectedAlias, document.get(MongoDBFields.FIELD_ITEM));
+        assertEquals(expectedRealName, document.get(MongoDBFields.FIELD_REALNAME));
+
+        // Use the map to handle the expected value
+        BiFunction<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());
+    }
+}