From f258822dd4a9cbfc2063a13f975e4d0def66195e Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Fri, 23 Oct 2026 16:54:10 +0200
Subject: [PATCH 1/2] Add failing tests for asset query isolation in rules
facade
---
manager/build.gradle | 2 +-
.../rules/facade/AssetsFacadeQueryTest.groovy | 223 ++++++++++++++++++
2 files changed, 224 insertions(+), 1 deletion(-)
create mode 100644 manager/src/test/groovy/org/openremote/manager/rules/facade/AssetsFacadeQueryTest.groovy
diff --git a/manager/build.gradle b/manager/build.gradle
index 4c3d6d3d23..7ae6cc236c 100644
--- a/manager/build.gradle
+++ b/manager/build.gradle
@@ -79,7 +79,7 @@ dependencies {
sourceSets {
test {
- groovy.srcDirs = ['test/src/groovy']
+ groovy.srcDirs = ['src/test/groovy', 'test/src/groovy']
}
}
diff --git a/manager/src/test/groovy/org/openremote/manager/rules/facade/AssetsFacadeQueryTest.groovy b/manager/src/test/groovy/org/openremote/manager/rules/facade/AssetsFacadeQueryTest.groovy
new file mode 100644
index 0000000000..e202210727
--- /dev/null
+++ b/manager/src/test/groovy/org/openremote/manager/rules/facade/AssetsFacadeQueryTest.groovy
@@ -0,0 +1,223 @@
+/*
+ * Copyright 2026, OpenRemote Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+package org.openremote.manager.rules.facade
+
+import org.openremote.manager.asset.AssetStorageService
+import org.openremote.manager.rules.RulesEngineId
+import org.openremote.model.asset.impl.ThingAsset
+import org.openremote.model.query.AssetQuery
+import org.openremote.model.query.LogicGroup
+import org.openremote.model.query.filter.AttributePredicate
+import org.openremote.model.query.filter.ParentPredicate
+import org.openremote.model.query.filter.PathPredicate
+import org.openremote.model.query.filter.RealmPredicate
+import org.openremote.model.query.filter.StringPredicate
+import spock.lang.Specification
+
+class AssetsFacadeQueryTest extends Specification {
+
+ def "#scope scope leaves the caller query unchanged with populated filters: #populated"() {
+ given:
+ def query = populated ? populatedQuery() : new AssetQuery()
+ def original = queryState(query)
+ def engine = engineFor(scope)
+ List submittedStates = []
+ def storage = Stub(AssetStorageService) {
+ findAll(_ as AssetQuery) >> { AssetQuery submitted ->
+ submittedStates.add(queryState(submitted))
+ // The real storage query fills in these defaults in place.
+ if (submitted.access == null) {
+ submitted.access = AssetQuery.Access.PRIVATE
+ }
+ if (submitted.orderBy == null && submitted.ids == null) {
+ submitted.orderBy = new AssetQuery.OrderBy(AssetQuery.OrderBy.Property.CREATED_ON)
+ }
+ []
+ }
+ }
+
+ when:
+ facadeFor(engine, storage).getResults(query).toList()
+
+ then: "execution restrictions and storage defaults do not modify caller-owned options"
+ verifyAll {
+ queryState(query) == original
+ submittedStates == [scopedState(original, engine)]
+ }
+
+ where:
+ [scope, populated] << ["global", "realm", "asset"].collectMany { scope ->
+ [false, true].collect { populated -> [scope, populated] }
+ }
+ }
+
+ def "Reusing a query across engines does not retain previous scope restrictions with populated filters: #populated"() {
+ given:
+ def query = populated ? populatedQuery() : new AssetQuery()
+ def original = queryState(query)
+ def engines = [
+ new RulesEngineId("realm-a", "asset-a"),
+ new RulesEngineId("realm-b", "asset-b"),
+ new RulesEngineId("realm-c"),
+ new RulesEngineId()
+ ]
+ List submittedStates = []
+ def storage = Stub(AssetStorageService) {
+ findAll(_ as AssetQuery) >> { AssetQuery submitted ->
+ submittedStates.add(queryState(submitted))
+ []
+ }
+ }
+
+ when:
+ engines.each { facadeFor(it, storage).getResults(query).toList() }
+
+ then:
+ verifyAll {
+ submittedStates == engines.collect { scopedState(original, it) }
+ queryState(query) == original
+ }
+
+ where:
+ populated << [false, true]
+ }
+
+ def "Later caller edits are isolated from earlier executions in #scope scope"() {
+ given:
+ def query = populatedQuery()
+ def original = queryState(query)
+ def engine = engineFor(scope)
+ List submittedQueries = []
+ List submittedStates = []
+ def storage = Stub(AssetStorageService) {
+ findAll(_ as AssetQuery) >> { AssetQuery submitted ->
+ submittedQueries.add(submitted)
+ submittedStates.add(queryState(submitted))
+ []
+ }
+ }
+ def facade = facadeFor(engine, storage)
+ facade.getResults(query).toList()
+ def firstExecution = queryState(submittedQueries[0])
+
+ when: "the caller changes arrays, nested predicates and query options before reusing the query"
+ query.realm.name = "next-realm"
+ query.ids[0] = "next-asset"
+ query.names[0].value = "Next name"
+ query.parents[0].id = "next-parent"
+ query.paths[0].path[0] = "next-path"
+ query.userIds[0] = "next-user"
+ query.types[0] = "NextType"
+ query.attributes.items[0].value.value = "next-value"
+ query.attributes.groups[0].items[0].name.value = "next-attribute"
+ query.select.attributes[0] = "next-selected-attribute"
+ query.orderBy.descending = false
+ query.access = AssetQuery.Access.PUBLIC
+ query.recursive = false
+ query.includeDeletePending = false
+ query.limit = 10
+ query.offset = 3
+ def edited = queryState(query)
+ facade.getResults(query).toList()
+
+ then: "each execution retains its own filters and the caller retains its edits"
+ verifyAll {
+ submittedQueries.size() == 2
+ queryState(submittedQueries[0]) == firstExecution
+ submittedStates == [scopedState(original, engine), scopedState(edited, engine)]
+ queryState(query) == edited
+ }
+
+ where:
+ scope << ["global", "realm", "asset"]
+ }
+
+ def "#scope scope preserves empty selection arrays when isolating a query"() {
+ given: "empty arrays must remain distinct from absent filters"
+ def query = new AssetQuery(
+ ids: [] as String[], names: [] as StringPredicate[], parents: [] as ParentPredicate[],
+ userIds: [] as String[], types: [] as String[])
+ def original = queryState(query)
+ def engine = engineFor(scope)
+ List submittedStates = []
+ def storage = Stub(AssetStorageService) {
+ findAll(_ as AssetQuery) >> { AssetQuery submitted ->
+ submittedStates.add(queryState(submitted))
+ []
+ }
+ }
+
+ when:
+ facadeFor(engine, storage).getResults(query).toList()
+
+ then:
+ submittedStates == [scopedState(original, engine)]
+
+ where:
+ scope << ["global", "realm", "asset"]
+ }
+
+ private AssetsFacade facadeFor(RulesEngineId engine, AssetStorageService storage) {
+ if (engine.assetId.present) {
+ def scopedAsset = Stub(ThingAsset) {
+ getPath() >> ([engine.assetId.get()] as String[])
+ }
+ storage.find(engine.assetId.get(), true) >> scopedAsset
+ }
+ new AssetsFacade(engine, storage, { throw new AssertionError("Unexpected dispatch") })
+ }
+
+ private static RulesEngineId engineFor(String scope) {
+ scope == "global" ? new RulesEngineId() :
+ scope == "realm" ? new RulesEngineId("building") : new RulesEngineId("building", "scope-asset")
+ }
+
+ private static AssetQuery populatedQuery() {
+ new AssetQuery().realm(new RealmPredicate("caller-realm"))
+ .ids("caller-asset").names(new StringPredicate(AssetQuery.Match.BEGIN, true, "Caller").negate(true))
+ .parents(new ParentPredicate("caller-parent")).paths(new PathPredicate("caller-path"))
+ .userIds("caller-user").types(ThingAsset)
+ .attributes(new LogicGroup(LogicGroup.Operator.OR,
+ [new LogicGroup(new AttributePredicate("nested", new StringPredicate("value")))],
+ [new AttributePredicate("status", new StringPredicate("active"))]))
+ .select(new AssetQuery.Select().attributes("temperature"))
+ .orderBy(new AssetQuery.OrderBy(AssetQuery.OrderBy.Property.NAME, true))
+ .access(AssetQuery.Access.PROTECTED).recursive(true).limit(7).offset(2)
+ .includeDeletePending(true)
+ }
+
+ private static Map queryState(AssetQuery query) {
+ [realm: query.realm?.name, ids: query.ids?.toList(), names: query.names?.collect {
+ it.toString()
+ },
+ parents: query.parents?.collect { it.id }, paths: query.paths?.collect { it.path?.toList() },
+ userIds: query.userIds?.toList(), types: query.types?.toList(), attributes: query.attributes?.toString(),
+ select: query.select == null ? null : [attributes: query.select.attributes?.toList()],
+ orderBy: query.orderBy == null ? null : [property: query.orderBy.property, descending: query.orderBy.descending],
+ access: query.access, recursive: query.recursive, includeDeletePending: query.@includeDeletePending,
+ limit: query.limit, offset: query.offset]
+ }
+
+ private static Map scopedState(Map original, RulesEngineId engine) {
+ // Capture current execution semantics. Combining caller paths with engine scope is #3479.
+ original + [realm: engine.realm.orElse(original.realm),
+ paths: engine.assetId.present ? [[engine.assetId.get()]] : original.paths,
+ select: [attributes: []]]
+ }
+}
From 228922f865b73678657766744db14a1d2535168f Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Fri, 23 Oct 2026 17:19:27 +0200
Subject: [PATCH 2/2] Copy asset queries before applying rules facade
restrictions
---
.../manager/rules/facade/AssetsFacade.java | 10 +-
.../rules/facade/AssetsFacadeQueryTest.groovy | 37 +++++
.../openremote/model/query/AssetQuery.java | 69 +++++++++
.../query/filter/AttributePredicate.java | 13 ++
.../filter/LocationAttributePredicate.java | 4 +
.../query/filter/NameValuePredicate.java | 12 ++
.../model/query/filter/ValuePredicate.java | 105 +++++++++++++
.../model/query/AssetQueryCopyTest.groovy | 146 ++++++++++++++++++
8 files changed, 389 insertions(+), 7 deletions(-)
create mode 100644 model/src/test/groovy/org/openremote/model/query/AssetQueryCopyTest.groovy
diff --git a/manager/src/main/java/org/openremote/manager/rules/facade/AssetsFacade.java b/manager/src/main/java/org/openremote/manager/rules/facade/AssetsFacade.java
index fbf894d627..27dcc5126e 100644
--- a/manager/src/main/java/org/openremote/manager/rules/facade/AssetsFacade.java
+++ b/manager/src/main/java/org/openremote/manager/rules/facade/AssetsFacade.java
@@ -57,6 +57,8 @@ public AssetsFacade(
@Override
public Stream> getResults(AssetQuery assetQuery) {
+ // Rules can reuse queries. Isolate nested predicates as well as the scope and storage defaults.
+ assetQuery = new AssetQuery(assetQuery);
if (RealmRuleset.class.isAssignableFrom(rulesEngineId.getScope())) {
// Realm is restricted to rules
@@ -89,14 +91,8 @@ public Stream> getResults(AssetQuery assetQuery) {
assetQuery.paths(new PathPredicate(restrictedAsset.getPath()));
}
- AssetQuery.Select oldValue = assetQuery.select;
assetQuery.select = new AssetQuery.Select().excludeAttributes();
-
- try {
- return assetStorageService.findAll(assetQuery).stream();
- } finally {
- assetQuery.select = oldValue;
- }
+ return assetStorageService.findAll(assetQuery).stream();
}
public AssetsFacade dispatch(AttributeEvent... events) {
diff --git a/manager/src/test/groovy/org/openremote/manager/rules/facade/AssetsFacadeQueryTest.groovy b/manager/src/test/groovy/org/openremote/manager/rules/facade/AssetsFacadeQueryTest.groovy
index e202210727..a981d7911f 100644
--- a/manager/src/test/groovy/org/openremote/manager/rules/facade/AssetsFacadeQueryTest.groovy
+++ b/manager/src/test/groovy/org/openremote/manager/rules/facade/AssetsFacadeQueryTest.groovy
@@ -23,7 +23,10 @@ import org.openremote.manager.rules.RulesEngineId
import org.openremote.model.asset.impl.ThingAsset
import org.openremote.model.query.AssetQuery
import org.openremote.model.query.LogicGroup
+import org.openremote.model.query.filter.ArrayPredicate
import org.openremote.model.query.filter.AttributePredicate
+import org.openremote.model.query.filter.NameValuePredicate
+import org.openremote.model.query.filter.NumberPredicate
import org.openremote.model.query.filter.ParentPredicate
import org.openremote.model.query.filter.PathPredicate
import org.openremote.model.query.filter.RealmPredicate
@@ -173,6 +176,40 @@ class AssetsFacadeQueryTest extends Specification {
scope << ["global", "realm", "asset"]
}
+ def "Query copies preserve nested attribute paths, empty values and null map entries"() {
+ given:
+ def payload = [empty: [] as Object[], absent: null]
+ def predicate = new AttributePredicate("readings", new ArrayPredicate(payload, 0, null, null, null, false))
+ .path(new NameValuePredicate.Path("measurements"))
+ .meta(new NameValuePredicate("unit", new StringPredicate("celsius")))
+ .previousValue(new NumberPredicate(new BigDecimal("1234567890.123456789")))
+ def query = new AssetQuery().attributes(predicate)
+ List submittedQueries = []
+ def storage = Stub(AssetStorageService) {
+ findAll(_ as AssetQuery) >> { AssetQuery submitted ->
+ submittedQueries.add(submitted)
+ []
+ }
+ }
+
+ when:
+ facadeFor(new RulesEngineId(), storage).getResults(query).toList()
+ def copied = submittedQueries[0].attributes.items[0]
+ predicate.path.append("next")
+ predicate.meta[0].value.value = "fahrenheit"
+ payload.empty = ["next"] as Object[]
+ payload.absent = "next"
+ predicate.previousValue.value = 0
+
+ then:
+ copied.path.paths.toList() == ["measurements"]
+ copied.meta[0].value.value == "celsius"
+ copied.value.value.empty.length == 0
+ copied.value.value.containsKey("absent")
+ copied.value.value.absent == null
+ copied.previousValue.value == new BigDecimal("1234567890.123456789")
+ }
+
private AssetsFacade facadeFor(RulesEngineId engine, AssetStorageService storage) {
if (engine.assetId.present) {
def scopedAsset = Stub(ThingAsset) {
diff --git a/model/src/main/java/org/openremote/model/query/AssetQuery.java b/model/src/main/java/org/openremote/model/query/AssetQuery.java
index e7047f4676..67688d93f7 100644
--- a/model/src/main/java/org/openremote/model/query/AssetQuery.java
+++ b/model/src/main/java/org/openremote/model/query/AssetQuery.java
@@ -221,6 +221,75 @@ public boolean compare(Comparator comparator, T x, T y, T z) {
public AssetQuery() {}
+ /** Copies query options and nested predicates without changing null or empty selections. */
+ public AssetQuery(AssetQuery source) {
+ recursive = source.recursive;
+ select =
+ source.select == null
+ ? null
+ : new Select()
+ .attributes(
+ source.select.attributes == null ? null : source.select.attributes.clone());
+ access = source.access;
+ ids = source.ids == null ? null : source.ids.clone();
+ names =
+ source.names == null
+ ? null
+ : Arrays.stream(source.names)
+ .map(p -> (StringPredicate) ValuePredicate.copy(p))
+ .toArray(StringPredicate[]::new);
+ parents =
+ source.parents == null
+ ? null
+ : Arrays.stream(source.parents)
+ .map(p -> p == null ? null : new ParentPredicate(p.id))
+ .toArray(ParentPredicate[]::new);
+ paths =
+ source.paths == null
+ ? null
+ : Arrays.stream(source.paths)
+ .map(
+ p ->
+ p == null
+ ? null
+ : new PathPredicate(p.path == null ? null : p.path.clone()))
+ .toArray(PathPredicate[]::new);
+ realm = source.realm == null ? null : new RealmPredicate(source.realm.name);
+ userIds = source.userIds == null ? null : source.userIds.clone();
+ types = source.types == null ? null : source.types.clone();
+ attributes = copyAttributes(source.attributes);
+ includeDeletePending = source.includeDeletePending;
+ orderBy =
+ source.orderBy == null
+ ? null
+ : new OrderBy(source.orderBy.property, source.orderBy.descending);
+ limit = source.limit;
+ offset = source.offset;
+ }
+
+ private static LogicGroup copyAttributes(
+ LogicGroup source) {
+ if (source == null) return null;
+ return new LogicGroup<>(
+ source.operator,
+ source.groups == null
+ ? null
+ : source.groups.stream()
+ .map(AssetQuery::copyAttributes)
+ .collect(Collectors.toCollection(ArrayList::new)),
+ source.items == null
+ ? null
+ : source.items.stream()
+ .map(
+ p ->
+ p == null
+ ? null
+ : p instanceof LocationAttributePredicate location
+ ? new LocationAttributePredicate(location)
+ : new AttributePredicate(p))
+ .collect(Collectors.toCollection(ArrayList::new)));
+ }
+
/** Resolves the concrete {@link Asset} types that are covered by the supplied asset types */
@SuppressWarnings("unchecked")
public static String[] getResolvedAssetTypes(String[] assetTypes) {
diff --git a/model/src/main/java/org/openremote/model/query/filter/AttributePredicate.java b/model/src/main/java/org/openremote/model/query/filter/AttributePredicate.java
index f8d1adf9cb..025f1e57d1 100644
--- a/model/src/main/java/org/openremote/model/query/filter/AttributePredicate.java
+++ b/model/src/main/java/org/openremote/model/query/filter/AttributePredicate.java
@@ -41,6 +41,19 @@ public class AttributePredicate extends NameValuePredicate {
public AttributePredicate() {}
+ /** Copies this predicate, including metadata and previous-value restrictions. */
+ public AttributePredicate(AttributePredicate source) {
+ super(source);
+ meta =
+ source.meta == null
+ ? null
+ : Arrays.stream(source.meta)
+ .map(p -> p == null ? null : new NameValuePredicate(p))
+ .toArray(NameValuePredicate[]::new);
+ previousValue = ValuePredicate.copy(source.previousValue);
+ timestampOlderThan = source.timestampOlderThan;
+ }
+
public AttributePredicate(NameHolder nameHolder, ValuePredicate value) {
super(nameHolder, value);
}
diff --git a/model/src/main/java/org/openremote/model/query/filter/LocationAttributePredicate.java b/model/src/main/java/org/openremote/model/query/filter/LocationAttributePredicate.java
index 7910d079b7..3046d5bc91 100644
--- a/model/src/main/java/org/openremote/model/query/filter/LocationAttributePredicate.java
+++ b/model/src/main/java/org/openremote/model/query/filter/LocationAttributePredicate.java
@@ -26,6 +26,10 @@
public class LocationAttributePredicate extends AttributePredicate {
+ public LocationAttributePredicate(LocationAttributePredicate source) {
+ super(source);
+ }
+
public LocationAttributePredicate(GeofencePredicate geofencePredicate) {
super(new StringPredicate(Asset.LOCATION), geofencePredicate);
}
diff --git a/model/src/main/java/org/openremote/model/query/filter/NameValuePredicate.java b/model/src/main/java/org/openremote/model/query/filter/NameValuePredicate.java
index a01fc96bc3..411358f5f0 100644
--- a/model/src/main/java/org/openremote/model/query/filter/NameValuePredicate.java
+++ b/model/src/main/java/org/openremote/model/query/filter/NameValuePredicate.java
@@ -56,6 +56,10 @@ public class NameValuePredicate {
public static class Path {
@JsonValue Object[] paths; // ints for index, string for keys
+ public Path(Path source) {
+ paths = source.paths == null ? null : source.paths.clone();
+ }
+
@JsonCreator
protected Path(@JsonProperty("paths") Object[] paths) {
this.paths = paths;
@@ -96,6 +100,14 @@ public Object[] getPaths() {
public NameValuePredicate() {}
+ /** Copies the name, value and path so later edits do not affect the source predicate. */
+ public NameValuePredicate(NameValuePredicate source) {
+ name = (StringPredicate) ValuePredicate.copy(source.name);
+ negated = source.negated;
+ path = source.path == null ? null : new Path(source.path);
+ value = ValuePredicate.copy(source.value);
+ }
+
public NameValuePredicate(NameHolder nameHolder, ValuePredicate value) {
this(nameHolder.getName(), value);
}
diff --git a/model/src/main/java/org/openremote/model/query/filter/ValuePredicate.java b/model/src/main/java/org/openremote/model/query/filter/ValuePredicate.java
index 16f1994d88..085194af63 100644
--- a/model/src/main/java/org/openremote/model/query/filter/ValuePredicate.java
+++ b/model/src/main/java/org/openremote/model/query/filter/ValuePredicate.java
@@ -20,10 +20,22 @@
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.databind.JsonNode;
import java.io.Serializable;
+import java.lang.reflect.Array;
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import java.util.function.Predicate;
import java.util.function.Supplier;
+import org.hibernate.internal.util.SerializationHelper;
@JsonSubTypes({
@JsonSubTypes.Type(value = StringPredicate.class, name = StringPredicate.name),
@@ -43,6 +55,99 @@
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "predicateType")
public abstract class ValuePredicate implements Serializable {
+ /** Copies a predicate and its mutable values, retaining their types and null/empty values. */
+ public static ValuePredicate copy(ValuePredicate source) {
+ return switch (source) {
+ case null -> null;
+ case StringPredicate p when p.getClass() == StringPredicate.class ->
+ new StringPredicate(p.match, p.caseSensitive, p.value).negate(p.negate);
+ case BooleanPredicate p when p.getClass() == BooleanPredicate.class ->
+ new BooleanPredicate(p.value);
+ case NumberPredicate p when p.getClass() == NumberPredicate.class -> {
+ NumberPredicate result = new NumberPredicate((Number) copyValue(p.value), p.operator);
+ result.rangeValue = (Number) copyValue(p.rangeValue);
+ result.negate = p.negate;
+ yield result;
+ }
+ case DateTimePredicate p when p.getClass() == DateTimePredicate.class -> {
+ DateTimePredicate result = new DateTimePredicate(p.operator, p.value);
+ result.rangeValue = p.rangeValue;
+ result.negate = p.negate;
+ yield result;
+ }
+ case ArrayPredicate p when p.getClass() == ArrayPredicate.class ->
+ new ArrayPredicate(
+ copyValue(p.value),
+ p.index,
+ p.lengthEquals,
+ p.lengthGreaterThan,
+ p.lengthLessThan,
+ p.negated);
+ case CalendarEventPredicate p when p.getClass() == CalendarEventPredicate.class ->
+ new CalendarEventPredicate(p.timestamp == null ? null : (Date) p.timestamp.clone());
+ case RadialGeofencePredicate p when p.getClass() == RadialGeofencePredicate.class ->
+ new RadialGeofencePredicate(p.radius, p.lat, p.lng, p.negated);
+ case RectangularGeofencePredicate p when p.getClass() == RectangularGeofencePredicate.class ->
+ new RectangularGeofencePredicate(p.latMin, p.lngMin, p.latMax, p.lngMax, p.negated);
+ case GeoJSONGeofencePredicate p when p.getClass() == GeoJSONGeofencePredicate.class ->
+ new GeoJSONGeofencePredicate(p.geoJSON, p.negated);
+ case ValueEmptyPredicate p when p.getClass() == ValueEmptyPredicate.class ->
+ new ValueEmptyPredicate().negate(p.negate);
+ case ValueAnyPredicate p when p.getClass() == ValueAnyPredicate.class ->
+ new ValueAnyPredicate();
+ // ValuePredicate's Serializable contract also allows custom predicate implementations.
+ default -> (ValuePredicate) SerializationHelper.clone(source);
+ };
+ }
+
+ private static Object copyValue(Object value) {
+ if (value == null
+ || value instanceof String
+ || value instanceof Boolean
+ || value instanceof Character
+ || value instanceof Byte
+ || value instanceof Short
+ || value instanceof Integer
+ || value instanceof Long
+ || value instanceof Float
+ || value instanceof Double
+ || value instanceof BigInteger
+ || value instanceof BigDecimal
+ || value instanceof Enum>) {
+ return value;
+ }
+ if (value instanceof JsonNode node) return node.deepCopy();
+ if (value instanceof Date date) return date.clone();
+ if (value.getClass().isArray()) {
+ int length = Array.getLength(value);
+ Object result = Array.newInstance(value.getClass().getComponentType(), length);
+ if (value.getClass().getComponentType().isPrimitive()) {
+ System.arraycopy(value, 0, result, 0, length);
+ } else {
+ for (int i = 0; i < length; i++) Array.set(result, i, copyValue(Array.get(value, i)));
+ }
+ return result;
+ }
+ if (value instanceof Map, ?> map) {
+ Map result = new LinkedHashMap<>();
+ map.forEach((key, item) -> result.put(copyValue(key), copyValue(item)));
+ return result;
+ }
+ if (value instanceof List> list) {
+ List result = new ArrayList<>(list.size());
+ list.forEach(item -> result.add(copyValue(item)));
+ return result;
+ }
+ if (value instanceof Set> set) {
+ Set result = new LinkedHashSet<>();
+ set.forEach(item -> result.add(copyValue(item)));
+ return result;
+ }
+ if (value instanceof Serializable serializable) return SerializationHelper.clone(serializable);
+ throw new IllegalArgumentException(
+ "Cannot copy predicate value of type: " + value.getClass().getName());
+ }
+
public static Predicate asPredicateOrTrue(
Supplier currentMillisSupplier, ValuePredicate valuePredicate) {
return obj ->
diff --git a/model/src/test/groovy/org/openremote/model/query/AssetQueryCopyTest.groovy b/model/src/test/groovy/org/openremote/model/query/AssetQueryCopyTest.groovy
new file mode 100644
index 0000000000..e9e6f73c9c
--- /dev/null
+++ b/model/src/test/groovy/org/openremote/model/query/AssetQueryCopyTest.groovy
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2026, OpenRemote Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+package org.openremote.model.query
+
+import org.openremote.model.query.filter.*
+import org.openremote.model.util.ValueUtil
+import spock.lang.Specification
+
+class AssetQueryCopyTest extends Specification {
+
+ def "Copying an asset query preserves and isolates #predicate.class.simpleName"() {
+ given:
+ def original = new AssetQuery().attributes(new AttributePredicate("value", predicate))
+ // Serialization is used only to compare all predicate options, not to make the copy.
+ def expected = ValueUtil.JSON.writeValueAsString(predicate)
+
+ when:
+ def copied = new AssetQuery(original).attributes.items[0].value
+
+ then:
+ copied.class == predicate.class
+ !copied.is(predicate)
+ ValueUtil.JSON.writeValueAsString(copied) == expected
+
+ when:
+ mutate(predicate)
+
+ then:
+ ValueUtil.JSON.writeValueAsString(copied) == expected
+
+ where:
+ predicate | mutate
+ new StringPredicate(AssetQuery.Match.CONTAINS, false, "match").negate(true) | {
+ it.value = "next"
+ }
+ new BooleanPredicate(true) | { it.value = false }
+ new NumberPredicate(new BigDecimal("1234567890.123456789"), AssetQuery.Operator.BETWEEN).rangeValue(99).negate(true) | {
+ it.rangeValue = 100
+ }
+ new DateTimePredicate("2026-01-01", "2026-12-31").negate(true) | {
+ it.rangeValue = "2027-01-01"
+ }
+ new ArrayPredicate([values: [1, 2]], 0, 3, 1, 5, true) | { it.value.values.add(3) }
+ new RadialGeofencePredicate(100, 51, 4, true) | { it.radius = 200 }
+ new RectangularGeofencePredicate(50, 3, 52, 5, true) | { it.latMax = 53 }
+ new GeoJSONGeofencePredicate('{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}', true) | {
+ it.geoJSON = null
+ }
+ new CalendarEventPredicate(new Date(1800000000000L)) | { it.timestamp.time = 0 }
+ new ValueEmptyPredicate().negate(true) | { it.negate = false }
+ new ValueAnyPredicate() | { }
+ new ExtendedStringPredicate(value: "match", extra: "custom") | { it.extra = "next" }
+ }
+
+ def "Copying an asset query preserves null and empty options"() {
+ given:
+ def original = new AssetQuery(
+ ids: [] as String[], names: [] as StringPredicate[], parents: [] as ParentPredicate[],
+ paths: [] as PathPredicate[], userIds: [] as String[], types: [] as String[],
+ select: new AssetQuery.Select().excludeAttributes(), attributes: new LogicGroup())
+ original.attributes.items = []
+ original.attributes.groups = []
+
+ when:
+ def copied = new AssetQuery(original)
+ def blank = new AssetQuery(new AssetQuery())
+
+ then:
+ ["ids", "names", "parents", "paths", "userIds", "types"].every {
+ copied[it].length == 0 && !copied[it].is(original[it]) && blank[it] == null
+ }
+ copied.select.attributes.length == 0
+ !copied.select.attributes.is(original.select.attributes)
+ copied.attributes.items.empty
+ copied.attributes.groups.empty
+ !copied.attributes.items.is(original.attributes.items)
+ !copied.attributes.groups.is(original.attributes.groups)
+ copied.@includeDeletePending == null
+ blank.select == null
+ blank.attributes == null
+ }
+
+ def "Copying location predicates retains their type and isolates their metadata"() {
+ given:
+ def predicate = new LocationAttributePredicate(new RadialGeofencePredicate(100, 51, 4))
+ predicate.meta(new NameValuePredicate("label", new StringPredicate("original")))
+ predicate.timestampOlderThan = "PT5M"
+
+ when:
+ def copied = new AssetQuery(new AssetQuery().attributes(predicate)).attributes.items[0]
+ predicate.value.radius = 200
+ predicate.meta[0].name.value = "next"
+ predicate.timestampOlderThan = "PT10M"
+
+ then:
+ copied instanceof LocationAttributePredicate
+ copied.value.radius == 100
+ copied.meta[0].name.value == "label"
+ copied.timestampOlderThan == "PT5M"
+ }
+
+ def "Copying array predicate payloads isolates nested mutable values"() {
+ given:
+ def payload = [numbers: [1, 2] as int[], records: [[value: "original"]],
+ time: new Date(1800000000000L), tree: ValueUtil.JSON.readTree('{"value":1}'),
+ counter: new java.util.concurrent.atomic.AtomicInteger(7)]
+ def predicate = new ArrayPredicate(payload, null, null, null, null, false)
+
+ when:
+ def copied = new AssetQuery(new AssetQuery().attributes(new AttributePredicate("value", predicate)))
+ .attributes.items[0].value.value
+ payload.numbers[0] = 99
+ payload.records[0].value = "next"
+ payload.time.time = 0
+ payload.tree.put("value", 2)
+ payload.counter.set(8)
+
+ then:
+ copied.numbers instanceof int[]
+ copied.numbers.toList() == [1, 2]
+ copied.records == [[value: "original"]]
+ copied.time.time == 1800000000000L
+ copied.tree.get("value").intValue() == 1
+ copied.counter.get() == 7
+ }
+
+ static class ExtendedStringPredicate extends StringPredicate {
+ String extra
+ }
+}