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 + } +}