From 8246097fb45553550a974468c7c3191b02379796 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 23 Mar 2026 16:32:42 +0200 Subject: [PATCH 01/28] Create unified ComplexOperation enum and update all references - Add top-level ComplexOperation enum in query package - Delete duplicate alarm.rule.condition.expression.ComplexOperation - Remove nested ComplexFilterPredicate.ComplexOperation enum - Update imports in 8 files across common/data, common/edqs, edqs, and application --- .../cf/ctx/state/alarm/AlarmCalculatedFieldState.java | 2 +- .../service/install/DefaultSystemDataLoaderService.java | 2 +- .../test/java/org/thingsboard/server/cf/AlarmRulesTest.java | 2 +- .../rule/condition/expression/AlarmConditionFilter.java | 1 + .../condition/expression/SimpleAlarmConditionExpression.java | 1 + .../expression/predicate/ComplexFilterPredicate.java | 2 +- .../server/common/data/query/ComplexFilterPredicate.java | 5 ----- .../condition/expression => query}/ComplexOperation.java | 2 +- .../org/thingsboard/server/edqs/util/RepositoryUtils.java | 4 ++-- .../thingsboard/server/edqs/repo/RepositoryUtilsTest.java | 2 +- 10 files changed, 10 insertions(+), 13 deletions(-) rename common/data/src/main/java/org/thingsboard/server/common/data/{alarm/rule/condition/expression => query}/ComplexOperation.java (89%) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java index 1719c95f7a..930d030d71 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java @@ -40,7 +40,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionTyp import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; -import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate; diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java index 965d7105a9..d528f10899 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java @@ -53,7 +53,7 @@ import org.thingsboard.server.common.data.alarm.rule.AlarmRule; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; -import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate; diff --git a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java index cf94940e07..5824b832df 100644 --- a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java @@ -38,7 +38,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.RepeatingAlarmCon import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; -import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NoDataFilterPredicate; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java index 75849967a0..d80eccb9fc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java @@ -24,6 +24,7 @@ import lombok.Data; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.ComplexFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.KeyFilterPredicate; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityKeyValueType; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java index 390bbb3230..a581fa3a41 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java @@ -22,6 +22,7 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType; +import org.thingsboard.server.common.data.query.ComplexOperation; import java.util.List; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java index 17c5279b3e..34f128d3e2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java @@ -18,7 +18,7 @@ package org.thingsboard.server.common.data.alarm.rule.condition.expression.predi import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; -import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; +import org.thingsboard.server.common.data.query.ComplexOperation; import java.util.List; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java index 557f08eb2b..248211ad92 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java @@ -34,9 +34,4 @@ public class ComplexFilterPredicate implements KeyFilterPredicate { return FilterPredicateType.COMPLEX; } - @Schema - public enum ComplexOperation { - AND, - OR - } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java similarity index 89% rename from common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java rename to common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java index 492fc683dd..4f0db45faa 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.data.alarm.rule.condition.expression; +package org.thingsboard.server.common.data.query; public enum ComplexOperation { AND, diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java index a0a030ba08..31fc82225e 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java @@ -60,8 +60,8 @@ import java.util.stream.Stream; import static org.apache.commons.lang3.StringUtils.containsIgnoreCase; import static org.thingsboard.server.common.data.StringUtils.equalsAny; import static org.thingsboard.server.common.data.StringUtils.splitByCommaWithoutQuotes; -import static org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation.AND; -import static org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation.OR; +import static org.thingsboard.server.common.data.query.ComplexOperation.AND; +import static org.thingsboard.server.common.data.query.ComplexOperation.OR; @Slf4j public class RepositoryUtils { diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java index a3691a0889..557cfb7a4a 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java @@ -26,7 +26,7 @@ import org.thingsboard.server.common.data.edqs.fields.DeviceProfileFields; import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.BooleanFilterPredicate.BooleanOperation; import org.thingsboard.server.common.data.query.ComplexFilterPredicate; -import org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.query.EntityKeyValueType; import org.thingsboard.server.common.data.query.FilterPredicateValue; From c9e0839b5294a4c9f0e67eec6e6a775bb96ca109 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 23 Mar 2026 16:34:28 +0200 Subject: [PATCH 02/28] Add keyFiltersOperation field to EntityCountQuery and propagate through constructors - Add keyFiltersOperation field with null-safe getKeyFiltersOperationOrDefault() - Add 3-arg constructor to EntityCountQuery for explicit operation - Add 6-arg constructors to AbstractDataQuery and EntityDataQuery - Add 7-arg constructor to AlarmDataQuery - Update next() methods to propagate keyFiltersOperation - Update buildEntityDataQuery methods to pass operation from alarm queries --- .../service/query/DefaultEntityQueryService.java | 4 ++-- .../server/common/data/query/AbstractDataQuery.java | 12 ++++++++++++ .../server/common/data/query/AlarmDataQuery.java | 7 ++++++- .../server/common/data/query/EntityCountQuery.java | 13 +++++++++++++ .../server/common/data/query/EntityDataQuery.java | 6 +++++- 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java index 50f0f0e75a..92d76693f9 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java @@ -221,7 +221,7 @@ public class DefaultEntityQueryService implements EntityQueryService { private EntityDataQuery buildEntityDataQuery(AlarmCountQuery query) { EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, EntityKeyMapping.CREATED_TIME))); - return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters()); + return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters(), query.getKeyFiltersOperationOrDefault()); } private EntityDataQuery buildEntityDataQuery(AlarmDataQuery query) { @@ -233,7 +233,7 @@ public class DefaultEntityQueryService implements EntityQueryService { entitiesSortOrder = sortOrder; } EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder); - return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters()); + return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperationOrDefault()); } @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java index a678338864..3aabbae0a8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java @@ -49,4 +49,16 @@ public abstract class AbstractDataQuery extends En this.latestValues = latestValues; } + public AbstractDataQuery(EntityFilter entityFilter, + T pageLink, + List entityFields, + List latestValues, + List keyFilters, + ComplexOperation keyFiltersOperation) { + super(entityFilter, keyFilters, keyFiltersOperation); + this.pageLink = pageLink; + this.entityFields = entityFields; + this.latestValues = latestValues; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java index 8d2ae6fae3..072f56abd1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java @@ -39,8 +39,13 @@ public class AlarmDataQuery extends AbstractDataQuery { this.alarmFields = alarmFields; } + public AlarmDataQuery(EntityFilter entityFilter, AlarmDataPageLink pageLink, List entityFields, List latestValues, List keyFilters, List alarmFields, ComplexOperation keyFiltersOperation) { + super(entityFilter, pageLink, entityFields, latestValues, keyFilters, keyFiltersOperation); + this.alarmFields = alarmFields; + } + @JsonIgnore public AlarmDataQuery next() { - return new AlarmDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters, alarmFields); + return new AlarmDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters, alarmFields, getKeyFiltersOperation()); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java index b4680bfd5b..ee39fe7d77 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java @@ -34,6 +34,9 @@ public class EntityCountQuery { @Getter protected List keyFilters; + @Getter + private ComplexOperation keyFiltersOperation; + public EntityCountQuery() { } @@ -45,4 +48,14 @@ public class EntityCountQuery { this.entityFilter = entityFilter; this.keyFilters = keyFilters; } + + public EntityCountQuery(EntityFilter entityFilter, List keyFilters, ComplexOperation keyFiltersOperation) { + this.entityFilter = entityFilter; + this.keyFilters = keyFilters; + this.keyFiltersOperation = keyFiltersOperation; + } + + public ComplexOperation getKeyFiltersOperationOrDefault() { + return keyFiltersOperation != null ? keyFiltersOperation : ComplexOperation.AND; + } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java index 959e3e3af6..f24ffd5821 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java @@ -34,9 +34,13 @@ public class EntityDataQuery extends AbstractDataQuery { super(entityFilter, pageLink, entityFields, latestValues, keyFilters); } + public EntityDataQuery(EntityFilter entityFilter, EntityDataPageLink pageLink, List entityFields, List latestValues, List keyFilters, ComplexOperation keyFiltersOperation) { + super(entityFilter, pageLink, entityFields, latestValues, keyFilters, keyFiltersOperation); + } + @JsonIgnore public EntityDataQuery next() { - return new EntityDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters); + return new EntityDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters, getKeyFiltersOperation()); } } From 4ae486a5f1173784aeb7f66f339b1aba9a217763 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 23 Mar 2026 16:38:29 +0200 Subject: [PATCH 03/28] Add feature toggle, SystemParams field, and service-layer validation - Add sql.query.key-filters-or-conditions.enabled property to thingsboard.yml - Add allowKeyFiltersOrConditions field to SystemParams - Wire config value into SystemInfoController for /api/system/params - Add validateKeyFiltersOperation to DefaultEntityQueryService - Add throws ThingsboardException to EntityQueryService interface methods - Reject OR queries with 400 when feature is disabled --- .../controller/SystemInfoController.java | 4 ++++ .../query/DefaultEntityQueryService.java | 22 +++++++++++++++---- .../service/query/EntityQueryService.java | 9 ++++---- .../src/main/resources/thingsboard.yml | 4 ++++ .../server/common/data/SystemParams.java | 1 + 5 files changed, 32 insertions(+), 8 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java index ee2e871d6e..9bcd756de0 100644 --- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java +++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java @@ -76,6 +76,9 @@ public class SystemInfoController extends BaseController { @Value("${debug.settings.default_duration:15}") private int defaultDebugDurationMinutes; + @Value("${sql.query.key-filters-or-conditions.enabled:true}") + private boolean keyFiltersOrConditionsEnabled; + @Autowired(required = false) private BuildProperties buildProperties; @@ -173,6 +176,7 @@ public class SystemInfoController extends BaseController { systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID)) .map(QrCodeSettings::getQrCodeConfig).map(QRCodeConfig::isShowOnHomePage) .orElse(false)); + systemParams.setAllowKeyFiltersOrConditions(keyFiltersOrConditionsEnabled); return systemParams; } diff --git a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java index 92d76693f9..00426dae18 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java @@ -44,6 +44,7 @@ import org.thingsboard.server.common.data.query.AvailableEntityKeysV2; import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeyInfo; import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeySample; import org.thingsboard.server.common.data.query.ComplexFilterPredicate; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; @@ -93,6 +94,9 @@ public class DefaultEntityQueryService implements EntityQueryService { @Value("${server.ws.max_entities_per_alarm_subscription:1000}") private int maxEntitiesPerAlarmSubscription; + @Value("${sql.query.key-filters-or-conditions.enabled:true}") + private boolean keyFiltersOrConditionsEnabled; + @Autowired private DbCallbackExecutorService dbCallbackExecutor; @@ -103,12 +107,14 @@ public class DefaultEntityQueryService implements EntityQueryService { private AttributesService attributesService; @Override - public long countEntitiesByQuery(SecurityUser securityUser, EntityCountQuery query) { + public long countEntitiesByQuery(SecurityUser securityUser, EntityCountQuery query) throws ThingsboardException { + validateKeyFiltersOperation(query); return entityService.countEntitiesByQuery(securityUser.getTenantId(), securityUser.getCustomerId(), query); } @Override - public PageData findEntityDataByQuery(SecurityUser securityUser, EntityDataQuery query) { + public PageData findEntityDataByQuery(SecurityUser securityUser, EntityDataQuery query) throws ThingsboardException { + validateKeyFiltersOperation(query); if (query.getKeyFilters() != null) { resolveDynamicValuesInPredicates( query.getKeyFilters().stream() @@ -177,7 +183,8 @@ public class DefaultEntityQueryService implements EntityQueryService { } @Override - public PageData findAlarmDataByQuery(SecurityUser securityUser, AlarmDataQuery query) { + public PageData findAlarmDataByQuery(SecurityUser securityUser, AlarmDataQuery query) throws ThingsboardException { + validateKeyFiltersOperation(query); EntityDataQuery entityDataQuery = this.buildEntityDataQuery(query); PageData entities = entityService.findEntityDataByQuery(securityUser.getTenantId(), securityUser.getCustomerId(), entityDataQuery); @@ -203,7 +210,8 @@ public class DefaultEntityQueryService implements EntityQueryService { } @Override - public long countAlarmsByQuery(SecurityUser securityUser, AlarmCountQuery query) { + public long countAlarmsByQuery(SecurityUser securityUser, AlarmCountQuery query) throws ThingsboardException { + validateKeyFiltersOperation(query); if (query.getEntityFilter() != null) { EntityDataQuery entityDataQuery = this.buildEntityDataQuery(query); PageData entities = entityService.findEntityDataByQuery(securityUser.getTenantId(), @@ -236,6 +244,12 @@ public class DefaultEntityQueryService implements EntityQueryService { return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperationOrDefault()); } + private void validateKeyFiltersOperation(EntityCountQuery query) throws ThingsboardException { + if (!keyFiltersOrConditionsEnabled && query.getKeyFiltersOperation() == ComplexOperation.OR) { + throw new ThingsboardException("OR conditions between key filters are disabled", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + } + @Override public ListenableFuture getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query, boolean isTimeseries, boolean isAttributes, AttributeScope scope) { diff --git a/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java index 354f8e9278..b3d2f208ca 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.query; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.AlarmCountQuery; @@ -33,13 +34,13 @@ import java.util.Set; public interface EntityQueryService { - long countEntitiesByQuery(SecurityUser securityUser, EntityCountQuery query); + long countEntitiesByQuery(SecurityUser securityUser, EntityCountQuery query) throws ThingsboardException; - PageData findEntityDataByQuery(SecurityUser securityUser, EntityDataQuery query); + PageData findEntityDataByQuery(SecurityUser securityUser, EntityDataQuery query) throws ThingsboardException; - PageData findAlarmDataByQuery(SecurityUser securityUser, AlarmDataQuery query); + PageData findAlarmDataByQuery(SecurityUser securityUser, AlarmDataQuery query) throws ThingsboardException; - long countAlarmsByQuery(SecurityUser securityUser, AlarmCountQuery query); + long countAlarmsByQuery(SecurityUser securityUser, AlarmCountQuery query) throws ThingsboardException; ListenableFuture getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query, boolean isTimeseries, boolean isAttributes, AttributeScope scope); diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 15d6afb37d..f21cd5c7dd 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -469,6 +469,10 @@ sql: log_tenant_stats: "${SQL_LOG_TENANT_STATS:true}" # Interval in milliseconds for printing the latest statistic information about the tenant log_tenant_stats_interval_ms: "${SQL_LOG_TENANT_STATS_INTERVAL_MS:60000}" + query: + key-filters-or-conditions: + # Enable/disable OR conditions between key filters in entity data queries + enabled: "${SQL_QUERY_KEY_FILTERS_OR_CONDITIONS_ENABLED:true}" postgres: # Specify partitioning size for timestamp key-value storage. Example: DAYS, MONTHS, YEARS, INDEFINITE. ts_key_value_partitioning: "${SQL_POSTGRES_TS_KV_PARTITIONING:MONTHS}" diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java index 3cd2ada38f..d3b2e8b73a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java @@ -45,4 +45,5 @@ public class SystemParams { long minAllowedAggregationIntervalInSecForCF; long intermediateAggregationIntervalInSecForCF; TrendzSettings trendzSettings; + boolean allowKeyFiltersOrConditions; } From ce68c8fca512647be3a03f078a481a9f4678f48e Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 23 Mar 2026 16:43:30 +0200 Subject: [PATCH 04/28] Add ComplexOperation field to EDQS query model and propagate through toNewQuery - Add keyFiltersOperation field to EdqsQuery base class - Update EdqsCountQuery and EdqsDataQuery constructors to accept and propagate ComplexOperation - Propagate keyFiltersOperationOrDefault in both toNewQuery overloads in RepositoryUtils --- .../org/thingsboard/server/edqs/query/EdqsCountQuery.java | 5 +++-- .../org/thingsboard/server/edqs/query/EdqsDataQuery.java | 5 +++-- .../java/org/thingsboard/server/edqs/query/EdqsQuery.java | 2 ++ .../org/thingsboard/server/edqs/util/RepositoryUtils.java | 2 ++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java index 0d2ed67987..d338362aeb 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java @@ -16,6 +16,7 @@ package org.thingsboard.server.edqs.query; import lombok.Builder; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityFilter; import java.util.List; @@ -23,8 +24,8 @@ import java.util.List; public class EdqsCountQuery extends EdqsQuery { @Builder - EdqsCountQuery(EntityFilter entityFilter, boolean hasKeyFilters, List keyFilters) { - super(entityFilter, hasKeyFilters, keyFilters); + EdqsCountQuery(EntityFilter entityFilter, boolean hasKeyFilters, List keyFilters, ComplexOperation keyFiltersOperation) { + super(entityFilter, hasKeyFilters, keyFilters, keyFiltersOperation); } } diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java index 72ccb68515..bd93eca2f1 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java @@ -19,6 +19,7 @@ import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityDataSortOrder; import org.thingsboard.server.common.data.query.EntityFilter; import org.thingsboard.server.common.data.query.EntityKeyType; @@ -41,10 +42,10 @@ public class EdqsDataQuery extends EdqsQuery { private final List latestValues; @Builder - public EdqsDataQuery(EntityFilter entityFilter, List keyFilters, + public EdqsDataQuery(EntityFilter entityFilter, List keyFilters, ComplexOperation keyFiltersOperation, int pageSize, int page, String textSearch, DataKey sortKey, EntityDataSortOrder.Direction sortDirection, List entityFields, List latestValues) { - super(entityFilter, CollectionsUtil.isNotEmpty(keyFilters), keyFilters); + super(entityFilter, CollectionsUtil.isNotEmpty(keyFilters), keyFilters, keyFiltersOperation); this.pageSize = pageSize; this.page = page; this.hasTextSearch = StringUtils.isNotBlank(textSearch); diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java index 9cfc5d442c..8d29ec89b8 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java @@ -16,6 +16,7 @@ package org.thingsboard.server.edqs.query; import lombok.Data; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityFilter; import java.util.List; @@ -26,5 +27,6 @@ public abstract class EdqsQuery { private final EntityFilter entityFilter; private final boolean hasKeyFilters; private final List keyFilters; + private final ComplexOperation keyFiltersOperation; } diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java index 31fc82225e..9aaa6a5927 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java @@ -139,6 +139,7 @@ public class RepositoryUtils { } query.entityFilter(oldQuery.getEntityFilter()); query.keyFilters(toKeyFilters(oldQuery.getKeyFilters())); + query.keyFiltersOperation(oldQuery.getKeyFiltersOperationOrDefault()); query.entityFields(toNewKeys(oldQuery.getEntityFields())); query.latestValues(toNewKeys(oldQuery.getLatestValues())); return query.build(); @@ -149,6 +150,7 @@ public class RepositoryUtils { .entityFilter(oldQuery.getEntityFilter()) .hasKeyFilters(CollectionsUtil.isNotEmpty(oldQuery.getKeyFilters())) .keyFilters(toKeyFilters(oldQuery.getKeyFilters())) + .keyFiltersOperation(oldQuery.getKeyFiltersOperationOrDefault()) .build(); } From caac757f181d59235c91a7f808dcd2b88faf4831 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 23 Mar 2026 16:44:52 +0200 Subject: [PATCH 05/28] Implement OR logic in EDQS checkKeyFilters with DRY helper extraction - Extract evaluateSingleFilter helper to avoid duplicating filter evaluation logic - Add 3-arg checkKeyFilters overload accepting ComplexOperation parameter - OR mode: any matching filter includes entity (short-circuit on first true) - AND mode: all filters must match (short-circuit on first false, unchanged behavior) - Null operation defaults to AND for backward compatibility - Update checkFilters to pass query.getKeyFiltersOperation() through to checkKeyFilters --- .../server/edqs/util/RepositoryUtils.java | 76 ++++++++++++------- 1 file changed, 47 insertions(+), 29 deletions(-) diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java index 9aaa6a5927..f6da57e24c 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.edqs.DataPoint; import org.thingsboard.server.common.data.permission.QueryContext; import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.ComplexFilterPredicate; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityDataSortOrder; @@ -198,38 +199,55 @@ public class RepositoryUtils { } public static boolean checkKeyFilters(EntityData entity, List keyFilters) { - for (EdqsFilter keyFilter : keyFilters) { - EntityKeyValueType valueType = keyFilter.valueType(); - if (valueType == null) { - valueType = switch (keyFilter.predicate().getType()) { - case STRING -> EntityKeyValueType.STRING; - case NUMERIC -> EntityKeyValueType.NUMERIC; - case BOOLEAN -> EntityKeyValueType.BOOLEAN; - default -> throw new IllegalStateException(); - }; - } - DataKey dataKey = keyFilter.key(); - DataPoint dp = entity.getDataPoint(dataKey, null); - boolean checkResult = switch (valueType) { - case STRING -> { - String str = dp != null ? dp.valueToString() : null; - yield (dataKey.type() == EntityKeyType.ENTITY_FIELD) ? (str == null || checkKeyFilter(str, keyFilter.predicate())) : - (str != null && checkKeyFilter(str, keyFilter.predicate())); - } - case BOOLEAN -> { - Boolean booleanValue = dp != null ? dp.getBool() : null; - yield booleanValue != null && checkKeyFilter(booleanValue, keyFilter.predicate()); + return checkKeyFilters(entity, keyFilters, ComplexOperation.AND); + } + + public static boolean checkKeyFilters(EntityData entity, List keyFilters, ComplexOperation operation) { + ComplexOperation op = operation != null ? operation : ComplexOperation.AND; + if (op == ComplexOperation.OR) { + for (EdqsFilter keyFilter : keyFilters) { + if (evaluateSingleFilter(entity, keyFilter)) { + return true; } - case DATE_TIME, NUMERIC -> { - Double doubleValue = dp != null ? dp.getDouble() : null; - yield doubleValue != null && checkKeyFilter(doubleValue, keyFilter.predicate()); + } + return keyFilters.isEmpty(); + } else { + for (EdqsFilter keyFilter : keyFilters) { + if (!evaluateSingleFilter(entity, keyFilter)) { + return false; } - }; - if (!checkResult) { - return false; } + return true; } - return true; + } + + private static boolean evaluateSingleFilter(EntityData entity, EdqsFilter keyFilter) { + EntityKeyValueType valueType = keyFilter.valueType(); + if (valueType == null) { + valueType = switch (keyFilter.predicate().getType()) { + case STRING -> EntityKeyValueType.STRING; + case NUMERIC -> EntityKeyValueType.NUMERIC; + case BOOLEAN -> EntityKeyValueType.BOOLEAN; + default -> throw new IllegalStateException(); + }; + } + DataKey dataKey = keyFilter.key(); + DataPoint dp = entity.getDataPoint(dataKey, null); + return switch (valueType) { + case STRING -> { + String str = dp != null ? dp.valueToString() : null; + yield (dataKey.type() == EntityKeyType.ENTITY_FIELD) ? (str == null || checkKeyFilter(str, keyFilter.predicate())) : + (str != null && checkKeyFilter(str, keyFilter.predicate())); + } + case BOOLEAN -> { + Boolean booleanValue = dp != null ? dp.getBool() : null; + yield booleanValue != null && checkKeyFilter(booleanValue, keyFilter.predicate()); + } + case DATE_TIME, NUMERIC -> { + Double doubleValue = dp != null ? dp.getDouble() : null; + yield doubleValue != null && checkKeyFilter(doubleValue, keyFilter.predicate()); + } + }; } public static boolean checkKeyFilter(String value, KeyFilterPredicate keyFilterPredicate) { @@ -378,7 +396,7 @@ public class RepositoryUtils { if (entity == null || entity.getFields() == null) { return false; // Entity was already removed or not arrived yet; } - if (query.isHasKeyFilters() && !checkKeyFilters(entity, query.getKeyFilters())) { + if (query.isHasKeyFilters() && !checkKeyFilters(entity, query.getKeyFilters(), query.getKeyFiltersOperation())) { return false; } if (query instanceof EdqsDataQuery dataQuery) { From daa092ebe977125c5a7c5fec0f818e0de185cf74 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 23 Mar 2026 16:45:44 +0200 Subject: [PATCH 06/28] OR-aware SQL generation in EntityKeyMapping - Add 4-arg buildQuery overload with ComplexOperation parameter that joins predicates with OR - Add 3-arg toQueries overload with outerContext flag for bare column names in outer WHERE - Add forceLeftJoin parameter to toLatestJoin and buildLatestJoins for LEFT JOIN under OR - Modify prepareKeyMapping to create individual mappings per KeyFilter under OR (no same-key grouping) - Modify prepareEntityCountKeyMapping with same OR ungrouping logic - All existing 2-arg/3-arg overloads preserved as backward-compatible delegates --- .../dao/sql/query/EntityKeyMapping.java | 130 +++++++++++++++--- 1 file changed, 110 insertions(+), 20 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java index b9a134e713..72096dbf6e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java @@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.ComplexFilterPredicate; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityDataSortOrder; @@ -283,8 +284,33 @@ public class EntityKeyMapping { } public Stream toQueries(SqlQueryContext ctx, EntityFilterType filterType) { + return toQueries(ctx, filterType, false); + } + + public Stream toQueries(SqlQueryContext ctx, EntityFilterType filterType, boolean outerContext) { if (hasFilter()) { - String keyAlias = (entityKey.getType().equals(EntityKeyType.ENTITY_FIELD) && getEntityKeyColumn() != null) ? "e" : alias; + String keyAlias; + if (entityKey.getType().equals(EntityKeyType.ENTITY_FIELD) && getEntityKeyColumn() != null) { + if (outerContext) { + // In the outer query (OR relocation), entity field columns are available + // as bare column names from the inner subquery SELECT. + // buildSimplePredicateQuery does: alias + "." + entityKeyColumn when entityKeyColumn != null. + // To produce just the bare column name, temporarily null out entityKeyColumn + // so buildSimplePredicateQuery uses alias directly as the field. + String bareColumn = getEntityKeyColumn(); + String savedColumn = this.entityKeyColumn; + this.entityKeyColumn = null; + List predicates = keyFilters.stream() + .map(keyFilter -> this.buildKeyQuery(ctx, bareColumn, keyFilter, filterType)) + .collect(Collectors.toList()); + this.entityKeyColumn = savedColumn; + return predicates.stream(); + } else { + keyAlias = "e"; + } + } else { + keyAlias = alias; + } return keyFilters.stream().map(keyFilter -> this.buildKeyQuery(ctx, keyAlias, keyFilter, filterType)); } else { @@ -293,6 +319,10 @@ public class EntityKeyMapping { } public String toLatestJoin(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType) { + return toLatestJoin(ctx, entityFilter, entityType, false); + } + + public String toLatestJoin(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType, boolean forceLeftJoin) { String entityTypeStr; if (entityFilter.getType().equals(EntityFilterType.RELATIONS_QUERY)) { entityTypeStr = "entities.entity_type"; @@ -307,13 +337,13 @@ public class EntityKeyMapping { filterQuery = " AND (" + filterQuery + ")"; } if (entityKey.getType().equals(EntityKeyType.TIME_SERIES)) { - String join = (hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join"; + String join = (!forceLeftJoin && hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join"; return String.format("%s ts_kv_latest %s ON %s.entity_id=entities.id AND %s.key = (select key_id from key_dictionary where key = :%s_key_id) %s", join, alias, alias, alias, alias, filterQuery); } else { String query; if (!entityKey.getType().equals(EntityKeyType.ATTRIBUTE)) { - String join = (hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join"; + String join = (!forceLeftJoin && hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join"; query = String.format("%s attribute_kv %s ON %s.entity_id=entities.id AND %s.attribute_key=(select key_id from key_dictionary where key = :%s_key_id) ", join, alias, alias, alias, alias); int scope; @@ -326,7 +356,7 @@ public class EntityKeyMapping { } query = String.format("%s AND %s.attribute_type=%s %s", query, alias, scope, filterQuery); } else { - String join = (hasFilter() && hasFilterValues(ctx)) ? "join LATERAL" : "left join LATERAL"; + String join = (!forceLeftJoin && hasFilter() && hasFilterValues(ctx)) ? "join LATERAL" : "left join LATERAL"; query = String.format("%s (select * from attribute_kv %s WHERE %s.entity_id=entities.id AND %s.attribute_key=(select key_id from key_dictionary where key = :%s_key_id) %s " + "ORDER BY %s.last_update_ts DESC limit 1) as %s ON true", join, alias, alias, alias, alias, filterQuery, alias, alias); @@ -351,27 +381,44 @@ public class EntityKeyMapping { } public static String buildLatestJoins(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType, List latestMappings, boolean countQuery) { + return buildLatestJoins(ctx, entityFilter, entityType, latestMappings, countQuery, false); + } + + public static String buildLatestJoins(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType, + List latestMappings, boolean countQuery, boolean forceLeftJoin) { return latestMappings.stream() .filter(mapping -> !countQuery || mapping.hasFilter()) - .map(mapping -> mapping.toLatestJoin(ctx, entityFilter, entityType)) + .map(mapping -> mapping.toLatestJoin(ctx, entityFilter, entityType, forceLeftJoin)) .collect(Collectors.joining(" ")); } public static String buildQuery(SqlQueryContext ctx, List mappings, EntityFilterType filterType) { + return buildQuery(ctx, mappings, filterType, ComplexOperation.AND); + } + + public static String buildQuery(SqlQueryContext ctx, List mappings, + EntityFilterType filterType, ComplexOperation operation) { + String joiner = (operation == ComplexOperation.OR) ? " OR " : " AND "; return mappings.stream() - .flatMap(mapping -> mapping.toQueries(ctx, filterType)) + .flatMap(mapping -> mapping.toQueries(ctx, filterType, operation == ComplexOperation.OR)) .filter(StringUtils::isNotEmpty) - .collect(Collectors.joining(" AND ")); + .collect(Collectors.joining(joiner)); } public static List prepareKeyMapping(EntityType entityType, EntityDataQuery query) { EntityFilterType entityFilterType = query.getEntityFilter().getType(); + ComplexOperation operation = query.getKeyFiltersOperationOrDefault(); List entityFields = query.getEntityFields() != null ? query.getEntityFields() : Collections.emptyList(); List latestValues = query.getLatestValues() != null ? query.getLatestValues() : Collections.emptyList(); - Map> filters = - query.getKeyFilters() != null ? - query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap(); + Map> filters; + if (operation == ComplexOperation.OR) { + // Under OR, don't group same-key filters; handle individually below + filters = Collections.emptyMap(); + } else { + filters = query.getKeyFilters() != null ? + query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap(); + } EntityDataSortOrder sortOrder = query.getPageLink().getSortOrder(); EntityKey sortOrderKey = sortOrder != null ? sortOrder.getKey() : null; int index = 2; @@ -428,14 +475,37 @@ public class EntityKeyMapping { for (EntityKeyMapping mapping : mappings) { mapping.setIndex(index); mapping.setAlias(String.format("alias%s", index)); - mapping.setKeyFilters(filters.remove(mapping.entityKey)); + if (operation != ComplexOperation.OR) { + // Under AND: assign grouped filters to matching selection mappings + mapping.setKeyFilters(filters.remove(mapping.entityKey)); + } if (mapping.getEntityKey().getType().equals(EntityKeyType.ENTITY_FIELD)) { index++; } else { index += 2; } } - if (!filters.isEmpty()) { + if (operation == ComplexOperation.OR && query.getKeyFilters() != null) { + // Under OR: each KeyFilter gets its own EntityKeyMapping (no same-key grouping) + for (KeyFilter keyFilter : query.getKeyFilters()) { + EntityKeyMapping mapping = new EntityKeyMapping(); + mapping.setIndex(index); + mapping.setAlias(String.format("alias%s", index)); + mapping.setKeyFilters(Collections.singletonList(keyFilter)); + EntityKey filterKey = keyFilter.getKey(); + mapping.setLatest(!filterKey.getType().equals(EntityKeyType.ENTITY_FIELD)); + mapping.setEntityKey(filterKey); + mapping.setEntityKeyColumn(entityType, entityFilterType); + mapping.setSelection(mapping.getEntityKeyColumn() == null); + mappings.add(mapping); + if (filterKey.getType().equals(EntityKeyType.ENTITY_FIELD)) { + index += 1; + } else { + index += 2; + } + } + } else if (!filters.isEmpty()) { + // Under AND: create mappings for filter-only keys not in selection for (EntityKey filterField : filters.keySet()) { EntityKeyMapping mapping = new EntityKeyMapping(); mapping.setIndex(index); @@ -464,25 +534,45 @@ public class EntityKeyMapping { public static List prepareEntityCountKeyMapping(EntityCountQuery query) { EntityType entityType = resolveEntityType(query.getEntityFilter()); EntityFilterType entityFilterType = query.getEntityFilter().getType(); + ComplexOperation operation = query.getKeyFiltersOperationOrDefault(); - Map> filters = - query.getKeyFilters() != null ? - query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap(); int index = 2; List mappings = new ArrayList<>(); - if (!filters.isEmpty()) { - for (EntityKey filterField : filters.keySet()) { + + if (operation == ComplexOperation.OR && query.getKeyFilters() != null && !query.getKeyFilters().isEmpty()) { + // Under OR: each KeyFilter gets its own mapping (no same-key grouping) + for (KeyFilter keyFilter : query.getKeyFilters()) { EntityKeyMapping mapping = new EntityKeyMapping(); mapping.setIndex(index); mapping.setAlias(String.format("alias%s", index)); - mapping.setKeyFilters(filters.get(filterField)); - mapping.setLatest(!filterField.getType().equals(EntityKeyType.ENTITY_FIELD)); - mapping.setEntityKey(filterField); + mapping.setKeyFilters(Collections.singletonList(keyFilter)); + EntityKey filterKey = keyFilter.getKey(); + mapping.setLatest(!filterKey.getType().equals(EntityKeyType.ENTITY_FIELD)); + mapping.setEntityKey(filterKey); mapping.setEntityKeyColumn(entityType, entityFilterType); mapping.setSelection(mapping.getEntityKeyColumn() == null); mappings.add(mapping); index += 1; } + } else { + // Under AND (or null/default): group same-key filters together + Map> filters = + query.getKeyFilters() != null ? + query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap(); + if (!filters.isEmpty()) { + for (EntityKey filterField : filters.keySet()) { + EntityKeyMapping mapping = new EntityKeyMapping(); + mapping.setIndex(index); + mapping.setAlias(String.format("alias%s", index)); + mapping.setKeyFilters(filters.get(filterField)); + mapping.setLatest(!filterField.getType().equals(EntityKeyType.ENTITY_FIELD)); + mapping.setEntityKey(filterField); + mapping.setEntityKeyColumn(entityType, entityFilterType); + mapping.setSelection(mapping.getEntityKeyColumn() == null); + mappings.add(mapping); + index += 1; + } + } } return mappings; From 51bb37aa962f33a7c02e91ec139e04bd84b52f64 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 23 Mar 2026 16:47:50 +0200 Subject: [PATCH 07/28] Thread OR operation through DefaultEntityQueryRepository query building - Read keyFiltersOperationOrDefault in countEntitiesByQuery and findEntityDataByQuery - Under OR: relocate entity field filter predicates from inner WHERE to outer WHERE - Under OR: pass forceLeftJoin=true to buildLatestJoins for all aliases - Under OR: combine all filter mappings with OR joiner via buildQuery(ComplexOperation.OR) - Under OR: ensure entity field filter columns are in inner SELECT for outer WHERE reference - AND path remains completely unchanged (same execution path as before) --- .../query/DefaultEntityQueryRepository.java | 87 +++++++++++++++---- 1 file changed, 71 insertions(+), 16 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java index a9b04618c8..94e615a1bb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java @@ -30,6 +30,7 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.permission.QueryContext; import org.thingsboard.server.common.data.query.ApiUsageStateFilter; import org.thingsboard.server.common.data.query.AssetSearchQueryFilter; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.AssetTypeFilter; import org.thingsboard.server.common.data.query.DeviceSearchQueryFilter; import org.thingsboard.server.common.data.query.DeviceTypeFilter; @@ -54,6 +55,7 @@ import org.thingsboard.server.common.data.query.SingleEntityFilter; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -351,25 +353,48 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { } }); } else { - List mappings = EntityKeyMapping.prepareEntityCountKeyMapping(query); + ComplexOperation operation = query.getKeyFiltersOperationOrDefault(); + boolean isOr = operation == ComplexOperation.OR; - List selectionMapping = mappings.stream().filter(EntityKeyMapping::isSelection) - .collect(Collectors.toList()); - List entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest()) - .collect(Collectors.toList()); + List mappings = EntityKeyMapping.prepareEntityCountKeyMapping(query); + List selectionMapping = new ArrayList<>(mappings.stream().filter(EntityKeyMapping::isSelection) + .collect(Collectors.toList())); List filterMapping = mappings.stream().filter(EntityKeyMapping::hasFilter) .collect(Collectors.toList()); List entityFieldsFiltersMapping = filterMapping.stream().filter(mapping -> !mapping.isLatest() && mapping.getEntityKeyColumn() != null) .collect(Collectors.toList()); + // Under OR: entity field filter columns must be in inner SELECT for outer WHERE reference + if (isOr) { + for (EntityKeyMapping m : entityFieldsFiltersMapping) { + if (!selectionMapping.contains(m)) { + selectionMapping.add(m); + } + } + } + + List entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest()) + .collect(Collectors.toList()); + List allLatestMappings = mappings.stream().filter(EntityKeyMapping::isLatest) .collect(Collectors.toList()); + // Under OR: entity field filters move to outer WHERE (not inner WHERE) + List innerEntityFieldsFilters = isOr ? Collections.emptyList() : entityFieldsFiltersMapping; + + String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), innerEntityFieldsFilters); + String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true, isOr); + + // Under OR: combine ALL filter mappings (entity fields + aliases) in outer WHERE with OR joiner + String aliasWhereQuery; + if (isOr) { + String combinedFilterQuery = EntityKeyMapping.buildQuery(ctx, filterMapping, query.getEntityFilter().getType(), ComplexOperation.OR); + aliasWhereQuery = combinedFilterQuery.isEmpty() ? "" : " where (" + combinedFilterQuery + ")"; + } else { + aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, ""); + } - String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), entityFieldsFiltersMapping); - String aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, ""); - String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true); String entityFieldsSelection = EntityKeyMapping.buildSelections(entityFieldsSelectionMapping, query.getEntityFilter().getType(), entityType); String entityTypeStr; if (query.getEntityFilter().getType().equals(EntityFilterType.RELATIONS_QUERY)) { @@ -419,13 +444,13 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { EntityType entityType = resolveEntityType(query.getEntityFilter()); SqlQueryContext ctx = new SqlQueryContext(new QueryContext(tenantId, customerId, entityType, ignorePermissionCheck)); EntityDataPageLink pageLink = query.getPageLink(); + ComplexOperation operation = query.getKeyFiltersOperationOrDefault(); + boolean isOr = operation == ComplexOperation.OR; List mappings = EntityKeyMapping.prepareKeyMapping(entityType, query); - List selectionMapping = mappings.stream().filter(EntityKeyMapping::isSelection) - .collect(Collectors.toList()); - List entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest()) - .collect(Collectors.toList()); + List selectionMapping = new ArrayList<>(mappings.stream().filter(EntityKeyMapping::isSelection) + .collect(Collectors.toList())); List latestSelectionMapping = selectionMapping.stream().filter(EntityKeyMapping::isLatest) .collect(Collectors.toList()); @@ -434,14 +459,44 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { List entityFieldsFiltersMapping = filterMapping.stream().filter(mapping -> !mapping.isLatest() && mapping.getEntityKeyColumn() != null) .collect(Collectors.toList()); + // Under OR: entity field filter columns must be in inner SELECT for outer WHERE reference + if (isOr) { + for (EntityKeyMapping m : entityFieldsFiltersMapping) { + if (!selectionMapping.contains(m)) { + selectionMapping.add(m); + } + } + } + + List entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest()) + .collect(Collectors.toList()); + List allLatestMappings = mappings.stream().filter(EntityKeyMapping::isLatest) .collect(Collectors.toList()); + // Under OR: entity field filters move to outer WHERE (not inner WHERE) + List innerEntityFieldsFilters = isOr ? Collections.emptyList() : entityFieldsFiltersMapping; + + String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), innerEntityFieldsFilters); + String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true, isOr); + String latestJoinsData = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, false, isOr); + + // Under OR: combine ALL filter mappings (entity fields + aliases) in outer WHERE with OR joiner + String aliasWhereQuery; + if (isOr) { + String combinedFilterQuery = EntityKeyMapping.buildQuery(ctx, filterMapping, query.getEntityFilter().getType(), ComplexOperation.OR); + String searchTextQuery = buildTextSearchQuery(ctx, selectionMapping, pageLink.getTextSearch()); + aliasWhereQuery = ""; + if (!combinedFilterQuery.isEmpty()) { + aliasWhereQuery = " where (" + combinedFilterQuery + ")"; + } + if (!searchTextQuery.isEmpty()) { + aliasWhereQuery += (aliasWhereQuery.isEmpty() ? " where " : " and ") + "(" + searchTextQuery + ") "; + } + } else { + aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, pageLink.getTextSearch()); + } - String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), entityFieldsFiltersMapping); - String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true); - String latestJoinsData = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, false); - String aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, pageLink.getTextSearch()); String entityFieldsSelection = EntityKeyMapping.buildSelections(entityFieldsSelectionMapping, query.getEntityFilter().getType(), entityType); String entityTypeStr; if (query.getEntityFilter().getType().equals(EntityFilterType.RELATIONS_QUERY)) { From 02ddfd09a9469636ab3f53a8310e2dda24708db3 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 23 Mar 2026 16:54:30 +0200 Subject: [PATCH 08/28] Add controller integration tests for OR key filter queries - Add testCountEntitiesWithOrKeyFiltersOperation: verifies OR count returns entities matching any filter - Add testFindEntityDataWithOrKeyFiltersOperation: verifies OR data query with different keys - Add testFindEntityDataWithOrSameKeyFilters: verifies same-key OR ungrouping - Add testCountEntitiesWithoutKeyFiltersOperation: verifies null defaults to AND - Add testAlarmDataQueryWithOrKeyFiltersOperation: verifies alarm queries with OR - Add OrConditionsDisabledEntityQueryControllerTest: verifies OR rejected when toggle disabled --- .../controller/EntityQueryControllerTest.java | 325 ++++++++++++++++++ ...ionsDisabledEntityQueryControllerTest.java | 63 ++++ 2 files changed, 388 insertions(+) create mode 100644 application/src/test/java/org/thingsboard/server/controller/OrConditionsDisabledEntityQueryControllerTest.java diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java index 835f00985b..2cf7c85ba9 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -62,6 +62,7 @@ import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.AvailableEntityKeysV2; import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeyInfo; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.DynamicValueSourceType; @@ -1729,4 +1730,328 @@ public class EntityQueryControllerTest extends AbstractControllerTest { return new EntityDataQuery(filter, new EntityDataPageLink(1, 0, null, null), Collections.emptyList(), null, null); } + // --- OR conditions tests --- + + @Test + public void testCountEntitiesWithOrKeyFiltersOperation() throws Exception { + // Create 3 devices with different temperature attributes + Device deviceA = new Device(); + deviceA.setName("OrTestDeviceA"); + deviceA.setType("orTestType"); + deviceA = doPost("/api/device", deviceA, Device.class); + String payloadA = "{\"temperature\":60}"; + doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, payloadA, String.class, status().isOk()); + + Device deviceB = new Device(); + deviceB.setName("OrTestDeviceB"); + deviceB.setType("orTestType"); + deviceB = doPost("/api/device", deviceB, Device.class); + String payloadB = "{\"temperature\":5}"; + doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, payloadB, String.class, status().isOk()); + + Device deviceC = new Device(); + deviceC.setName("OrTestDeviceC"); + deviceC.setType("orTestType"); + deviceC = doPost("/api/device", deviceC, Device.class); + String payloadC = "{\"temperature\":30}"; + doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE, payloadC, String.class, status().isOk()); + + Thread.sleep(1000); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("orTestType")); + filter.setDeviceNameFilter(""); + + // Filter 1: temperature > 50 + KeyFilter tempGt50 = new KeyFilter(); + tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempGt50.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate gt50 = new NumericFilterPredicate(); + gt50.setValue(FilterPredicateValue.fromDouble(50)); + gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + tempGt50.setPredicate(gt50); + + // Filter 2: temperature < 10 + KeyFilter tempLt10 = new KeyFilter(); + tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempLt10.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate lt10 = new NumericFilterPredicate(); + lt10.setValue(FilterPredicateValue.fromDouble(10)); + lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS); + tempLt10.setPredicate(lt10); + + List keyFilters = List.of(tempGt50, tempLt10); + + // OR: deviceA (60>50) and deviceB (5<10) match => count=2 + EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); + countByQueryAndCheck(orQuery, 2); + + // AND: no device has temperature both >50 AND <10 => count=0 + EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); + countByQueryAndCheck(andQuery, 0); + } + + @Test + public void testFindEntityDataWithOrKeyFiltersOperation() throws Exception { + // Create devices with different attributes + Device deviceX = new Device(); + deviceX.setName("OrDataDeviceX"); + deviceX.setType("orDataType"); + deviceX = doPost("/api/device", deviceX, Device.class); + doPost("/api/plugins/telemetry/" + deviceX.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"status\":\"active\"}", String.class, status().isOk()); + + Device deviceY = new Device(); + deviceY.setName("OrDataDeviceY"); + deviceY.setType("orDataType"); + deviceY = doPost("/api/device", deviceY, Device.class); + doPost("/api/plugins/telemetry/" + deviceY.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"humidity\":80}", String.class, status().isOk()); + + Device deviceZ = new Device(); + deviceZ.setName("OrDataDeviceZ"); + deviceZ.setType("orDataType"); + deviceZ = doPost("/api/device", deviceZ, Device.class); + // deviceZ has neither matching attribute + + Thread.sleep(1000); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("orDataType")); + filter.setDeviceNameFilter(""); + + // Filter 1: status = "active" + KeyFilter statusFilter = new KeyFilter(); + statusFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "status")); + statusFilter.setValueType(EntityKeyValueType.STRING); + StringFilterPredicate statusPred = new StringFilterPredicate(); + statusPred.setValue(FilterPredicateValue.fromString("active")); + statusPred.setOperation(StringFilterPredicate.StringOperation.EQUAL); + statusFilter.setPredicate(statusPred); + + // Filter 2: humidity > 70 + KeyFilter humidityFilter = new KeyFilter(); + humidityFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "humidity")); + humidityFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate humidityPred = new NumericFilterPredicate(); + humidityPred.setValue(FilterPredicateValue.fromDouble(70)); + humidityPred.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + humidityFilter.setPredicate(humidityPred); + + List keyFilters = List.of(statusFilter, humidityFilter); + + EntityDataSortOrder sortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC + ); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder); + List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + + // OR: deviceX matches status=active, deviceY matches humidity>70 + EntityDataQuery orQuery = new EntityDataQuery(filter, pageLink, entityFields, null, keyFilters, ComplexOperation.OR); + PageData result = findByQueryAndCheck(orQuery, 2); + List names = result.getData().stream() + .map(e -> e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()) + .collect(Collectors.toList()); + assertThat(names).containsExactlyInAnyOrder("OrDataDeviceX", "OrDataDeviceY"); + } + + @Test + public void testFindEntityDataWithOrSameKeyFilters() throws Exception { + // Create devices with temperature values that test same-key OR ungrouping + Device deviceA = new Device(); + deviceA.setName("OrSameKeyDeviceA"); + deviceA.setType("orSameKeyType"); + deviceA = doPost("/api/device", deviceA, Device.class); + doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":60}", String.class, status().isOk()); + + Device deviceB = new Device(); + deviceB.setName("OrSameKeyDeviceB"); + deviceB.setType("orSameKeyType"); + deviceB = doPost("/api/device", deviceB, Device.class); + doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":5}", String.class, status().isOk()); + + Thread.sleep(1000); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("orSameKeyType")); + filter.setDeviceNameFilter(""); + + // Filter 1: temperature > 50 + KeyFilter tempGt50 = new KeyFilter(); + tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempGt50.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate gt50 = new NumericFilterPredicate(); + gt50.setValue(FilterPredicateValue.fromDouble(50)); + gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + tempGt50.setPredicate(gt50); + + // Filter 2: temperature < 10 (same key, different predicate) + KeyFilter tempLt10 = new KeyFilter(); + tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempLt10.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate lt10 = new NumericFilterPredicate(); + lt10.setValue(FilterPredicateValue.fromDouble(10)); + lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS); + tempLt10.setPredicate(lt10); + + List keyFilters = List.of(tempGt50, tempLt10); + + EntityDataSortOrder sortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC + ); + EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, sortOrder); + List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + + // OR on same key: deviceA (60>50) and deviceB (5<10) should both be returned + EntityDataQuery orQuery = new EntityDataQuery(filter, pageLink, entityFields, null, keyFilters, ComplexOperation.OR); + PageData result = findByQueryAndCheck(orQuery, 2); + List names = result.getData().stream() + .map(e -> e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()) + .collect(Collectors.toList()); + assertThat(names).containsExactlyInAnyOrder("OrSameKeyDeviceA", "OrSameKeyDeviceB"); + } + + @Test + public void testCountEntitiesWithoutKeyFiltersOperation() throws Exception { + // Create 2 devices with temperature attributes + Device deviceA = new Device(); + deviceA.setName("BackCompatDeviceA"); + deviceA.setType("backCompatType"); + deviceA = doPost("/api/device", deviceA, Device.class); + doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":60}", String.class, status().isOk()); + + Device deviceB = new Device(); + deviceB.setName("BackCompatDeviceB"); + deviceB.setType("backCompatType"); + deviceB = doPost("/api/device", deviceB, Device.class); + doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":5}", String.class, status().isOk()); + + Thread.sleep(1000); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("backCompatType")); + filter.setDeviceNameFilter(""); + + // Filter 1: temperature > 50 + KeyFilter tempGt50 = new KeyFilter(); + tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempGt50.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate gt50 = new NumericFilterPredicate(); + gt50.setValue(FilterPredicateValue.fromDouble(50)); + gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + tempGt50.setPredicate(gt50); + + // Filter 2: temperature < 10 + KeyFilter tempLt10 = new KeyFilter(); + tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempLt10.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate lt10 = new NumericFilterPredicate(); + lt10.setValue(FilterPredicateValue.fromDouble(10)); + lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS); + tempLt10.setPredicate(lt10); + + List keyFilters = List.of(tempGt50, tempLt10); + + // Query without keyFiltersOperation (null) -- should behave as AND + EntityCountQuery nullOpQuery = new EntityCountQuery(filter, keyFilters); + Long nullResult = countByQueryAndCheck(nullOpQuery, 0); + + // Query with explicit AND -- should produce the same result + EntityCountQuery andOpQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); + Long andResult = countByQueryAndCheck(andOpQuery, 0); + + Assert.assertEquals(nullResult, andResult); + } + + @Test + public void testAlarmDataQueryWithOrKeyFiltersOperation() throws Exception { + loginTenantAdmin(); + + // Create devices with different temperatures and alarms + Device deviceHot = new Device(); + deviceHot.setName("OrAlarmDeviceHot"); + deviceHot.setType("orAlarmType"); + deviceHot = doPost("/api/device", deviceHot, Device.class); + doPost("/api/plugins/telemetry/" + deviceHot.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":60}", String.class, status().isOk()); + + Device deviceCold = new Device(); + deviceCold.setName("OrAlarmDeviceCold"); + deviceCold.setType("orAlarmType"); + deviceCold = doPost("/api/device", deviceCold, Device.class); + doPost("/api/plugins/telemetry/" + deviceCold.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":5}", String.class, status().isOk()); + + Device deviceMid = new Device(); + deviceMid.setName("OrAlarmDeviceMid"); + deviceMid.setType("orAlarmType"); + deviceMid = doPost("/api/device", deviceMid, Device.class); + doPost("/api/plugins/telemetry/" + deviceMid.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":30}", String.class, status().isOk()); + + Thread.sleep(500); + + // Create alarms for each device + Alarm alarmHot = new Alarm(); + alarmHot.setOriginator(deviceHot.getId()); + alarmHot.setType("highTemp"); + alarmHot.setSeverity(AlarmSeverity.CRITICAL); + doPost("/api/alarm", alarmHot, Alarm.class); + + Alarm alarmCold = new Alarm(); + alarmCold.setOriginator(deviceCold.getId()); + alarmCold.setType("lowTemp"); + alarmCold.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarmCold, Alarm.class); + + Alarm alarmMid = new Alarm(); + alarmMid.setOriginator(deviceMid.getId()); + alarmMid.setType("normalTemp"); + alarmMid.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarmMid, Alarm.class); + + Thread.sleep(500); + + // Filter 1: temperature > 50 + KeyFilter tempGt50 = new KeyFilter(); + tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempGt50.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate gt50 = new NumericFilterPredicate(); + gt50.setValue(FilterPredicateValue.fromDouble(50)); + gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + tempGt50.setPredicate(gt50); + + // Filter 2: temperature < 10 + KeyFilter tempLt10 = new KeyFilter(); + tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempLt10.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate lt10 = new NumericFilterPredicate(); + lt10.setValue(FilterPredicateValue.fromDouble(10)); + lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS); + tempLt10.setPredicate(lt10); + + List keyFilters = List.of(tempGt50, tempLt10); + + DeviceTypeFilter entityFilter = new DeviceTypeFilter(); + entityFilter.setDeviceTypes(List.of("orAlarmType")); + entityFilter.setDeviceNameFilter(""); + + AlarmDataPageLink pageLink = new AlarmDataPageLink(); + pageLink.setPage(0); + pageLink.setPageSize(100); + pageLink.setSortOrder(new EntityDataSortOrder(new EntityKey(EntityKeyType.ALARM_FIELD, "createdTime"))); + + List alarmFields = List.of(new EntityKey(EntityKeyType.ALARM_FIELD, "type")); + + // OR query: should return alarms for deviceHot (60>50) and deviceCold (5<10) = 2 alarms + AlarmDataQuery orAlarmQuery = new AlarmDataQuery(entityFilter, pageLink, null, null, keyFilters, alarmFields, ComplexOperation.OR); + PageData alarmResult = findAlarmsByQueryAndCheck(orAlarmQuery, 2); + List alarmTypes = alarmResult.getData().stream().map(AlarmData::getType).collect(Collectors.toList()); + assertThat(alarmTypes).containsExactlyInAnyOrder("highTemp", "lowTemp"); + } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/OrConditionsDisabledEntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/OrConditionsDisabledEntityQueryControllerTest.java new file mode 100644 index 0000000000..4b5d3959b9 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/OrConditionsDisabledEntityQueryControllerTest.java @@ -0,0 +1,63 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import org.junit.Test; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.common.data.query.ComplexOperation; +import org.thingsboard.server.common.data.query.DeviceTypeFilter; +import org.thingsboard.server.common.data.query.EntityCountQuery; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +@DirtiesContext +@TestPropertySource(properties = { + "queue.edqs.sync.enabled=true", + "queue.edqs.api.supported=false", + "sql.query.key-filters-or-conditions.enabled=false" +}) +public class OrConditionsDisabledEntityQueryControllerTest extends EntityQueryControllerTest { + + @Test + public void testOrKeyFiltersOperationRejectedWhenDisabled() throws Exception { + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("default")); + filter.setDeviceNameFilter(""); + + // POST a query with OR operation -- should be rejected with 400 + EntityCountQuery orQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.OR); + String errorMessage = getErrorMessage( + doPost("/api/entitiesQuery/count", orQuery).andExpect(status().isBadRequest()) + ); + assertThat(errorMessage).contains("OR conditions between key filters are disabled"); + + // POST a query without keyFiltersOperation (null/AND) -- should still succeed + EntityCountQuery andQuery = new EntityCountQuery(filter, Collections.emptyList()); + doPost("/api/entitiesQuery/count", andQuery).andExpect(status().isOk()); + + // POST a query with explicit AND -- should also succeed + EntityCountQuery explicitAndQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.AND); + doPost("/api/entitiesQuery/count", explicitAndQuery).andExpect(status().isOk()); + } + +} From adeacba23f62f01a0340bc0ae253135beddbe015 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 23 Mar 2026 16:56:05 +0200 Subject: [PATCH 09/28] Add EDQS unit tests for OR filter evaluation in RepositoryUtils - Add testCheckKeyFiltersWithOrOperation: OR short-circuits on first match - Add testCheckKeyFiltersWithOrOperationNoMatch: OR returns false when nothing matches - Add testCheckKeyFiltersWithOrOperationSameKey: same-key OR works correctly - Add testCheckKeyFiltersWithAndOperationUnchanged: AND behavior unchanged - Add testCheckKeyFiltersWithNullOperationDefaultsToAnd: null defaults to AND - Add testCheckKeyFiltersWithOrEmptyFilters: OR with empty filters returns true --- .../server/edqs/repo/RepositoryUtilsTest.java | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java index 557cfb7a4a..a7846c0674 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.edqs.repo; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -44,6 +45,7 @@ import org.thingsboard.server.edqs.query.DataKey; import org.thingsboard.server.edqs.query.EdqsFilter; import org.thingsboard.server.edqs.util.RepositoryUtils; +import java.util.Collections; import java.util.List; import java.util.UUID; import java.util.stream.Stream; @@ -469,4 +471,118 @@ public class RepositoryUtilsTest { return complexFilterPredicate; } + // --- OR operation tests for checkKeyFilters --- + + @Test + public void testCheckKeyFiltersWithOrOperation() { + // Entity with temperature=60; filters: temperature>50 (match) and humidity>80 (no data) + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("orDevice1").build()); + deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0)); + + EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50); + EdqsFilter humidityGt80 = getHumidityFilter(NumericOperation.GREATER, 80); + + // OR: temperature>50 matches, short-circuits to true + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), ComplexOperation.OR)).isTrue(); + } + + @Test + public void testCheckKeyFiltersWithOrOperationNoMatch() { + // Entity with temperature=30; filters: temperature>50 (no match) and temperature<10 (no match) + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("orDevice2").build()); + deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 30.0)); + + EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50); + EdqsFilter tempLt10 = getTemperatureFilter(NumericOperation.LESS, 10); + + // OR: neither filter matches + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, tempLt10), ComplexOperation.OR)).isFalse(); + } + + @Test + public void testCheckKeyFiltersWithOrOperationSameKey() { + // Entity with temperature=5; filters: temperature>50 (no match) and temperature<10 (match) + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("orDevice3").build()); + deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 5.0)); + + EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50); + EdqsFilter tempLt10 = getTemperatureFilter(NumericOperation.LESS, 10); + + // OR on same key: second filter matches + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, tempLt10), ComplexOperation.OR)).isTrue(); + } + + @Test + public void testCheckKeyFiltersWithAndOperationUnchanged() { + // Entity with temperature=60 and humidity=90 + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("andDevice1").build()); + deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0)); + deviceData.putTs(6, new LongDataPoint(System.currentTimeMillis(), 90)); + + EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50); + EdqsFilter humidityGt80 = getHumidityFilter(NumericOperation.GREATER, 80); + + // AND: both match + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), ComplexOperation.AND)).isTrue(); + + // Now with humidity=70 -- humidity>80 fails + DeviceData deviceData2 = new DeviceData(UUID.randomUUID()); + deviceData2.setCustomerId(UUID.randomUUID()); + deviceData2.setFields(DeviceFields.builder().name("andDevice2").build()); + deviceData2.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0)); + deviceData2.putTs(6, new LongDataPoint(System.currentTimeMillis(), 70)); + + // AND: humidity>80 fails + assertThat(RepositoryUtils.checkKeyFilters(deviceData2, List.of(tempGt50, humidityGt80), ComplexOperation.AND)).isFalse(); + } + + @Test + public void testCheckKeyFiltersWithNullOperationDefaultsToAnd() { + // Entity with temperature=60 and humidity=90 + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("nullOpDevice").build()); + deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0)); + deviceData.putTs(6, new LongDataPoint(System.currentTimeMillis(), 90)); + + EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50); + EdqsFilter humidityGt80 = getHumidityFilter(NumericOperation.GREATER, 80); + + // null operation should behave like AND + boolean nullResult = RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), null); + boolean andResult = RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), ComplexOperation.AND); + assertThat(nullResult).isEqualTo(andResult); + assertThat(nullResult).isTrue(); + + // Entity with temperature=60 and humidity=70 -- humidity>80 fails + DeviceData deviceData2 = new DeviceData(UUID.randomUUID()); + deviceData2.setCustomerId(UUID.randomUUID()); + deviceData2.setFields(DeviceFields.builder().name("nullOpDevice2").build()); + deviceData2.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0)); + deviceData2.putTs(6, new LongDataPoint(System.currentTimeMillis(), 70)); + + boolean nullResult2 = RepositoryUtils.checkKeyFilters(deviceData2, List.of(tempGt50, humidityGt80), null); + boolean andResult2 = RepositoryUtils.checkKeyFilters(deviceData2, List.of(tempGt50, humidityGt80), ComplexOperation.AND); + assertThat(nullResult2).isEqualTo(andResult2); + assertThat(nullResult2).isFalse(); + } + + @Test + public void testCheckKeyFiltersWithOrEmptyFilters() { + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("emptyFilterDevice").build()); + + // OR with empty filters = include all (matches SQL behavior) + assertThat(RepositoryUtils.checkKeyFilters(deviceData, Collections.emptyList(), ComplexOperation.OR)).isTrue(); + } + } From ca08f4b435fec0376a01c5e6aa98dc746a0a5096 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 23 Mar 2026 18:20:28 +0200 Subject: [PATCH 10/28] Fix OR key filter SQL generation, propagation, and add comprehensive tests - Move OR filter predicates to middle-layer WHERE clause where JOIN alias table references are visible, fixing BadSqlGrammarException - Use alias name (e.g. alias2) instead of bare column name for entity field predicates in middle-layer WHERE - Propagate keyFiltersOperation through BaseEntityService optimization path and alarm subscription contexts (TbAlarmDataSubCtx, TbAlarmCountSubCtx) - Replace ThingsboardException with IllegalArgumentException for OR disabled validation - Add AlarmCountQuery 3-arg constructor for test convenience - Add 8 new integration tests: alarm data/count OR, mixed entity field + attribute OR, string attributes, single filter, 3-way OR, pagination, and zero-match edge case --- .../query/DefaultEntityQueryService.java | 12 +- .../service/query/EntityQueryService.java | 9 +- .../subscription/TbAlarmCountSubCtx.java | 2 +- .../subscription/TbAlarmDataSubCtx.java | 2 +- .../controller/EntityQueryControllerTest.java | 435 ++++++++++++++++++ .../common/data/query/AlarmCountQuery.java | 4 + .../server/dao/entity/BaseEntityService.java | 2 +- .../query/DefaultEntityQueryRepository.java | 24 +- .../dao/sql/query/EntityKeyMapping.java | 12 +- 9 files changed, 474 insertions(+), 28 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java index 00426dae18..5883920ad8 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java @@ -107,13 +107,13 @@ public class DefaultEntityQueryService implements EntityQueryService { private AttributesService attributesService; @Override - public long countEntitiesByQuery(SecurityUser securityUser, EntityCountQuery query) throws ThingsboardException { + public long countEntitiesByQuery(SecurityUser securityUser, EntityCountQuery query) { validateKeyFiltersOperation(query); return entityService.countEntitiesByQuery(securityUser.getTenantId(), securityUser.getCustomerId(), query); } @Override - public PageData findEntityDataByQuery(SecurityUser securityUser, EntityDataQuery query) throws ThingsboardException { + public PageData findEntityDataByQuery(SecurityUser securityUser, EntityDataQuery query) { validateKeyFiltersOperation(query); if (query.getKeyFilters() != null) { resolveDynamicValuesInPredicates( @@ -183,7 +183,7 @@ public class DefaultEntityQueryService implements EntityQueryService { } @Override - public PageData findAlarmDataByQuery(SecurityUser securityUser, AlarmDataQuery query) throws ThingsboardException { + public PageData findAlarmDataByQuery(SecurityUser securityUser, AlarmDataQuery query) { validateKeyFiltersOperation(query); EntityDataQuery entityDataQuery = this.buildEntityDataQuery(query); PageData entities = entityService.findEntityDataByQuery(securityUser.getTenantId(), @@ -210,7 +210,7 @@ public class DefaultEntityQueryService implements EntityQueryService { } @Override - public long countAlarmsByQuery(SecurityUser securityUser, AlarmCountQuery query) throws ThingsboardException { + public long countAlarmsByQuery(SecurityUser securityUser, AlarmCountQuery query) { validateKeyFiltersOperation(query); if (query.getEntityFilter() != null) { EntityDataQuery entityDataQuery = this.buildEntityDataQuery(query); @@ -244,9 +244,9 @@ public class DefaultEntityQueryService implements EntityQueryService { return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperationOrDefault()); } - private void validateKeyFiltersOperation(EntityCountQuery query) throws ThingsboardException { + private void validateKeyFiltersOperation(EntityCountQuery query) { if (!keyFiltersOrConditionsEnabled && query.getKeyFiltersOperation() == ComplexOperation.OR) { - throw new ThingsboardException("OR conditions between key filters are disabled", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + throw new IllegalArgumentException("OR conditions between key filters are disabled"); } } diff --git a/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java index b3d2f208ca..354f8e9278 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java @@ -17,7 +17,6 @@ package org.thingsboard.server.service.query; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.AttributeScope; -import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.AlarmCountQuery; @@ -34,13 +33,13 @@ import java.util.Set; public interface EntityQueryService { - long countEntitiesByQuery(SecurityUser securityUser, EntityCountQuery query) throws ThingsboardException; + long countEntitiesByQuery(SecurityUser securityUser, EntityCountQuery query); - PageData findEntityDataByQuery(SecurityUser securityUser, EntityDataQuery query) throws ThingsboardException; + PageData findEntityDataByQuery(SecurityUser securityUser, EntityDataQuery query); - PageData findAlarmDataByQuery(SecurityUser securityUser, AlarmDataQuery query) throws ThingsboardException; + PageData findAlarmDataByQuery(SecurityUser securityUser, AlarmDataQuery query); - long countAlarmsByQuery(SecurityUser securityUser, AlarmCountQuery query) throws ThingsboardException; + long countAlarmsByQuery(SecurityUser securityUser, AlarmCountQuery query); ListenableFuture getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query, boolean isTimeseries, boolean isAttributes, AttributeScope scope); diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java index c2fe743bb4..22fdd77d7a 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java @@ -130,7 +130,7 @@ public class TbAlarmCountSubCtx extends TbAbstractEntityQuerySubCtx { entitiesSortOrder = sortOrder; } EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder); - return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters()); + return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperation()); } } diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java index 2cf7c85ba9..cee923346b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -78,6 +78,7 @@ import org.thingsboard.server.common.data.query.EntityListFilter; import org.thingsboard.server.common.data.query.EntityTypeFilter; import org.thingsboard.server.common.data.query.FilterPredicateValue; import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.NumericFilterPredicate; import org.thingsboard.server.common.data.query.RelationsQueryFilter; import org.thingsboard.server.common.data.query.SingleEntityFilter; @@ -2052,6 +2053,440 @@ public class EntityQueryControllerTest extends AbstractControllerTest { PageData alarmResult = findAlarmsByQueryAndCheck(orAlarmQuery, 2); List alarmTypes = alarmResult.getData().stream().map(AlarmData::getType).collect(Collectors.toList()); assertThat(alarmTypes).containsExactlyInAnyOrder("highTemp", "lowTemp"); + + // AND query: no device has temp both >50 AND <10 => 0 alarms + AlarmDataQuery andAlarmQuery = new AlarmDataQuery(entityFilter, pageLink, null, null, keyFilters, alarmFields, ComplexOperation.AND); + findAlarmsByQueryAndCheck(andAlarmQuery, 0); + } + + @Test + public void testCountAlarmsByQueryWithOrKeyFiltersOperation() throws Exception { + loginTenantAdmin(); + + Device deviceHot = new Device(); + deviceHot.setName("OrAlarmCntDeviceHot"); + deviceHot.setType("orAlarmCntType"); + deviceHot = doPost("/api/device", deviceHot, Device.class); + doPost("/api/plugins/telemetry/" + deviceHot.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":60}", String.class, status().isOk()); + + Device deviceCold = new Device(); + deviceCold.setName("OrAlarmCntDeviceCold"); + deviceCold.setType("orAlarmCntType"); + deviceCold = doPost("/api/device", deviceCold, Device.class); + doPost("/api/plugins/telemetry/" + deviceCold.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":5}", String.class, status().isOk()); + + Device deviceMid = new Device(); + deviceMid.setName("OrAlarmCntDeviceMid"); + deviceMid.setType("orAlarmCntType"); + deviceMid = doPost("/api/device", deviceMid, Device.class); + doPost("/api/plugins/telemetry/" + deviceMid.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":30}", String.class, status().isOk()); + + Thread.sleep(500); + + // Create 2 alarms for deviceHot, 1 for deviceCold, 1 for deviceMid + Alarm alarm1 = new Alarm(); + alarm1.setOriginator(deviceHot.getId()); + alarm1.setType("highTemp1"); + alarm1.setSeverity(AlarmSeverity.CRITICAL); + doPost("/api/alarm", alarm1, Alarm.class); + + Alarm alarm2 = new Alarm(); + alarm2.setOriginator(deviceHot.getId()); + alarm2.setType("highTemp2"); + alarm2.setSeverity(AlarmSeverity.CRITICAL); + doPost("/api/alarm", alarm2, Alarm.class); + + Alarm alarm3 = new Alarm(); + alarm3.setOriginator(deviceCold.getId()); + alarm3.setType("lowTemp"); + alarm3.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarm3, Alarm.class); + + Alarm alarm4 = new Alarm(); + alarm4.setOriginator(deviceMid.getId()); + alarm4.setType("normalTemp"); + alarm4.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarm4, Alarm.class); + + Thread.sleep(500); + + KeyFilter tempGt50 = new KeyFilter(); + tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempGt50.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate gt50 = new NumericFilterPredicate(); + gt50.setValue(FilterPredicateValue.fromDouble(50)); + gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + tempGt50.setPredicate(gt50); + + KeyFilter tempLt10 = new KeyFilter(); + tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempLt10.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate lt10 = new NumericFilterPredicate(); + lt10.setValue(FilterPredicateValue.fromDouble(10)); + lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS); + tempLt10.setPredicate(lt10); + + List keyFilters = List.of(tempGt50, tempLt10); + + DeviceTypeFilter entityFilter = new DeviceTypeFilter(); + entityFilter.setDeviceTypes(List.of("orAlarmCntType")); + entityFilter.setDeviceNameFilter(""); + + // OR: deviceHot (2 alarms) + deviceCold (1 alarm) match => 3 alarms total + AlarmCountQuery orQuery = new AlarmCountQuery(entityFilter, keyFilters, ComplexOperation.OR); + countAlarmsByQueryAndCheck(orQuery, 3); + + // AND: no device matches both filters => 0 + AlarmCountQuery andQuery = new AlarmCountQuery(entityFilter, keyFilters, ComplexOperation.AND); + countAlarmsByQueryAndCheck(andQuery, 0); + } + + @Test + public void testCountEntitiesWithOrMixedEntityFieldAndAttribute() throws Exception { + // Tests the entity field predicate relocation to middle-layer WHERE under OR + Device deviceA = new Device(); + deviceA.setName("OrMixedAlpha"); + deviceA.setType("orMixedType"); + deviceA = doPost("/api/device", deviceA, Device.class); + doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":10}", String.class, status().isOk()); + + Device deviceB = new Device(); + deviceB.setName("OrMixedBeta"); + deviceB.setType("orMixedType"); + deviceB = doPost("/api/device", deviceB, Device.class); + doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":60}", String.class, status().isOk()); + + Device deviceC = new Device(); + deviceC.setName("OrMixedGamma"); + deviceC.setType("orMixedType"); + deviceC = doPost("/api/device", deviceC, Device.class); + doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":10}", String.class, status().isOk()); + + Thread.sleep(1000); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("orMixedType")); + filter.setDeviceNameFilter(""); + + // Filter 1: entity field name CONTAINS "Alpha" + KeyFilter nameFilter = new KeyFilter(); + nameFilter.setKey(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + nameFilter.setValueType(EntityKeyValueType.STRING); + StringFilterPredicate namePred = new StringFilterPredicate(); + namePred.setValue(FilterPredicateValue.fromString("Alpha")); + namePred.setOperation(StringFilterPredicate.StringOperation.CONTAINS); + nameFilter.setPredicate(namePred); + + // Filter 2: attribute temperature > 50 + KeyFilter tempFilter = new KeyFilter(); + tempFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate tempPred = new NumericFilterPredicate(); + tempPred.setValue(FilterPredicateValue.fromDouble(50)); + tempPred.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + tempFilter.setPredicate(tempPred); + + List keyFilters = List.of(nameFilter, tempFilter); + + // OR: deviceA matches name contains "Alpha", deviceB matches temp>50 => count=2 + EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); + countByQueryAndCheck(orQuery, 2); + + // AND: only deviceA has name "Alpha" AND temp is 10 (not >50) => count=0 + EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); + countByQueryAndCheck(andQuery, 0); + } + + @Test + public void testCountEntitiesWithOrStringAttributes() throws Exception { + Device deviceA = new Device(); + deviceA.setName("OrStrDeviceA"); + deviceA.setType("orStrType"); + deviceA = doPost("/api/device", deviceA, Device.class); + doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"color\":\"red\"}", String.class, status().isOk()); + + Device deviceB = new Device(); + deviceB.setName("OrStrDeviceB"); + deviceB.setType("orStrType"); + deviceB = doPost("/api/device", deviceB, Device.class); + doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"color\":\"blue\"}", String.class, status().isOk()); + + Device deviceC = new Device(); + deviceC.setName("OrStrDeviceC"); + deviceC.setType("orStrType"); + deviceC = doPost("/api/device", deviceC, Device.class); + doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"color\":\"green\"}", String.class, status().isOk()); + + Thread.sleep(1000); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("orStrType")); + filter.setDeviceNameFilter(""); + + // Filter 1: color = "red" + KeyFilter redFilter = new KeyFilter(); + redFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "color")); + redFilter.setValueType(EntityKeyValueType.STRING); + StringFilterPredicate redPred = new StringFilterPredicate(); + redPred.setValue(FilterPredicateValue.fromString("red")); + redPred.setOperation(StringFilterPredicate.StringOperation.EQUAL); + redFilter.setPredicate(redPred); + + // Filter 2: color = "blue" + KeyFilter blueFilter = new KeyFilter(); + blueFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "color")); + blueFilter.setValueType(EntityKeyValueType.STRING); + StringFilterPredicate bluePred = new StringFilterPredicate(); + bluePred.setValue(FilterPredicateValue.fromString("blue")); + bluePred.setOperation(StringFilterPredicate.StringOperation.EQUAL); + blueFilter.setPredicate(bluePred); + + List keyFilters = List.of(redFilter, blueFilter); + + // OR: deviceA (red) and deviceB (blue) match => count=2 + EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); + countByQueryAndCheck(orQuery, 2); + + // AND: no device is both red AND blue => count=0 + EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); + countByQueryAndCheck(andQuery, 0); + } + + @Test + public void testCountEntitiesWithOrSingleFilter() throws Exception { + Device deviceA = new Device(); + deviceA.setName("OrSingleDeviceA"); + deviceA.setType("orSingleType"); + deviceA = doPost("/api/device", deviceA, Device.class); + doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":60}", String.class, status().isOk()); + + Device deviceB = new Device(); + deviceB.setName("OrSingleDeviceB"); + deviceB.setType("orSingleType"); + deviceB = doPost("/api/device", deviceB, Device.class); + doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":30}", String.class, status().isOk()); + + Thread.sleep(1000); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("orSingleType")); + filter.setDeviceNameFilter(""); + + KeyFilter tempGt50 = new KeyFilter(); + tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempGt50.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate gt50 = new NumericFilterPredicate(); + gt50.setValue(FilterPredicateValue.fromDouble(50)); + gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + tempGt50.setPredicate(gt50); + + List keyFilters = List.of(tempGt50); + + // Single filter with OR should behave identically to AND + EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); + Long orResult = countByQueryAndCheck(orQuery, 1); + + EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); + Long andResult = countByQueryAndCheck(andQuery, 1); + + Assert.assertEquals(orResult, andResult); + } + + @Test + public void testCountEntitiesWithOrThreeFilters() throws Exception { + Device deviceA = new Device(); + deviceA.setName("Or3fDeviceA"); + deviceA.setType("or3fType"); + deviceA = doPost("/api/device", deviceA, Device.class); + doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":60,\"humidity\":50,\"pressure\":1000}", String.class, status().isOk()); + + Device deviceB = new Device(); + deviceB.setName("Or3fDeviceB"); + deviceB.setType("or3fType"); + deviceB = doPost("/api/device", deviceB, Device.class); + doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":20,\"humidity\":90,\"pressure\":1000}", String.class, status().isOk()); + + Device deviceC = new Device(); + deviceC.setName("Or3fDeviceC"); + deviceC.setType("or3fType"); + deviceC = doPost("/api/device", deviceC, Device.class); + doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":20,\"humidity\":50,\"pressure\":1050}", String.class, status().isOk()); + + Device deviceD = new Device(); + deviceD.setName("Or3fDeviceD"); + deviceD.setType("or3fType"); + deviceD = doPost("/api/device", deviceD, Device.class); + doPost("/api/plugins/telemetry/" + deviceD.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":20,\"humidity\":50,\"pressure\":1000}", String.class, status().isOk()); + + Thread.sleep(1000); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("or3fType")); + filter.setDeviceNameFilter(""); + + // Filter 1: temperature > 50 + KeyFilter tempFilter = new KeyFilter(); + tempFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate tempPred = new NumericFilterPredicate(); + tempPred.setValue(FilterPredicateValue.fromDouble(50)); + tempPred.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + tempFilter.setPredicate(tempPred); + + // Filter 2: humidity > 80 + KeyFilter humFilter = new KeyFilter(); + humFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "humidity")); + humFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate humPred = new NumericFilterPredicate(); + humPred.setValue(FilterPredicateValue.fromDouble(80)); + humPred.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + humFilter.setPredicate(humPred); + + // Filter 3: pressure > 1040 + KeyFilter pressFilter = new KeyFilter(); + pressFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "pressure")); + pressFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate pressPred = new NumericFilterPredicate(); + pressPred.setValue(FilterPredicateValue.fromDouble(1040)); + pressPred.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + pressFilter.setPredicate(pressPred); + + List keyFilters = List.of(tempFilter, humFilter, pressFilter); + + // OR: A matches temp>50, B matches hum>80, C matches press>1040, D matches none => 3 + EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); + countByQueryAndCheck(orQuery, 3); + + // AND: no device matches all three => 0 + EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); + countByQueryAndCheck(andQuery, 0); + } + + @Test + public void testFindEntityDataWithOrPagination() throws Exception { + // Create 5 devices, 4 match OR filters (1,2: temp>50; 3,4: temp<10; 5: no match) + for (int i = 1; i <= 5; i++) { + Device device = new Device(); + device.setName(String.format("OrPageDevice%02d", i)); + device.setType("orPageType"); + device = doPost("/api/device", device, Device.class); + // Devices 1,2: temperature > 50. Device 3,4: temperature < 10. Device 5: no match (25). + int temp = (i <= 2) ? 60 + i : (i <= 4) ? 5 - i : 25; + doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":" + temp + "}", String.class, status().isOk()); + } + + Thread.sleep(1000); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("orPageType")); + filter.setDeviceNameFilter(""); + + KeyFilter tempGt50 = new KeyFilter(); + tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempGt50.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate gt50 = new NumericFilterPredicate(); + gt50.setValue(FilterPredicateValue.fromDouble(50)); + gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + tempGt50.setPredicate(gt50); + + KeyFilter tempLt10 = new KeyFilter(); + tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempLt10.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate lt10 = new NumericFilterPredicate(); + lt10.setValue(FilterPredicateValue.fromDouble(10)); + lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS); + tempLt10.setPredicate(lt10); + + List keyFilters = List.of(tempGt50, tempLt10); + + EntityDataSortOrder sortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC + ); + List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + + // Page 1: pageSize=2, totalElements=4, data.size()=2 + EntityDataPageLink pageLink1 = new EntityDataPageLink(2, 0, null, sortOrder); + EntityDataQuery orQuery1 = new EntityDataQuery(filter, pageLink1, entityFields, null, keyFilters, ComplexOperation.OR); + PageData page1 = findByQueryAndCheck(orQuery1, 4); + Assert.assertEquals(2, page1.getData().size()); + Assert.assertTrue(page1.hasNext()); + + // Page 2: remaining 2 of 4 + EntityDataPageLink pageLink2 = new EntityDataPageLink(2, 1, null, sortOrder); + EntityDataQuery orQuery2 = new EntityDataQuery(filter, pageLink2, entityFields, null, keyFilters, ComplexOperation.OR); + PageData page2 = findByQueryAndCheck(orQuery2, 4); + Assert.assertEquals(2, page2.getData().size()); + Assert.assertFalse(page2.hasNext()); + + // All names across both pages should be from the 4 matching devices (01,02,03,04) + List allNames = new ArrayList<>(); + page1.getData().forEach(e -> allNames.add(e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue())); + page2.getData().forEach(e -> allNames.add(e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue())); + assertThat(allNames).hasSize(4); + assertThat(allNames).doesNotContain("OrPageDevice05"); + } + + @Test + public void testCountEntitiesWithOrZeroMatches() throws Exception { + Device deviceA = new Device(); + deviceA.setName("OrZeroDeviceA"); + deviceA.setType("orZeroType"); + deviceA = doPost("/api/device", deviceA, Device.class); + doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":30}", String.class, status().isOk()); + + Device deviceB = new Device(); + deviceB.setName("OrZeroDeviceB"); + deviceB.setType("orZeroType"); + deviceB = doPost("/api/device", deviceB, Device.class); + doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":40}", String.class, status().isOk()); + + Thread.sleep(1000); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("orZeroType")); + filter.setDeviceNameFilter(""); + + // Filter 1: temperature > 50 (no match) + KeyFilter tempGt50 = new KeyFilter(); + tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempGt50.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate gt50 = new NumericFilterPredicate(); + gt50.setValue(FilterPredicateValue.fromDouble(50)); + gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + tempGt50.setPredicate(gt50); + + // Filter 2: temperature < 10 (no match) + KeyFilter tempLt10 = new KeyFilter(); + tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempLt10.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate lt10 = new NumericFilterPredicate(); + lt10.setValue(FilterPredicateValue.fromDouble(10)); + lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS); + tempLt10.setPredicate(lt10); + + List keyFilters = List.of(tempGt50, tempLt10); + + // OR with no matches: neither filter matches any device => count=0 + EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); + countByQueryAndCheck(orQuery, 0); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java index 8431d7a303..d45335021f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java @@ -51,4 +51,8 @@ public class AlarmCountQuery extends EntityCountQuery { super(entityFilter, keyFilters); } + public AlarmCountQuery(EntityFilter entityFilter, List keyFilters, ComplexOperation keyFiltersOperation) { + super(entityFilter, keyFilters, keyFiltersOperation); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java index b20535dbf4..e1b3b084ec 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java @@ -409,7 +409,7 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe .collect(Collectors.toList()); } } - EntityDataQuery entityQuery = new EntityDataQuery(query.getEntityFilter(), query.getPageLink(), entityFields, latestValues, query.getKeyFilters()); + EntityDataQuery entityQuery = new EntityDataQuery(query.getEntityFilter(), query.getPageLink(), entityFields, latestValues, query.getKeyFilters(), query.getKeyFiltersOperation()); return this.entityQueryDao.findEntityDataByQuery(tenantId, customerId, entityQuery); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java index 94e615a1bb..140fe38af4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java @@ -386,11 +386,16 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), innerEntityFieldsFilters); String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true, isOr); - // Under OR: combine ALL filter mappings (entity fields + aliases) in outer WHERE with OR joiner + // Under OR: combine ALL filter mappings (entity fields + aliases) with OR joiner + // Place at the middle layer (after JOINs) where alias table references are visible. + // Under AND: place at the outer result layer (existing behavior). String aliasWhereQuery; if (isOr) { String combinedFilterQuery = EntityKeyMapping.buildQuery(ctx, filterMapping, query.getEntityFilter().getType(), ComplexOperation.OR); - aliasWhereQuery = combinedFilterQuery.isEmpty() ? "" : " where (" + combinedFilterQuery + ")"; + if (!combinedFilterQuery.isEmpty()) { + latestJoinsCnt += " where (" + combinedFilterQuery + ")"; + } + aliasWhereQuery = ""; } else { aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, ""); } @@ -481,18 +486,23 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true, isOr); String latestJoinsData = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, false, isOr); - // Under OR: combine ALL filter mappings (entity fields + aliases) in outer WHERE with OR joiner + // Under OR: combine ALL filter mappings (entity fields + aliases) with OR joiner + // Place at the middle layer (after JOINs) where alias table references are visible. + // Under AND: place at the outer result layer (existing behavior). String aliasWhereQuery; if (isOr) { String combinedFilterQuery = EntityKeyMapping.buildQuery(ctx, filterMapping, query.getEntityFilter().getType(), ComplexOperation.OR); - String searchTextQuery = buildTextSearchQuery(ctx, selectionMapping, pageLink.getTextSearch()); - aliasWhereQuery = ""; + String middleWhere = ""; if (!combinedFilterQuery.isEmpty()) { - aliasWhereQuery = " where (" + combinedFilterQuery + ")"; + middleWhere = " where (" + combinedFilterQuery + ")"; } + String searchTextQuery = buildTextSearchQuery(ctx, selectionMapping, pageLink.getTextSearch()); if (!searchTextQuery.isEmpty()) { - aliasWhereQuery += (aliasWhereQuery.isEmpty() ? " where " : " and ") + "(" + searchTextQuery + ") "; + middleWhere += (middleWhere.isEmpty() ? " where " : " and ") + "(" + searchTextQuery + ") "; } + latestJoinsCnt += middleWhere; + latestJoinsData += middleWhere; + aliasWhereQuery = ""; } else { aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, pageLink.getTextSearch()); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java index 72096dbf6e..28fa6bec0f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java @@ -292,16 +292,14 @@ public class EntityKeyMapping { String keyAlias; if (entityKey.getType().equals(EntityKeyType.ENTITY_FIELD) && getEntityKeyColumn() != null) { if (outerContext) { - // In the outer query (OR relocation), entity field columns are available - // as bare column names from the inner subquery SELECT. - // buildSimplePredicateQuery does: alias + "." + entityKeyColumn when entityKeyColumn != null. - // To produce just the bare column name, temporarily null out entityKeyColumn - // so buildSimplePredicateQuery uses alias directly as the field. - String bareColumn = getEntityKeyColumn(); + // In the middle layer (OR relocation), entity field columns are available + // by their alias name from the inner subquery SELECT (e.g., "alias2" from + // "cast(e.name as varchar) as alias2"). Temporarily null out entityKeyColumn + // so buildSimplePredicateQuery uses the alias directly as the field. String savedColumn = this.entityKeyColumn; this.entityKeyColumn = null; List predicates = keyFilters.stream() - .map(keyFilter -> this.buildKeyQuery(ctx, bareColumn, keyFilter, filterType)) + .map(keyFilter -> this.buildKeyQuery(ctx, alias, keyFilter, filterType)) .collect(Collectors.toList()); this.entityKeyColumn = savedColumn; return predicates.stream(); From 293126caeaac0a4edf57898a68d58491a6addd92 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 24 Mar 2026 10:33:57 +0200 Subject: [PATCH 11/28] Fix test for disabled OR conditions --- .../query/DefaultEntityQueryService.java | 2 + .../controller/EntityQueryControllerTest.java | 35 ++++++++++- ...ionsDisabledEntityQueryControllerTest.java | 63 ------------------- 3 files changed, 35 insertions(+), 65 deletions(-) delete mode 100644 application/src/test/java/org/thingsboard/server/controller/OrConditionsDisabledEntityQueryControllerTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java index 5883920ad8..8da82a54d0 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java @@ -18,6 +18,7 @@ package org.thingsboard.server.service.query; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.springframework.beans.factory.annotation.Autowired; @@ -94,6 +95,7 @@ public class DefaultEntityQueryService implements EntityQueryService { @Value("${server.ws.max_entities_per_alarm_subscription:1000}") private int maxEntitiesPerAlarmSubscription; + @Setter @Value("${sql.query.key-filters-or-conditions.enabled:true}") private boolean keyFiltersOrConditionsEnabled; diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java index cee923346b..bf9762b208 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -78,7 +78,6 @@ import org.thingsboard.server.common.data.query.EntityListFilter; import org.thingsboard.server.common.data.query.EntityTypeFilter; import org.thingsboard.server.common.data.query.FilterPredicateValue; import org.thingsboard.server.common.data.query.KeyFilter; -import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.NumericFilterPredicate; import org.thingsboard.server.common.data.query.RelationsQueryFilter; import org.thingsboard.server.common.data.query.SingleEntityFilter; @@ -94,6 +93,7 @@ import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.edqs.util.EdqsRocksDb; +import org.thingsboard.server.service.query.DefaultEntityQueryService; import java.util.ArrayList; import java.util.Arrays; @@ -125,6 +125,8 @@ public class EntityQueryControllerTest extends AbstractControllerTest { @Autowired private QueueStatsService queueStatsService; + @Autowired + private DefaultEntityQueryService entityQueryService; @MockitoBean private EdqsRocksDb edqsRocksDb; @@ -1345,7 +1347,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { //assign dashboard doPost("/api/customer/" + savedCustomer.getId().getId().toString() - + "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); + + "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); // check entity data query by customer User customerUser = new User(); @@ -2489,4 +2491,33 @@ public class EntityQueryControllerTest extends AbstractControllerTest { countByQueryAndCheck(orQuery, 0); } + @Test + public void testOrKeyFiltersOperationRejectedWhenDisabled() throws Exception { + loginTenantAdmin(); + + entityQueryService.setKeyFiltersOrConditionsEnabled(false); + try { + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("default")); + filter.setDeviceNameFilter(""); + + // POST a query with OR operation -- should be rejected with 400 + EntityCountQuery orQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.OR); + String errorMessage = getErrorMessage( + doPost("/api/entitiesQuery/count", orQuery).andExpect(status().isBadRequest()) + ); + assertThat(errorMessage).contains("OR conditions between key filters are disabled"); + + // POST a query without keyFiltersOperation (null/AND) -- should still succeed + EntityCountQuery andQuery = new EntityCountQuery(filter, Collections.emptyList()); + doPost("/api/entitiesQuery/count", andQuery).andExpect(status().isOk()); + + // POST a query with explicit AND -- should also succeed + EntityCountQuery explicitAndQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.AND); + doPost("/api/entitiesQuery/count", explicitAndQuery).andExpect(status().isOk()); + } finally { + entityQueryService.setKeyFiltersOrConditionsEnabled(true); + } + } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/OrConditionsDisabledEntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/OrConditionsDisabledEntityQueryControllerTest.java deleted file mode 100644 index 4b5d3959b9..0000000000 --- a/application/src/test/java/org/thingsboard/server/controller/OrConditionsDisabledEntityQueryControllerTest.java +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Copyright © 2016-2026 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.controller; - -import org.junit.Test; -import org.springframework.test.annotation.DirtiesContext; -import org.springframework.test.context.TestPropertySource; -import org.thingsboard.server.common.data.query.ComplexOperation; -import org.thingsboard.server.common.data.query.DeviceTypeFilter; -import org.thingsboard.server.common.data.query.EntityCountQuery; -import org.thingsboard.server.dao.service.DaoSqlTest; - -import java.util.Collections; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@DaoSqlTest -@DirtiesContext -@TestPropertySource(properties = { - "queue.edqs.sync.enabled=true", - "queue.edqs.api.supported=false", - "sql.query.key-filters-or-conditions.enabled=false" -}) -public class OrConditionsDisabledEntityQueryControllerTest extends EntityQueryControllerTest { - - @Test - public void testOrKeyFiltersOperationRejectedWhenDisabled() throws Exception { - DeviceTypeFilter filter = new DeviceTypeFilter(); - filter.setDeviceTypes(List.of("default")); - filter.setDeviceNameFilter(""); - - // POST a query with OR operation -- should be rejected with 400 - EntityCountQuery orQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.OR); - String errorMessage = getErrorMessage( - doPost("/api/entitiesQuery/count", orQuery).andExpect(status().isBadRequest()) - ); - assertThat(errorMessage).contains("OR conditions between key filters are disabled"); - - // POST a query without keyFiltersOperation (null/AND) -- should still succeed - EntityCountQuery andQuery = new EntityCountQuery(filter, Collections.emptyList()); - doPost("/api/entitiesQuery/count", andQuery).andExpect(status().isOk()); - - // POST a query with explicit AND -- should also succeed - EntityCountQuery explicitAndQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.AND); - doPost("/api/entitiesQuery/count", explicitAndQuery).andExpect(status().isOk()); - } - -} From 16a92eefa088e9939bfef41ea21d59eee26f33bf Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 24 Mar 2026 11:36:14 +0200 Subject: [PATCH 12/28] Update TypeScript models for OR conditions support - Add allowKeyFiltersOrConditions to SysParamsState interface - Add keyFiltersOperation to FilterInfo and EntityCountQuery interfaces - Update keyFiltersToText to accept dynamic operation parameter with AND default - Propagate keyFiltersOperation in getFilterId helper --- ui-ngx/src/app/core/auth/auth.models.ts | 1 + ui-ngx/src/app/shared/models/query/query.models.ts | 14 +++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/ui-ngx/src/app/core/auth/auth.models.ts b/ui-ngx/src/app/core/auth/auth.models.ts index 623ceefadd..d353ae8c8c 100644 --- a/ui-ngx/src/app/core/auth/auth.models.ts +++ b/ui-ngx/src/app/core/auth/auth.models.ts @@ -40,6 +40,7 @@ export interface SysParamsState { calculatedFieldDebugPerTenantLimitsConfiguration?: string; intermediateAggregationIntervalInSecForCF: number; trendzSettings: TrendzSettings; + allowKeyFiltersOrConditions: boolean; } export interface SysParams extends SysParamsState { diff --git a/ui-ngx/src/app/shared/models/query/query.models.ts b/ui-ngx/src/app/shared/models/query/query.models.ts index 7f04f07664..6273fbd434 100644 --- a/ui-ngx/src/app/shared/models/query/query.models.ts +++ b/ui-ngx/src/app/shared/models/query/query.models.ts @@ -401,20 +401,22 @@ export interface FilterInfo { filter: string; editable: boolean; keyFilters: Array; + keyFiltersOperation?: ComplexOperation; } export interface FiltersInfo { datasourceFilters: {[datasourceIndex: number]: FilterInfo}; } -export function keyFiltersToText(translate: TranslateService, datePipe: DatePipe, keyFilters: Array): string { +export function keyFiltersToText(translate: TranslateService, datePipe: DatePipe, keyFilters: Array, + operation: ComplexOperation = ComplexOperation.AND): string { const filtersText = keyFilters.map(keyFilter => keyFilterToText(translate, datePipe, keyFilter, - keyFilters.length > 1 ? ComplexOperation.AND : undefined)); + keyFilters.length > 1 ? operation : undefined)); let result: string; if (filtersText.length > 1) { - const andText = translate.instant('filter.operation.and'); - result = filtersText.join(' ' + andText + ' '); + const opText = translate.instant(complexOperationTranslationMap.get(operation)); + result = filtersText.join(' ' + opText + ' '); } else { result = filtersText[0]; } @@ -802,6 +804,7 @@ export const singleEntityFilterFromDeviceId = (deviceId: string): EntityFilter = export interface EntityCountQuery { entityFilter: EntityFilter; keyFilters?: Array; + keyFiltersOperation?: ComplexOperation; } export interface AbstractDataQuery extends EntityCountQuery { @@ -940,7 +943,8 @@ export const getFilterId = (filters: Filters, filterInfo: FilterInfo): string => const newFilterName = createFilterName(filters, filterInfo.filter); newFilterId = guid(); filters[newFilterId] = {id: newFilterId, filter: newFilterName, - keyFilters: filterInfo.keyFilters, editable: filterInfo.editable}; + keyFilters: filterInfo.keyFilters, editable: filterInfo.editable, + keyFiltersOperation: filterInfo.keyFiltersOperation}; } return newFilterId; } From 5672c6812ba15d5e1370b1d2545ab1743164b413 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 24 Mar 2026 11:39:25 +0200 Subject: [PATCH 13/28] Add AND/OR toggle to filter dialog and dynamic operation labels - Add tb-toggle-select with AND/OR options to filter-dialog - Toggle disabled with tooltip when feature flag is false - Pass operation to key-filter-list via @Input binding - Replace hardcoded AND label with dynamic operation label - Pass operation through to filter-text preview component - Add or-conditions-disabled translation key --- .../components/filter/filter-dialog.component.html | 13 ++++++++++++- .../components/filter/filter-dialog.component.ts | 12 ++++++++++-- .../home/components/filter/filter-text.component.ts | 7 +++++-- .../filter/key-filter-list.component.html | 4 ++-- .../components/filter/key-filter-list.component.ts | 6 ++++++ ui-ngx/src/assets/locale/locale.constant-en_US.json | 1 + 6 files changed, 36 insertions(+), 7 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html index 230c8c8bb7..581dd42c93 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html @@ -48,8 +48,19 @@ +
+
filter.key-filters
+ + {{ complexOperationTranslationMap.get(ComplexOperation.AND) | translate }} + {{ complexOperationTranslationMap.get(ComplexOperation.OR) | translate }} + +
+ formControlName="keyFilters" + [operation]="filterFormGroup.get('keyFiltersOperation').value"> diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts index ec3c9b79f0..465b87b4b1 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts @@ -32,7 +32,8 @@ import { Router } from '@angular/router'; import { DialogComponent } from '@app/shared/components/dialog.component'; import { UtilsService } from '@core/services/utils.service'; import { TranslateService } from '@ngx-translate/core'; -import { Filter, Filters } from '@shared/models/query/query.models'; +import { ComplexOperation, complexOperationTranslationMap, Filter, Filters } from '@shared/models/query/query.models'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; export interface FilterDialogData { isAdd: boolean; @@ -59,6 +60,10 @@ export class FilterDialogComponent extends DialogComponent, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: FilterDialogData, @@ -87,11 +92,13 @@ export class FilterDialogComponent extends DialogComponent) { this.requiredClass = false; if (value && value.length) { - this.filterText = keyFiltersToText(this.translate, this.datePipe, value); + this.filterText = keyFiltersToText(this.translate, this.datePipe, value, this.operation); } else { if (this.required && !this.disabled) { this.filterText = this.addFilterPrompt; diff --git a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html index ba5fbfc250..a772c765fd 100644 --- a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html @@ -38,7 +38,7 @@ formArrayName="keyFilters" *ngFor="let keyFilterControl of keyFiltersFormArray.controls; let $index = index">
- filter.operation.and + {{ complexOperationTranslationMap.get(operation) | translate }}
@@ -85,7 +85,7 @@
- +
diff --git a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts index aad686707d..8951764a4b 100644 --- a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts @@ -30,6 +30,8 @@ import { } from '@angular/forms'; import { Observable, Subject } from 'rxjs'; import { + ComplexOperation, + complexOperationTranslationMap, EntityKeyType, entityKeyTypeTranslationMap, KeyFilterInfo, @@ -71,6 +73,10 @@ export class KeyFilterListComponent implements ControlValueAccessor, Validator, @Input() entityId: EntityId; + @Input() operation: ComplexOperation = ComplexOperation.AND; + + complexOperationTranslationMap = complexOperationTranslationMap; + keyFilterListFormGroup: UntypedFormGroup; entityKeyTypeTranslations = entityKeyTypeTranslationMap; diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 4fdc707c1e..1f4da168c7 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -3610,6 +3610,7 @@ "order-priority": "Display order", "key-filter": "Key filter", "key-filters": "Key filters", + "or-conditions-disabled": "OR conditions between key filters are disabled by the system administrator.", "key-name": "Key name", "key-name-required": "Key name is required.", "key-type": { From 2ef626e3d0d20949908475e2495d73dc6743451a Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 24 Mar 2026 11:48:05 +0200 Subject: [PATCH 14/28] Add keyFiltersOperation to Datasource and subscription option interfaces - Add keyFiltersOperation?: ComplexOperation to Datasource interface in widget.models.ts - Add keyFiltersOperation?: ComplexOperation to EntityDataSubscriptionOptions interface - Add keyFiltersOperation?: ComplexOperation to AlarmDataSubscriptionOptions interface - Import ComplexOperation from query.models in all three files --- ui-ngx/src/app/core/api/alarm-data-subscription.ts | 2 ++ ui-ngx/src/app/core/api/entity-data-subscription.ts | 2 ++ ui-ngx/src/app/shared/models/widget.models.ts | 2 ++ 3 files changed, 6 insertions(+) diff --git a/ui-ngx/src/app/core/api/alarm-data-subscription.ts b/ui-ngx/src/app/core/api/alarm-data-subscription.ts index 16dafd53eb..e854798ee5 100644 --- a/ui-ngx/src/app/core/api/alarm-data-subscription.ts +++ b/ui-ngx/src/app/core/api/alarm-data-subscription.ts @@ -23,6 +23,7 @@ import { DatasourceType } from '@shared/models/widget.models'; import { AlarmData, AlarmDataPageLink, + ComplexOperation, EntityFilter, EntityKey, EntityKeyType, @@ -47,6 +48,7 @@ export interface AlarmDataSubscriptionOptions { pageLink?: AlarmDataPageLink; keyFilters?: Array; additionalKeyFilters?: Array; + keyFiltersOperation?: ComplexOperation; subscriptionTimewindow?: SubscriptionTimewindow; } diff --git a/ui-ngx/src/app/core/api/entity-data-subscription.ts b/ui-ngx/src/app/core/api/entity-data-subscription.ts index 021c5e81e9..db3ac9e7ef 100644 --- a/ui-ngx/src/app/core/api/entity-data-subscription.ts +++ b/ui-ngx/src/app/core/api/entity-data-subscription.ts @@ -34,6 +34,7 @@ import { import { AlarmFilter, ComparisonTsValue, + ComplexOperation, EntityData, EntityDataPageLink, EntityFilter, @@ -112,6 +113,7 @@ export interface EntityDataSubscriptionOptions { pageLink?: EntityDataPageLink; keyFilters?: Array; additionalKeyFilters?: Array; + keyFiltersOperation?: ComplexOperation; subscriptionTimewindow?: SubscriptionTimewindow; latestTsOffset?: number; } diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index 1a2f7e1c8b..982f492eba 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -24,6 +24,7 @@ import { EntityId } from '@shared/models/id/entity-id'; import { AlarmFilter, AlarmFilterConfig, + ComplexOperation, EntityDataPageLink, EntityFilter, KeyFilter @@ -450,6 +451,7 @@ export interface Datasource { origDatasourceIndex?: number; pageLink?: EntityDataPageLink; keyFilters?: Array; + keyFiltersOperation?: ComplexOperation; entityFilter?: EntityFilter; alarmFilterConfig?: AlarmFilterConfig; alarmFilter?: AlarmFilter; From cea7fc2b69f6a50648295ee9e8808db685407d05 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 24 Mar 2026 11:50:11 +0200 Subject: [PATCH 15/28] Thread keyFiltersOperation through data subscription pipeline - alias-controller: propagate keyFiltersOperation from FilterInfo to Datasource - entity-data.service: propagate keyFiltersOperation from Datasource to EntityDataSubscriptionOptions - entity-data-subscription: include keyFiltersOperation in entity data, entity count, and alarm count query construction - alarm-data-subscription: include keyFiltersOperation in alarm data query construction - alarm-data.service: propagate keyFiltersOperation from alarmSource to AlarmDataSubscriptionOptions --- ui-ngx/src/app/core/api/alarm-data-subscription.ts | 1 + ui-ngx/src/app/core/api/alarm-data.service.ts | 1 + ui-ngx/src/app/core/api/alias-controller.ts | 4 ++++ ui-ngx/src/app/core/api/entity-data-subscription.ts | 7 +++++-- ui-ngx/src/app/core/api/entity-data.service.ts | 1 + 5 files changed, 12 insertions(+), 2 deletions(-) diff --git a/ui-ngx/src/app/core/api/alarm-data-subscription.ts b/ui-ngx/src/app/core/api/alarm-data-subscription.ts index e854798ee5..ad6abb6573 100644 --- a/ui-ngx/src/app/core/api/alarm-data-subscription.ts +++ b/ui-ngx/src/app/core/api/alarm-data-subscription.ts @@ -122,6 +122,7 @@ export class AlarmDataSubscription { entityFilter: this.alarmDataSubscriptionOptions.entityFilter, pageLink: deepClone(this.alarmDataSubscriptionOptions.pageLink), keyFilters, + keyFiltersOperation: this.alarmDataSubscriptionOptions.keyFiltersOperation, alarmFields, entityFields, latestValues diff --git a/ui-ngx/src/app/core/api/alarm-data.service.ts b/ui-ngx/src/app/core/api/alarm-data.service.ts index 901155dec5..c0c5a928bf 100644 --- a/ui-ngx/src/app/core/api/alarm-data.service.ts +++ b/ui-ngx/src/app/core/api/alarm-data.service.ts @@ -85,6 +85,7 @@ export class AlarmDataService { alarmDataSubscriptionOptions.entityFilter = alarmSource.entityFilter; alarmDataSubscriptionOptions.pageLink = pageLink; alarmDataSubscriptionOptions.keyFilters = alarmSource.keyFilters; + alarmDataSubscriptionOptions.keyFiltersOperation = alarmSource.keyFiltersOperation; alarmDataSubscriptionOptions.additionalKeyFilters = additionalKeyFilters; } return alarmDataSubscriptionOptions; diff --git a/ui-ngx/src/app/core/api/alias-controller.ts b/ui-ngx/src/app/core/api/alias-controller.ts index ffd06a5289..d48bcb574b 100644 --- a/ui-ngx/src/app/core/api/alias-controller.ts +++ b/ui-ngx/src/app/core/api/alias-controller.ts @@ -315,6 +315,10 @@ export class AliasController implements IAliasController { || newDatasource.type === DatasourceType.alarmCount) { if (newDatasource.filterId) { newDatasource.keyFilters = this.getKeyFilters(newDatasource.filterId); + const filterInfo = this.getFilterInfo(newDatasource.filterId); + if (filterInfo?.keyFiltersOperation) { + newDatasource.keyFiltersOperation = filterInfo.keyFiltersOperation; + } } if (newDatasource.type === DatasourceType.alarmCount) { newDatasource.alarmFilter = this.entityService.resolveAlarmFilter(newDatasource.alarmFilterConfig, false); diff --git a/ui-ngx/src/app/core/api/entity-data-subscription.ts b/ui-ngx/src/app/core/api/entity-data-subscription.ts index db3ac9e7ef..e367ae8faa 100644 --- a/ui-ngx/src/app/core/api/entity-data-subscription.ts +++ b/ui-ngx/src/app/core/api/entity-data-subscription.ts @@ -372,6 +372,7 @@ export class EntityDataSubscription { entityFilter: this.entityDataSubscriptionOptions.entityFilter, pageLink: this.entityDataSubscriptionOptions.pageLink, keyFilters, + keyFiltersOperation: this.entityDataSubscriptionOptions.keyFiltersOperation, entityFields, latestValues: this.latestValues }; @@ -496,7 +497,8 @@ export class EntityDataSubscription { } this.countCommand.query = { entityFilter: this.entityDataSubscriptionOptions.entityFilter, - keyFilters + keyFilters, + keyFiltersOperation: this.entityDataSubscriptionOptions.keyFiltersOperation }; this.subscriber.subscriptionCommands.push(this.countCommand); @@ -571,7 +573,8 @@ export class EntityDataSubscription { } this.alarmCountCommand.query = { entityFilter: this.entityDataSubscriptionOptions.entityFilter, - keyFilters + keyFilters, + keyFiltersOperation: this.entityDataSubscriptionOptions.keyFiltersOperation }; if (this.entityDataSubscriptionOptions.alarmFilter) { this.alarmCountCommand.query = {...this.alarmCountCommand.query, ...this.entityDataSubscriptionOptions.alarmFilter}; diff --git a/ui-ngx/src/app/core/api/entity-data.service.ts b/ui-ngx/src/app/core/api/entity-data.service.ts index 9ed064403c..697187b61e 100644 --- a/ui-ngx/src/app/core/api/entity-data.service.ts +++ b/ui-ngx/src/app/core/api/entity-data.service.ts @@ -183,6 +183,7 @@ export class EntityDataService { entityDataSubscriptionOptions.alarmFilter = datasource.alarmFilter; entityDataSubscriptionOptions.keyFilters = keyFilters; entityDataSubscriptionOptions.additionalKeyFilters = additionalKeyFilters; + entityDataSubscriptionOptions.keyFiltersOperation = datasource.keyFiltersOperation; if (entityDataSubscriptionOptions.datasourceType === DatasourceType.entity) { entityDataSubscriptionOptions.pageLink = pageLink; } From 38833727dca7dc6bbccaeb28dece8109fe181270 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 24 Mar 2026 12:02:56 +0200 Subject: [PATCH 16/28] Add allowKeyFiltersOrConditions to emptyUserAuthState initializer --- ui-ngx/src/app/core/auth/auth.reducer.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui-ngx/src/app/core/auth/auth.reducer.ts b/ui-ngx/src/app/core/auth/auth.reducer.ts index 51e02b1ab8..669c47fd78 100644 --- a/ui-ngx/src/app/core/auth/auth.reducer.ts +++ b/ui-ngx/src/app/core/auth/auth.reducer.ts @@ -42,7 +42,8 @@ const emptyUserAuthState: AuthPayload = { maxDebugModeDurationMinutes: 0, intermediateAggregationIntervalInSecForCF: 0, userSettings: initialUserSettings, - trendzSettings: initialTrendzSettings + trendzSettings: initialTrendzSettings, + allowKeyFiltersOrConditions: false }; export const initialState: AuthState = { From 2093da73e1ba8264f2fecfd41de6a9e7a342abc9 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 24 Mar 2026 12:21:35 +0200 Subject: [PATCH 17/28] Fix filter preview reactivity and keyFiltersOperation change detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FilterTextComponent: implement OnChanges to re-render preview text when the operation input changes (AND→OR or OR→AND) - isFilterEqual: include keyFiltersOperation in comparison so changing only the operation triggers widget subscription refresh --- .../home/components/filter/filter-text.component.ts | 12 ++++++++++-- ui-ngx/src/app/shared/models/query/query.models.ts | 3 ++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-text.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-text.component.ts index 94ef717912..75713c77e3 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-text.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-text.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, forwardRef, Input, OnInit } from '@angular/core'; +import { Component, forwardRef, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; import { ControlValueAccessor, UntypedFormBuilder, NG_VALUE_ACCESSOR } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { ComplexOperation, KeyFilter, keyFiltersToText } from '@shared/models/query/query.models'; @@ -35,7 +35,7 @@ import { coerceBooleanProperty } from '@angular/cdk/coercion'; ], standalone: false }) -export class FilterTextComponent implements ControlValueAccessor, OnInit { +export class FilterTextComponent implements ControlValueAccessor, OnInit, OnChanges { private requiredValue: boolean; get required(): boolean { @@ -65,6 +65,7 @@ export class FilterTextComponent implements ControlValueAccessor, OnInit { public filterText: string; + private currentValue: Array; private propagateChange = (v: any) => { }; constructor(private dialog: MatDialog, @@ -83,11 +84,18 @@ export class FilterTextComponent implements ControlValueAccessor, OnInit { ngOnInit() { } + ngOnChanges(changes: SimpleChanges) { + if (changes.operation && !changes.operation.firstChange) { + this.updateFilterText(this.currentValue); + } + } + setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; } writeValue(value: Array): void { + this.currentValue = value; this.updateFilterText(value); } diff --git a/ui-ngx/src/app/shared/models/query/query.models.ts b/ui-ngx/src/app/shared/models/query/query.models.ts index 6273fbd434..258f09a535 100644 --- a/ui-ngx/src/app/shared/models/query/query.models.ts +++ b/ui-ngx/src/app/shared/models/query/query.models.ts @@ -950,7 +950,8 @@ export const getFilterId = (filters: Filters, filterInfo: FilterInfo): string => } const isFilterEqual = (filter1: FilterInfo, filter2: FilterInfo): boolean => { - return isEqual(filter1.keyFilters, filter2.keyFilters); + return isEqual(filter1.keyFilters, filter2.keyFilters) && + filter1.keyFiltersOperation === filter2.keyFiltersOperation; } const createFilterName = (filters: Filters, filter: string): string => { From 47f26f136af5098cbe7c92e913b60d0232291905 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 24 Mar 2026 12:55:39 +0200 Subject: [PATCH 18/28] Fix keyFiltersOperation not persisted in dashboard configuration filters-dialog.component.ts was the bridge between filter-dialog (which sets keyFiltersOperation on the Filter object) and the dashboard configuration JSON. It was dropping the field in three places: - createFilterFormControl: form group had no keyFiltersOperation field - edit path: patched filter/editable/keyFilters but not operation - save(): rebuilt Filter objects without keyFiltersOperation --- .../home/components/filter/filters-dialog.component.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts index 8282c2cd21..46784b5309 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.ts @@ -37,7 +37,7 @@ import { TranslateService } from '@ngx-translate/core'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { DialogService } from '@core/services/dialog.service'; import { deepClone, isUndefined } from '@core/utils'; -import { Filter, Filters, KeyFilterInfo } from '@shared/models/query/query.models'; +import { ComplexOperation, Filter, Filters, KeyFilterInfo } from '@shared/models/query/query.models'; import { FilterDialogComponent, FilterDialogData } from '@home/components/filter/filter-dialog.component'; import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; @@ -132,7 +132,8 @@ export class FiltersDialogComponent extends DialogComponent = filterValue.keyFilters; const editable: boolean = filterValue.editable; + const keyFiltersOperation: ComplexOperation = filterValue.keyFiltersOperation; if (uniqueFilterList[filter]) { valid = false; message = this.translate.instant('filter.duplicate-filter-error', {filter}); @@ -263,7 +266,7 @@ export class FiltersDialogComponent extends DialogComponent Date: Tue, 24 Mar 2026 13:32:22 +0200 Subject: [PATCH 19/28] Move OR validation to BaseEntityService and fix toggle behavior - Move validateKeyFiltersOperation from DefaultEntityQueryService to BaseEntityService.validateEntityCountQuery so both REST and WebSocket paths are covered - Fix AND/OR toggle: use valueChanges listener to prevent OR selection when disabled (instead of [disabled] which broke the control entirely) - Force AND when OR is disabled and filter had OR saved - Update test to set flag on BaseEntityService --- .../query/DefaultEntityQueryService.java | 21 ++++++------------- .../controller/EntityQueryControllerTest.java | 7 +++++-- .../server/dao/entity/BaseEntityService.java | 14 +++++++++++-- .../filter/filter-dialog.component.html | 1 - .../filter/filter-dialog.component.ts | 11 +++++++++- 5 files changed, 33 insertions(+), 21 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java index 8da82a54d0..7758fe56e5 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java @@ -18,7 +18,7 @@ package org.thingsboard.server.service.query; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import lombok.Setter; + import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.springframework.beans.factory.annotation.Autowired; @@ -45,7 +45,6 @@ import org.thingsboard.server.common.data.query.AvailableEntityKeysV2; import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeyInfo; import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeySample; import org.thingsboard.server.common.data.query.ComplexFilterPredicate; -import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; @@ -95,10 +94,6 @@ public class DefaultEntityQueryService implements EntityQueryService { @Value("${server.ws.max_entities_per_alarm_subscription:1000}") private int maxEntitiesPerAlarmSubscription; - @Setter - @Value("${sql.query.key-filters-or-conditions.enabled:true}") - private boolean keyFiltersOrConditionsEnabled; - @Autowired private DbCallbackExecutorService dbCallbackExecutor; @@ -110,13 +105,13 @@ public class DefaultEntityQueryService implements EntityQueryService { @Override public long countEntitiesByQuery(SecurityUser securityUser, EntityCountQuery query) { - validateKeyFiltersOperation(query); + return entityService.countEntitiesByQuery(securityUser.getTenantId(), securityUser.getCustomerId(), query); } @Override public PageData findEntityDataByQuery(SecurityUser securityUser, EntityDataQuery query) { - validateKeyFiltersOperation(query); + if (query.getKeyFilters() != null) { resolveDynamicValuesInPredicates( query.getKeyFilters().stream() @@ -186,7 +181,7 @@ public class DefaultEntityQueryService implements EntityQueryService { @Override public PageData findAlarmDataByQuery(SecurityUser securityUser, AlarmDataQuery query) { - validateKeyFiltersOperation(query); + EntityDataQuery entityDataQuery = this.buildEntityDataQuery(query); PageData entities = entityService.findEntityDataByQuery(securityUser.getTenantId(), securityUser.getCustomerId(), entityDataQuery); @@ -213,7 +208,7 @@ public class DefaultEntityQueryService implements EntityQueryService { @Override public long countAlarmsByQuery(SecurityUser securityUser, AlarmCountQuery query) { - validateKeyFiltersOperation(query); + if (query.getEntityFilter() != null) { EntityDataQuery entityDataQuery = this.buildEntityDataQuery(query); PageData entities = entityService.findEntityDataByQuery(securityUser.getTenantId(), @@ -246,11 +241,7 @@ public class DefaultEntityQueryService implements EntityQueryService { return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperationOrDefault()); } - private void validateKeyFiltersOperation(EntityCountQuery query) { - if (!keyFiltersOrConditionsEnabled && query.getKeyFiltersOperation() == ComplexOperation.OR) { - throw new IllegalArgumentException("OR conditions between key filters are disabled"); - } - } + @Override public ListenableFuture getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query, diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java index bf9762b208..d8081f60dd 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -90,6 +90,7 @@ import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; +import org.thingsboard.server.dao.entity.BaseEntityService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.edqs.util.EdqsRocksDb; @@ -127,6 +128,8 @@ public class EntityQueryControllerTest extends AbstractControllerTest { private QueueStatsService queueStatsService; @Autowired private DefaultEntityQueryService entityQueryService; + @Autowired + private BaseEntityService baseEntityService; @MockitoBean private EdqsRocksDb edqsRocksDb; @@ -2495,7 +2498,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { public void testOrKeyFiltersOperationRejectedWhenDisabled() throws Exception { loginTenantAdmin(); - entityQueryService.setKeyFiltersOrConditionsEnabled(false); + baseEntityService.setKeyFiltersOrConditionsEnabled(false); try { DeviceTypeFilter filter = new DeviceTypeFilter(); filter.setDeviceTypes(List.of("default")); @@ -2516,7 +2519,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { EntityCountQuery explicitAndQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.AND); doPost("/api/entitiesQuery/count", explicitAndQuery).andExpect(status().isOk()); } finally { - entityQueryService.setKeyFiltersOrConditionsEnabled(true); + baseEntityService.setKeyFiltersOrConditionsEnabled(true); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java index e1b3b084ec..98a1113157 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java @@ -19,8 +19,10 @@ import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; @@ -40,6 +42,7 @@ import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.NameLabelAndCustomerDetails; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataPageLink; @@ -91,6 +94,10 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe private static final Set EXCLUDED_TYPES_FROM_OPTIMIZATION = Set.of( EntityFilterType.ENTITY_LIST, EntityFilterType.SINGLE_ENTITY, EntityFilterType.RELATIONS_QUERY); + @Setter + @Value("${sql.query.key-filters-or-conditions.enabled:true}") + private boolean keyFiltersOrConditionsEnabled; + @Autowired private EntityQueryDao entityQueryDao; @@ -328,7 +335,7 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe return new NameLabelAndCustomerDetails(getName(entity), getLabel(entity), getCustomerId(entity)); } - private static void validateEntityCountQuery(EntityCountQuery query) { + private void validateEntityCountQuery(EntityCountQuery query) { if (query == null) { throw new IncorrectParameterException("Query must be specified."); } else if (query.getEntityFilter() == null) { @@ -342,9 +349,12 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe } else if (query.getEntityFilter().getType().equals(ENTITY_NAME)) { validateEntityNameQuery((EntityNameFilter) query.getEntityFilter()); } + if (!keyFiltersOrConditionsEnabled && query.getKeyFiltersOperation() == ComplexOperation.OR) { + throw new IncorrectParameterException("OR conditions between key filters are disabled"); + } } - private static void validateEntityDataQuery(EntityDataQuery query) { + private void validateEntityDataQuery(EntityDataQuery query) { validateEntityCountQuery(query); validateEntityDataPageLink(query.getPageLink()); } diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html index 581dd42c93..6865bfd9c3 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html @@ -51,7 +51,6 @@
filter.key-filters
{{ complexOperationTranslationMap.get(ComplexOperation.AND) | translate }} diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts index 465b87b4b1..b77a951984 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts @@ -94,12 +94,21 @@ export class FilterDialogComponent extends DialogComponent { + if (value === ComplexOperation.OR) { + this.filterFormGroup.get('keyFiltersOperation').setValue(ComplexOperation.AND, {emitEvent: false}); + } + }); + } } validateDuplicateFilterName(): ValidatorFn { From 2a54290db1aabfd3237afb74629c6345c0775a35 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 24 Mar 2026 14:08:40 +0200 Subject: [PATCH 20/28] Mark form dirty when OR is force-reset to AND When OR is disabled but a filter had OR saved, the reset to AND now marks the form dirty so the Update button is enabled and the user can save the corrected value. --- .../home/components/filter/filter-dialog.component.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts index b77a951984..10c3941ee6 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts @@ -94,18 +94,20 @@ export class FilterDialogComponent extends DialogComponent { if (value === ComplexOperation.OR) { - this.filterFormGroup.get('keyFiltersOperation').setValue(ComplexOperation.AND, {emitEvent: false}); + this.filterFormGroup.get('keyFiltersOperation').setValue(ComplexOperation.AND); } }); } From 2c639d534b3f0b6c98f6e4893f5cc2581a56f9c5 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 24 Mar 2026 14:12:57 +0200 Subject: [PATCH 21/28] Fix toggle disabled behavior via form control API Disable the keyFiltersOperation form control when OR is not allowed. This uses Angular's reactive form disabled state which properly propagates through setDisabledState to tb-toggle-select, greying out the entire toggle. If the filter had OR saved, it resets to AND and marks the form dirty so Update is enabled. The tooltip on hover explains why the toggle is disabled. --- .../components/filter/filter-dialog.component.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts index 10c3941ee6..334ce90931 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts @@ -100,16 +100,12 @@ export class FilterDialogComponent extends DialogComponent { - if (value === ComplexOperation.OR) { - this.filterFormGroup.get('keyFiltersOperation').setValue(ComplexOperation.AND); - } - }); + if (this.filter.keyFiltersOperation === ComplexOperation.OR) { + this.filterFormGroup.get('keyFiltersOperation').setValue(ComplexOperation.AND); + this.filterFormGroup.markAsDirty(); + } + this.filterFormGroup.get('keyFiltersOperation').disable(); } } From 46853ef7b8fc6f2f633a4e6490946f543a608bf7 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 24 Mar 2026 15:12:46 +0200 Subject: [PATCH 22/28] Address PR review comments and add missing test coverage - Wrap entityKeyColumn null-swap in try/finally in EntityKeyMapping - Remove @Setter from BaseEntityService, use ReflectionTestUtils in test - Replace Thread.sleep with awaitility in all OR test methods - Fix confusing temperature comment in pagination test - Add test for OR with textSearch (middle-layer WHERE logic) - Add test for OR with TIME_SERIES key filters - Add test for OR with ComplexFilterPredicate within key filter - Add EDQS tests for STRING/entity-field with OR (including null special case) --- .../controller/EntityQueryControllerTest.java | 273 +++++++++++++++--- .../server/dao/entity/BaseEntityService.java | 2 - .../dao/sql/query/EntityKeyMapping.java | 15 +- .../server/edqs/repo/RepositoryUtilsTest.java | 45 +++ 4 files changed, 288 insertions(+), 47 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java index d8081f60dd..46ebf31f9d 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -28,6 +28,7 @@ import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.ResultActions; import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; @@ -62,6 +63,7 @@ import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.AvailableEntityKeysV2; import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeyInfo; +import org.thingsboard.server.common.data.query.ComplexFilterPredicate; import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.DynamicValue; @@ -94,7 +96,6 @@ import org.thingsboard.server.dao.entity.BaseEntityService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.edqs.util.EdqsRocksDb; -import org.thingsboard.server.service.query.DefaultEntityQueryService; import java.util.ArrayList; import java.util.Arrays; @@ -127,8 +128,6 @@ public class EntityQueryControllerTest extends AbstractControllerTest { @Autowired private QueueStatsService queueStatsService; @Autowired - private DefaultEntityQueryService entityQueryService; - @Autowired private BaseEntityService baseEntityService; @MockitoBean @@ -1762,8 +1761,6 @@ public class EntityQueryControllerTest extends AbstractControllerTest { String payloadC = "{\"temperature\":30}"; doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE, payloadC, String.class, status().isOk()); - Thread.sleep(1000); - DeviceTypeFilter filter = new DeviceTypeFilter(); filter.setDeviceTypes(List.of("orTestType")); filter.setDeviceNameFilter(""); @@ -1790,7 +1787,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { // OR: deviceA (60>50) and deviceB (5<10) match => count=2 EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); - countByQueryAndCheck(orQuery, 2); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); // AND: no device has temperature both >50 AND <10 => count=0 EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); @@ -1820,8 +1817,6 @@ public class EntityQueryControllerTest extends AbstractControllerTest { deviceZ = doPost("/api/device", deviceZ, Device.class); // deviceZ has neither matching attribute - Thread.sleep(1000); - DeviceTypeFilter filter = new DeviceTypeFilter(); filter.setDeviceTypes(List.of("orDataType")); filter.setDeviceNameFilter(""); @@ -1854,6 +1849,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { // OR: deviceX matches status=active, deviceY matches humidity>70 EntityDataQuery orQuery = new EntityDataQuery(filter, pageLink, entityFields, null, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2)); PageData result = findByQueryAndCheck(orQuery, 2); List names = result.getData().stream() .map(e -> e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()) @@ -1878,8 +1874,6 @@ public class EntityQueryControllerTest extends AbstractControllerTest { doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, "{\"temperature\":5}", String.class, status().isOk()); - Thread.sleep(1000); - DeviceTypeFilter filter = new DeviceTypeFilter(); filter.setDeviceTypes(List.of("orSameKeyType")); filter.setDeviceNameFilter(""); @@ -1912,6 +1906,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { // OR on same key: deviceA (60>50) and deviceB (5<10) should both be returned EntityDataQuery orQuery = new EntityDataQuery(filter, pageLink, entityFields, null, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2)); PageData result = findByQueryAndCheck(orQuery, 2); List names = result.getData().stream() .map(e -> e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()) @@ -1936,8 +1931,6 @@ public class EntityQueryControllerTest extends AbstractControllerTest { doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, "{\"temperature\":5}", String.class, status().isOk()); - Thread.sleep(1000); - DeviceTypeFilter filter = new DeviceTypeFilter(); filter.setDeviceTypes(List.of("backCompatType")); filter.setDeviceNameFilter(""); @@ -1962,6 +1955,10 @@ public class EntityQueryControllerTest extends AbstractControllerTest { List keyFilters = List.of(tempGt50, tempLt10); + // Await attribute propagation: verify with an OR query that should find 2 when propagated + EntityCountQuery orCheckQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orCheckQuery, 2)); + // Query without keyFiltersOperation (null) -- should behave as AND EntityCountQuery nullOpQuery = new EntityCountQuery(filter, keyFilters); Long nullResult = countByQueryAndCheck(nullOpQuery, 0); @@ -1999,8 +1996,6 @@ public class EntityQueryControllerTest extends AbstractControllerTest { doPost("/api/plugins/telemetry/" + deviceMid.getId() + "/" + DataConstants.SHARED_SCOPE, "{\"temperature\":30}", String.class, status().isOk()); - Thread.sleep(500); - // Create alarms for each device Alarm alarmHot = new Alarm(); alarmHot.setOriginator(deviceHot.getId()); @@ -2020,8 +2015,6 @@ public class EntityQueryControllerTest extends AbstractControllerTest { alarmMid.setSeverity(AlarmSeverity.WARNING); doPost("/api/alarm", alarmMid, Alarm.class); - Thread.sleep(500); - // Filter 1: temperature > 50 KeyFilter tempGt50 = new KeyFilter(); tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); @@ -2055,6 +2048,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { // OR query: should return alarms for deviceHot (60>50) and deviceCold (5<10) = 2 alarms AlarmDataQuery orAlarmQuery = new AlarmDataQuery(entityFilter, pageLink, null, null, keyFilters, alarmFields, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findAlarmsByQueryAndCheck(orAlarmQuery, 2)); PageData alarmResult = findAlarmsByQueryAndCheck(orAlarmQuery, 2); List alarmTypes = alarmResult.getData().stream().map(AlarmData::getType).collect(Collectors.toList()); assertThat(alarmTypes).containsExactlyInAnyOrder("highTemp", "lowTemp"); @@ -2089,8 +2083,6 @@ public class EntityQueryControllerTest extends AbstractControllerTest { doPost("/api/plugins/telemetry/" + deviceMid.getId() + "/" + DataConstants.SHARED_SCOPE, "{\"temperature\":30}", String.class, status().isOk()); - Thread.sleep(500); - // Create 2 alarms for deviceHot, 1 for deviceCold, 1 for deviceMid Alarm alarm1 = new Alarm(); alarm1.setOriginator(deviceHot.getId()); @@ -2116,8 +2108,6 @@ public class EntityQueryControllerTest extends AbstractControllerTest { alarm4.setSeverity(AlarmSeverity.WARNING); doPost("/api/alarm", alarm4, Alarm.class); - Thread.sleep(500); - KeyFilter tempGt50 = new KeyFilter(); tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); tempGt50.setValueType(EntityKeyValueType.NUMERIC); @@ -2142,7 +2132,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { // OR: deviceHot (2 alarms) + deviceCold (1 alarm) match => 3 alarms total AlarmCountQuery orQuery = new AlarmCountQuery(entityFilter, keyFilters, ComplexOperation.OR); - countAlarmsByQueryAndCheck(orQuery, 3); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countAlarmsByQueryAndCheck(orQuery, 3)); // AND: no device matches both filters => 0 AlarmCountQuery andQuery = new AlarmCountQuery(entityFilter, keyFilters, ComplexOperation.AND); @@ -2173,8 +2163,6 @@ public class EntityQueryControllerTest extends AbstractControllerTest { doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE, "{\"temperature\":10}", String.class, status().isOk()); - Thread.sleep(1000); - DeviceTypeFilter filter = new DeviceTypeFilter(); filter.setDeviceTypes(List.of("orMixedType")); filter.setDeviceNameFilter(""); @@ -2201,7 +2189,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { // OR: deviceA matches name contains "Alpha", deviceB matches temp>50 => count=2 EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); - countByQueryAndCheck(orQuery, 2); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); // AND: only deviceA has name "Alpha" AND temp is 10 (not >50) => count=0 EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); @@ -2231,8 +2219,6 @@ public class EntityQueryControllerTest extends AbstractControllerTest { doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE, "{\"color\":\"green\"}", String.class, status().isOk()); - Thread.sleep(1000); - DeviceTypeFilter filter = new DeviceTypeFilter(); filter.setDeviceTypes(List.of("orStrType")); filter.setDeviceNameFilter(""); @@ -2259,7 +2245,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { // OR: deviceA (red) and deviceB (blue) match => count=2 EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); - countByQueryAndCheck(orQuery, 2); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); // AND: no device is both red AND blue => count=0 EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); @@ -2282,8 +2268,6 @@ public class EntityQueryControllerTest extends AbstractControllerTest { doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, "{\"temperature\":30}", String.class, status().isOk()); - Thread.sleep(1000); - DeviceTypeFilter filter = new DeviceTypeFilter(); filter.setDeviceTypes(List.of("orSingleType")); filter.setDeviceNameFilter(""); @@ -2300,6 +2284,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { // Single filter with OR should behave identically to AND EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 1)); Long orResult = countByQueryAndCheck(orQuery, 1); EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); @@ -2338,8 +2323,6 @@ public class EntityQueryControllerTest extends AbstractControllerTest { doPost("/api/plugins/telemetry/" + deviceD.getId() + "/" + DataConstants.SHARED_SCOPE, "{\"temperature\":20,\"humidity\":50,\"pressure\":1000}", String.class, status().isOk()); - Thread.sleep(1000); - DeviceTypeFilter filter = new DeviceTypeFilter(); filter.setDeviceTypes(List.of("or3fType")); filter.setDeviceNameFilter(""); @@ -2375,7 +2358,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { // OR: A matches temp>50, B matches hum>80, C matches press>1040, D matches none => 3 EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); - countByQueryAndCheck(orQuery, 3); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 3)); // AND: no device matches all three => 0 EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); @@ -2385,19 +2368,17 @@ public class EntityQueryControllerTest extends AbstractControllerTest { @Test public void testFindEntityDataWithOrPagination() throws Exception { // Create 5 devices, 4 match OR filters (1,2: temp>50; 3,4: temp<10; 5: no match) + // Device 1: temp=61, Device 2: temp=62, Device 3: temp=2, Device 4: temp=1, Device 5: temp=25 for (int i = 1; i <= 5; i++) { Device device = new Device(); device.setName(String.format("OrPageDevice%02d", i)); device.setType("orPageType"); device = doPost("/api/device", device, Device.class); - // Devices 1,2: temperature > 50. Device 3,4: temperature < 10. Device 5: no match (25). int temp = (i <= 2) ? 60 + i : (i <= 4) ? 5 - i : 25; doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE, "{\"temperature\":" + temp + "}", String.class, status().isOk()); } - Thread.sleep(1000); - DeviceTypeFilter filter = new DeviceTypeFilter(); filter.setDeviceTypes(List.of("orPageType")); filter.setDeviceNameFilter(""); @@ -2428,6 +2409,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { // Page 1: pageSize=2, totalElements=4, data.size()=2 EntityDataPageLink pageLink1 = new EntityDataPageLink(2, 0, null, sortOrder); EntityDataQuery orQuery1 = new EntityDataQuery(filter, pageLink1, entityFields, null, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery1, 4)); PageData page1 = findByQueryAndCheck(orQuery1, 4); Assert.assertEquals(2, page1.getData().size()); Assert.assertTrue(page1.hasNext()); @@ -2463,12 +2445,21 @@ public class EntityQueryControllerTest extends AbstractControllerTest { doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, "{\"temperature\":40}", String.class, status().isOk()); - Thread.sleep(1000); - DeviceTypeFilter filter = new DeviceTypeFilter(); filter.setDeviceTypes(List.of("orZeroType")); filter.setDeviceNameFilter(""); + // Await attribute propagation: verify devices are queryable by a filter that matches + KeyFilter tempGt20 = new KeyFilter(); + tempGt20.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempGt20.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate gt20 = new NumericFilterPredicate(); + gt20.setValue(FilterPredicateValue.fromDouble(20)); + gt20.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + tempGt20.setPredicate(gt20); + EntityCountQuery propagationCheck = new EntityCountQuery(filter, List.of(tempGt20), ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(propagationCheck, 2)); + // Filter 1: temperature > 50 (no match) KeyFilter tempGt50 = new KeyFilter(); tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); @@ -2498,7 +2489,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { public void testOrKeyFiltersOperationRejectedWhenDisabled() throws Exception { loginTenantAdmin(); - baseEntityService.setKeyFiltersOrConditionsEnabled(false); + ReflectionTestUtils.setField(baseEntityService, "keyFiltersOrConditionsEnabled", false); try { DeviceTypeFilter filter = new DeviceTypeFilter(); filter.setDeviceTypes(List.of("default")); @@ -2519,8 +2510,212 @@ public class EntityQueryControllerTest extends AbstractControllerTest { EntityCountQuery explicitAndQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.AND); doPost("/api/entitiesQuery/count", explicitAndQuery).andExpect(status().isOk()); } finally { - baseEntityService.setKeyFiltersOrConditionsEnabled(true); + ReflectionTestUtils.setField(baseEntityService, "keyFiltersOrConditionsEnabled", true); } } + @Test + public void testFindEntityDataWithOrAndTextSearch() throws Exception { + // Create 3 devices: 2 match OR filters, but only 1 also matches textSearch + Device deviceA = new Device(); + deviceA.setName("OrTextAlpha"); + deviceA.setType("orTextType"); + deviceA = doPost("/api/device", deviceA, Device.class); + doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":60}", String.class, status().isOk()); + + Device deviceB = new Device(); + deviceB.setName("OrTextBeta"); + deviceB.setType("orTextType"); + deviceB = doPost("/api/device", deviceB, Device.class); + doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":5}", String.class, status().isOk()); + + Device deviceC = new Device(); + deviceC.setName("OrTextGamma"); + deviceC.setType("orTextType"); + deviceC = doPost("/api/device", deviceC, Device.class); + doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":30}", String.class, status().isOk()); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("orTextType")); + filter.setDeviceNameFilter(""); + + // Filter 1: temperature > 50 (matches deviceA) + KeyFilter tempGt50 = new KeyFilter(); + tempGt50.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempGt50.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate gt50 = new NumericFilterPredicate(); + gt50.setValue(FilterPredicateValue.fromDouble(50)); + gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + tempGt50.setPredicate(gt50); + + // Filter 2: temperature < 10 (matches deviceB) + KeyFilter tempLt10 = new KeyFilter(); + tempLt10.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempLt10.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate lt10 = new NumericFilterPredicate(); + lt10.setValue(FilterPredicateValue.fromDouble(10)); + lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS); + tempLt10.setPredicate(lt10); + + List keyFilters = List.of(tempGt50, tempLt10); + + EntityDataSortOrder sortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC + ); + List entityFields = Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + + // OR without textSearch: deviceA and deviceB match => 2 + EntityDataPageLink pageLinkNoText = new EntityDataPageLink(10, 0, null, sortOrder); + EntityDataQuery orQueryNoText = new EntityDataQuery(filter, pageLinkNoText, entityFields, null, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQueryNoText, 2)); + + // OR with textSearch="Alpha": only deviceA matches both OR filter AND text search + EntityDataPageLink pageLinkWithText = new EntityDataPageLink(10, 0, "Alpha", sortOrder); + EntityDataQuery orQueryWithText = new EntityDataQuery(filter, pageLinkWithText, entityFields, null, keyFilters, ComplexOperation.OR); + PageData result = findByQueryAndCheck(orQueryWithText, 1); + String name = result.getData().get(0).getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue(); + assertThat(name).isEqualTo("OrTextAlpha"); + + // OR with textSearch="Beta": only deviceB matches both OR filter AND text search + EntityDataPageLink pageLinkBeta = new EntityDataPageLink(10, 0, "Beta", sortOrder); + EntityDataQuery orQueryBeta = new EntityDataQuery(filter, pageLinkBeta, entityFields, null, keyFilters, ComplexOperation.OR); + PageData resultBeta = findByQueryAndCheck(orQueryBeta, 1); + String nameBeta = resultBeta.getData().get(0).getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue(); + assertThat(nameBeta).isEqualTo("OrTextBeta"); + + // OR with textSearch="Gamma": deviceC doesn't match any OR filter => 0 + EntityDataPageLink pageLinkGamma = new EntityDataPageLink(10, 0, "Gamma", sortOrder); + EntityDataQuery orQueryGamma = new EntityDataQuery(filter, pageLinkGamma, entityFields, null, keyFilters, ComplexOperation.OR); + findByQueryAndCheck(orQueryGamma, 0); + } + + @Test + public void testCountEntitiesWithOrTimeSeriesKeyFilters() throws Exception { + // Create devices and post time-series telemetry (not attributes) + Device deviceA = new Device(); + deviceA.setName("OrTsDeviceA"); + deviceA.setType("orTsType"); + deviceA = doPost("/api/device", deviceA, Device.class); + JsonNode tsPayloadA = JacksonUtil.toJsonNode("{\"temperature\": 60}"); + doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + deviceA.getUuidId() + "/timeseries/SERVER_SCOPE", tsPayloadA) + .andExpect(status().isOk()); + + Device deviceB = new Device(); + deviceB.setName("OrTsDeviceB"); + deviceB.setType("orTsType"); + deviceB = doPost("/api/device", deviceB, Device.class); + JsonNode tsPayloadB = JacksonUtil.toJsonNode("{\"temperature\": 5}"); + doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + deviceB.getUuidId() + "/timeseries/SERVER_SCOPE", tsPayloadB) + .andExpect(status().isOk()); + + Device deviceC = new Device(); + deviceC.setName("OrTsDeviceC"); + deviceC.setType("orTsType"); + deviceC = doPost("/api/device", deviceC, Device.class); + JsonNode tsPayloadC = JacksonUtil.toJsonNode("{\"temperature\": 30}"); + doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + deviceC.getUuidId() + "/timeseries/SERVER_SCOPE", tsPayloadC) + .andExpect(status().isOk()); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("orTsType")); + filter.setDeviceNameFilter(""); + + // Filter 1: TIME_SERIES temperature > 50 + KeyFilter tempGt50 = new KeyFilter(); + tempGt50.setKey(new EntityKey(EntityKeyType.TIME_SERIES, "temperature")); + tempGt50.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate gt50 = new NumericFilterPredicate(); + gt50.setValue(FilterPredicateValue.fromDouble(50)); + gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + tempGt50.setPredicate(gt50); + + // Filter 2: TIME_SERIES temperature < 10 + KeyFilter tempLt10 = new KeyFilter(); + tempLt10.setKey(new EntityKey(EntityKeyType.TIME_SERIES, "temperature")); + tempLt10.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate lt10 = new NumericFilterPredicate(); + lt10.setValue(FilterPredicateValue.fromDouble(10)); + lt10.setOperation(NumericFilterPredicate.NumericOperation.LESS); + tempLt10.setPredicate(lt10); + + List keyFilters = List.of(tempGt50, tempLt10); + + // OR: deviceA (60>50) and deviceB (5<10) match => count=2 + EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); + + // AND: no device has ts temperature both >50 AND <10 => count=0 + EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); + countByQueryAndCheck(andQuery, 0); + } + + @Test + public void testCountEntitiesWithOrComplexFilterPredicate() throws Exception { + // Tests key-level ComplexFilterPredicate combined with query-level OR + Device deviceA = new Device(); + deviceA.setName("OrCplxDeviceA"); + deviceA.setType("orCplxType"); + deviceA = doPost("/api/device", deviceA, Device.class); + doPost("/api/plugins/telemetry/" + deviceA.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":65,\"humidity\":50}", String.class, status().isOk()); + + Device deviceB = new Device(); + deviceB.setName("OrCplxDeviceB"); + deviceB.setType("orCplxType"); + deviceB = doPost("/api/device", deviceB, Device.class); + doPost("/api/plugins/telemetry/" + deviceB.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":25,\"humidity\":90}", String.class, status().isOk()); + + Device deviceC = new Device(); + deviceC.setName("OrCplxDeviceC"); + deviceC.setType("orCplxType"); + deviceC = doPost("/api/device", deviceC, Device.class); + doPost("/api/plugins/telemetry/" + deviceC.getId() + "/" + DataConstants.SHARED_SCOPE, + "{\"temperature\":25,\"humidity\":50}", String.class, status().isOk()); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("orCplxType")); + filter.setDeviceNameFilter(""); + + // Key filter 1: temperature > 50 AND temperature < 70 (complex predicate within key filter) + // Matches deviceA (65) only + NumericFilterPredicate gt50 = new NumericFilterPredicate(); + gt50.setValue(FilterPredicateValue.fromDouble(50)); + gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + NumericFilterPredicate lt70 = new NumericFilterPredicate(); + lt70.setValue(FilterPredicateValue.fromDouble(70)); + lt70.setOperation(NumericFilterPredicate.NumericOperation.LESS); + ComplexFilterPredicate complexTempPred = new ComplexFilterPredicate(); + complexTempPred.setOperation(ComplexOperation.AND); + complexTempPred.setPredicates(List.of(gt50, lt70)); + + KeyFilter tempComplexFilter = new KeyFilter(); + tempComplexFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempComplexFilter.setValueType(EntityKeyValueType.NUMERIC); + tempComplexFilter.setPredicate(complexTempPred); + + // Key filter 2: humidity > 80 (simple predicate) + // Matches deviceB (90) only + KeyFilter humFilter = new KeyFilter(); + humFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "humidity")); + humFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate humGt80 = new NumericFilterPredicate(); + humGt80.setValue(FilterPredicateValue.fromDouble(80)); + humGt80.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + humFilter.setPredicate(humGt80); + + List keyFilters = List.of(tempComplexFilter, humFilter); + + // Query-level OR: deviceA matches key filter 1, deviceB matches key filter 2 => 2 + EntityCountQuery orQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); + + // Query-level AND: no device matches both key filters => 0 + EntityCountQuery andQuery = new EntityCountQuery(filter, keyFilters, ComplexOperation.AND); + countByQueryAndCheck(andQuery, 0); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java index 98a1113157..7367d43910 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java @@ -19,7 +19,6 @@ import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; -import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -94,7 +93,6 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe private static final Set EXCLUDED_TYPES_FROM_OPTIMIZATION = Set.of( EntityFilterType.ENTITY_LIST, EntityFilterType.SINGLE_ENTITY, EntityFilterType.RELATIONS_QUERY); - @Setter @Value("${sql.query.key-filters-or-conditions.enabled:true}") private boolean keyFiltersOrConditionsEnabled; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java index 28fa6bec0f..100c733df6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java @@ -297,12 +297,15 @@ public class EntityKeyMapping { // "cast(e.name as varchar) as alias2"). Temporarily null out entityKeyColumn // so buildSimplePredicateQuery uses the alias directly as the field. String savedColumn = this.entityKeyColumn; - this.entityKeyColumn = null; - List predicates = keyFilters.stream() - .map(keyFilter -> this.buildKeyQuery(ctx, alias, keyFilter, filterType)) - .collect(Collectors.toList()); - this.entityKeyColumn = savedColumn; - return predicates.stream(); + try { + this.entityKeyColumn = null; + List predicates = keyFilters.stream() + .map(keyFilter -> this.buildKeyQuery(ctx, alias, keyFilter, filterType)) + .collect(Collectors.toList()); + return predicates.stream(); + } finally { + this.entityKeyColumn = savedColumn; + } } else { keyAlias = "e"; } diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java index a7846c0674..ab1b6c4060 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java @@ -585,4 +585,49 @@ public class RepositoryUtilsTest { assertThat(RepositoryUtils.checkKeyFilters(deviceData, Collections.emptyList(), ComplexOperation.OR)).isTrue(); } + @Test + public void testCheckKeyFiltersWithOrStringEntityFieldFilters() { + // Entity with name="loranet device 123" + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("loranet device 123").build()); + + // Filter 1: name STARTS_WITH "mqtt" (no match) + EdqsFilter nameStartsMqtt = getStringEntityFieldFilter("name", StringOperation.STARTS_WITH, "mqtt"); + // Filter 2: name CONTAINS "lora" (match) + EdqsFilter nameContainsLora = getStringEntityFieldFilter("name", StringOperation.CONTAINS, "lora"); + + // OR: second filter matches + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(nameStartsMqtt, nameContainsLora), ComplexOperation.OR)).isTrue(); + + // AND: first filter fails + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(nameStartsMqtt, nameContainsLora), ComplexOperation.AND)).isFalse(); + } + + @Test + public void testCheckKeyFiltersWithOrEntityFieldNullStringSpecialCase() { + // Entity with name=null — tests the special case where null string + entity field = match + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name(null).build()); + + // Filter on entity field "label" which is not set (null) + EdqsFilter labelFilter = getStringEntityFieldFilter("label", StringOperation.STARTS_WITH, "test"); + // Filter on timeseries (non-entity-field) that doesn't exist + EdqsFilter tempFilter = getTemperatureFilter(NumericOperation.GREATER, 50); + + // Entity field with null string = match (special case in evaluateSingleFilter) + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter), ComplexOperation.OR)).isTrue(); + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter), ComplexOperation.AND)).isTrue(); + + // Non-entity-field with missing data = no match + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempFilter), ComplexOperation.OR)).isFalse(); + + // OR: entity field null match + missing timeseries = true (first matches) + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter, tempFilter), ComplexOperation.OR)).isTrue(); + + // AND: entity field null match + missing timeseries = false (second fails) + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter, tempFilter), ComplexOperation.AND)).isFalse(); + } + } From f7b79f8dc677c1adf71be767f5e3ae4ee84fd30b Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Wed, 25 Mar 2026 09:56:22 +0200 Subject: [PATCH 23/28] Cleanup --- .../server/service/query/DefaultEntityQueryService.java | 7 ------- 1 file changed, 7 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java index 7758fe56e5..92d76693f9 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java @@ -18,7 +18,6 @@ package org.thingsboard.server.service.query; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; - import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.springframework.beans.factory.annotation.Autowired; @@ -105,13 +104,11 @@ public class DefaultEntityQueryService implements EntityQueryService { @Override public long countEntitiesByQuery(SecurityUser securityUser, EntityCountQuery query) { - return entityService.countEntitiesByQuery(securityUser.getTenantId(), securityUser.getCustomerId(), query); } @Override public PageData findEntityDataByQuery(SecurityUser securityUser, EntityDataQuery query) { - if (query.getKeyFilters() != null) { resolveDynamicValuesInPredicates( query.getKeyFilters().stream() @@ -181,7 +178,6 @@ public class DefaultEntityQueryService implements EntityQueryService { @Override public PageData findAlarmDataByQuery(SecurityUser securityUser, AlarmDataQuery query) { - EntityDataQuery entityDataQuery = this.buildEntityDataQuery(query); PageData entities = entityService.findEntityDataByQuery(securityUser.getTenantId(), securityUser.getCustomerId(), entityDataQuery); @@ -208,7 +204,6 @@ public class DefaultEntityQueryService implements EntityQueryService { @Override public long countAlarmsByQuery(SecurityUser securityUser, AlarmCountQuery query) { - if (query.getEntityFilter() != null) { EntityDataQuery entityDataQuery = this.buildEntityDataQuery(query); PageData entities = entityService.findEntityDataByQuery(securityUser.getTenantId(), @@ -241,8 +236,6 @@ public class DefaultEntityQueryService implements EntityQueryService { return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperationOrDefault()); } - - @Override public ListenableFuture getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query, boolean isTimeseries, boolean isAttributes, AttributeScope scope) { From ae58a58a6a3995dafadac36df5a098e8b38bdd8f Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Tue, 31 Mar 2026 16:59:04 +0300 Subject: [PATCH 24/28] Refactor filter dialog components: typed forms, modern Angular patterns, UI improvements - Migrate filter-dialog, key-filter-list, user-filter-dialog to typed reactive forms - Replace mat-expansion-panel with tb-form-panel stroked layout in key-filter-list - Move AND/OR toggle-select into key-filter-list via ng-content projection - Replace editable slide-toggle with tb-rounded-btn toggle button - Migrate to @if/@for/@switch control flow and takeUntilDestroyed - Remove unnecessary ErrorStateMatcher/submitted from dialog components - Simplify keyFiltersToText operation parameter and alias-controller filter resolution - Fix filter-text ngOnChanges to check value equality - Fix user-filter-dialog: pass label to tb-datetime, fix tbTruncateWithTooltip in slide-toggle --- ui-ngx/src/app/core/api/alias-controller.ts | 10 +- .../app/core/api/entity-data-subscription.ts | 1 - .../filter/filter-dialog.component.html | 81 +++++----- .../filter/filter-dialog.component.scss | 11 -- .../filter/filter-dialog.component.ts | 81 +++++----- .../filter/filter-text.component.ts | 56 +++---- .../filter/key-filter-list.component.html | 144 +++++++++--------- .../filter/key-filter-list.component.scss | 51 ++++--- .../filter/key-filter-list.component.ts | 122 +++++++-------- .../filter/user-filter-dialog.component.html | 77 +++++----- .../filter/user-filter-dialog.component.scss | 6 +- .../filter/user-filter-dialog.component.ts | 68 ++++----- .../components/time/datetime.component.ts | 2 +- .../app/shared/models/query/query.models.ts | 3 +- 14 files changed, 333 insertions(+), 380 deletions(-) diff --git a/ui-ngx/src/app/core/api/alias-controller.ts b/ui-ngx/src/app/core/api/alias-controller.ts index d48bcb574b..de6863ee56 100644 --- a/ui-ngx/src/app/core/api/alias-controller.ts +++ b/ui-ngx/src/app/core/api/alias-controller.ts @@ -314,10 +314,14 @@ export class AliasController implements IAliasController { || newDatasource.type === DatasourceType.entityCount || newDatasource.type === DatasourceType.alarmCount) { if (newDatasource.filterId) { - newDatasource.keyFilters = this.getKeyFilters(newDatasource.filterId); const filterInfo = this.getFilterInfo(newDatasource.filterId); - if (filterInfo?.keyFiltersOperation) { - newDatasource.keyFiltersOperation = filterInfo.keyFiltersOperation; + if (filterInfo) { + newDatasource.keyFilters = filterInfoToKeyFilters(filterInfo); + if (filterInfo.keyFiltersOperation) { + newDatasource.keyFiltersOperation = filterInfo.keyFiltersOperation; + } + } else { + newDatasource.keyFilters = []; } } if (newDatasource.type === DatasourceType.alarmCount) { diff --git a/ui-ngx/src/app/core/api/entity-data-subscription.ts b/ui-ngx/src/app/core/api/entity-data-subscription.ts index e367ae8faa..6f7949d259 100644 --- a/ui-ngx/src/app/core/api/entity-data-subscription.ts +++ b/ui-ngx/src/app/core/api/entity-data-subscription.ts @@ -358,7 +358,6 @@ export class EntityDataSubscription { this.subscriber = new TelemetrySubscriber(this.telemetryService); this.dataCommand = new EntityDataCmd(); - let keyFilters = this.entityDataSubscriptionOptions.keyFilters; if (this.entityDataSubscriptionOptions.additionalKeyFilters) { if (keyFilters) { diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html index 6865bfd9c3..0cd0ea729e 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html @@ -16,64 +16,61 @@ -->
- +

{{ (isAdd ? 'filter.add' : 'filter.edit') | translate }}

-
- - -
-
-
-
- - filter.name - - - {{ 'filter.name-required' | translate }} - - - {{ 'filter.duplicate-filter' | translate }} - - -
- - - -
-
-
-
filter.key-filters
- - {{ complexOperationTranslationMap.get(ComplexOperation.AND) | translate }} - {{ complexOperationTranslationMap.get(ComplexOperation.OR) | translate }} - -
- - -
-
+
+
+ + filter.name + + @if (filterFormGroup.controls.filter.hasError('required')) { + {{ 'filter.name-required' | translate }} + } + @if (filterFormGroup.controls.filter.hasError('duplicateFilterName')) { + {{ 'filter.duplicate-filter' | translate }} + } + + +
+ + + {{ complexOperationTranslationMap.get(ComplexOperation.AND) | translate | titlecase }} + + {{ complexOperationTranslationMap.get(ComplexOperation.OR) | translate | titlecase }} + + +
diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.scss b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.scss index 7b620f8c2f..2fd238043d 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.scss @@ -14,15 +14,4 @@ * limitations under the License. */ :host { - .tb-editable-switch { - padding-left: 10px; - - .editable-switch { - margin: 0; - } - - .editable-label { - margin: 5px 0; - } - } } diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts index 334ce90931..6db4a76822 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts @@ -14,26 +14,23 @@ /// limitations under the License. /// -import { Component, Inject, OnInit, SkipSelf } from '@angular/core'; -import { ErrorStateMatcher } from '@angular/material/core'; +import { Component, Inject } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { - UntypedFormBuilder, - UntypedFormControl, - UntypedFormGroup, - FormGroupDirective, - NgForm, - ValidatorFn, - Validators -} from '@angular/forms'; +import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { DialogComponent } from '@app/shared/components/dialog.component'; import { UtilsService } from '@core/services/utils.service'; -import { TranslateService } from '@ngx-translate/core'; -import { ComplexOperation, complexOperationTranslationMap, Filter, Filters } from '@shared/models/query/query.models'; +import { + ComplexOperation, + complexOperationTranslationMap, + Filter, + FilterInfo, + Filters +} from '@shared/models/query/query.models'; import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { FormControlsFrom } from '@shared/models/tenant.model'; export interface FilterDialogData { isAdd: boolean; @@ -42,36 +39,30 @@ export interface FilterDialogData { } @Component({ - selector: 'tb-filter-dialog', - templateUrl: './filter-dialog.component.html', - providers: [{ provide: ErrorStateMatcher, useExisting: FilterDialogComponent }], - styleUrls: ['./filter-dialog.component.scss'], - standalone: false + selector: 'tb-filter-dialog', + templateUrl: './filter-dialog.component.html', + styleUrls: ['./filter-dialog.component.scss'], + standalone: false }) -export class FilterDialogComponent extends DialogComponent - implements OnInit, ErrorStateMatcher { +export class FilterDialogComponent extends DialogComponent { isAdd: boolean; - filters: Array; - - filter: Filter; - - filterFormGroup: UntypedFormGroup; - submitted = false; + filterFormGroup: FormGroup>; ComplexOperation = ComplexOperation; complexOperationTranslationMap = complexOperationTranslationMap; allowKeyFiltersOrConditions: boolean; + private readonly filter: Filter; + private filters: Array; + constructor(protected store: Store, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: FilterDialogData, - @SkipSelf() private errorStateMatcher: ErrorStateMatcher, - public dialogRef: MatDialogRef, - private fb: UntypedFormBuilder, - private utils: UtilsService, - public translate: TranslateService) { + protected dialogRef: MatDialogRef, + private fb: FormBuilder, + private utils: UtilsService) { super(store, router, dialogRef); this.isAdd = data.isAdd; if (Array.isArray(data.filters)) { @@ -102,15 +93,15 @@ export class FilterDialogComponent extends DialogComponent { + return (c: FormControl) => { const newFilter = c.value.trim(); const found = this.filters.find((filter) => filter.filter === newFilter); if (found) { @@ -126,13 +117,11 @@ export class FilterDialogComponent extends DialogComponent FilterTextComponent), - multi: true - } - ], - standalone: false + selector: 'tb-filter-text', + templateUrl: './filter-text.component.html', + styleUrls: ['./filter-text.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FilterTextComponent), + multi: true + } + ], + standalone: false }) -export class FilterTextComponent implements ControlValueAccessor, OnInit, OnChanges { +export class FilterTextComponent implements ControlValueAccessor, OnChanges { - private requiredValue: boolean; - get required(): boolean { - return this.requiredValue; - } @Input() - set required(value: boolean) { - this.requiredValue = coerceBooleanProperty(value); - } + @coerceBoolean() + required = false; @Input() disabled: boolean; @@ -66,26 +60,20 @@ export class FilterTextComponent implements ControlValueAccessor, OnInit, OnChan public filterText: string; private currentValue: Array; - private propagateChange = (v: any) => { }; - constructor(private dialog: MatDialog, - private fb: UntypedFormBuilder, - private translate: TranslateService, + constructor(private translate: TranslateService, private datePipe: DatePipe) { } - registerOnChange(fn: any): void { - this.propagateChange = fn; - } - - registerOnTouched(fn: any): void { + registerOnChange(_fn: any): void { } - ngOnInit() { + registerOnTouched(_fn: any): void { } ngOnChanges(changes: SimpleChanges) { - if (changes.operation && !changes.operation.firstChange) { + if (changes.operation && !changes.operation.firstChange + && changes.operation.currentValue !== changes.operation.previousValue) { this.updateFilterText(this.currentValue); } } diff --git a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html index a772c765fd..8bfd7792d5 100644 --- a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html @@ -15,78 +15,86 @@ limitations under the License. --> -
- - - - -
filter.key-filters
-
-
-
- -
- - -   -   -
-
- -
-
-
- {{ complexOperationTranslationMap.get(operation) | translate }} +
+
+
+
filter.key-filters
+ +
+ @if (keyFiltersFormArray.length) { +
+
+ +
+
filter.key-name
+
filter.key-type.key-type
+   +  
-
-
-
{{ keyFilterControl.value.key.key }}
-
{{ entityKeyTypeTranslations.get(keyFilterControl.value.key.type) | translate }}
- - +
+ +
+ @for (keyFilterControl of keyFiltersFormArray.controls; track keyFilterControl; let index = $index) { +
+
+ @if (index) { +
+ {{ complexOperationTranslationMap.get(operation) | translate }} +
+ } +
+
+
+
{{ keyFilterControl.value.key.key }}
+
{{ entityKeyTypeTranslations.get(keyFilterControl.value.key.type) | translate }}
+ + +
+
- -
+ }
- filter.no-key-filters -
-
- -
- - +
+ } @else { + filter.no-key-filters + } +
+ +
+
+
+ - -
filter.preview
-
+ {{ 'filter.preview' | translate }}
-
- -
+ + @if (keyFiltersFormArray.length) { + + } @else { + filter.no-filter-text + } +
- +
diff --git a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.scss b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.scss index 2d1bcc923a..9ef70c8845 100644 --- a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.scss +++ b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.scss @@ -13,30 +13,45 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@import "../scss/constants"; + :host { - .key-filter-list { + .filter-title { + padding: 12px 0; + font-size: 14px; + font-weight: 500; + } + .no-data-found { + height: 50px; + font-size: 16px; + } + .filter-list { overflow: auto; max-height: 300px; - .no-data-found { - height: 50px; + + &-divider { + border-top: 1px solid rgba(0, 0, 0, 0.12); + } + + .filters-text { + font-size: 14px; } } .filters-operation { - margin-top: -18px; - color: #666; - font-weight: 500; - } - .tb-filter-preview { - padding: 8px; - border: 1px groove rgba(0, 0, 0, .25); - border-radius: 4px; - } -} - -:host ::ng-deep { - .tb-filter-preview { - .tb-filter-text { - max-height: 200px; + display: flex; + justify-content: center; + margin-top: -14px; + &-container { + background-color: white; + } + &-label { + font-size: 15px; + font-weight: 400; + color: $tb-primary-color; + padding: 0 8px; + border-radius: 4px; + border: 1px solid rgba($tb-primary-color, 0.32); + background-color: rgba($tb-primary-color, 0.04); } } } diff --git a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts index 8951764a4b..73804faa7a 100644 --- a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts @@ -14,26 +14,26 @@ /// limitations under the License. /// -import { Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; import { - AbstractControl, ControlValueAccessor, - UntypedFormArray, - UntypedFormBuilder, - UntypedFormControl, - UntypedFormGroup, + FormArray, + FormBuilder, + FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator, Validators } from '@angular/forms'; -import { Observable, Subject } from 'rxjs'; +import { Observable } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ComplexOperation, complexOperationTranslationMap, EntityKeyType, entityKeyTypeTranslationMap, + KeyFilter, KeyFilterInfo, keyFilterInfosToKeyFilters } from '@shared/models/query/query.models'; @@ -41,27 +41,26 @@ import { MatDialog } from '@angular/material/dialog'; import { deepClone } from '@core/utils'; import { KeyFilterDialogComponent, KeyFilterDialogData } from '@home/components/filter/key-filter-dialog.component'; import { EntityId } from '@shared/models/id/entity-id'; -import { takeUntil } from 'rxjs/operators'; @Component({ - selector: 'tb-key-filter-list', - templateUrl: './key-filter-list.component.html', - styleUrls: ['./key-filter-list.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => KeyFilterListComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => KeyFilterListComponent), - multi: true - } - ], - standalone: false + selector: 'tb-key-filter-list', + templateUrl: './key-filter-list.component.html', + styleUrls: ['./key-filter-list.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => KeyFilterListComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => KeyFilterListComponent), + multi: true + } + ], + standalone: false }) -export class KeyFilterListComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy { +export class KeyFilterListComponent implements ControlValueAccessor, Validator, OnInit { @Input() disabled: boolean; @@ -77,39 +76,28 @@ export class KeyFilterListComponent implements ControlValueAccessor, Validator, complexOperationTranslationMap = complexOperationTranslationMap; - keyFilterListFormGroup: UntypedFormGroup; - entityKeyTypeTranslations = entityKeyTypeTranslationMap; - keyFiltersControl: UntypedFormControl; + keyFiltersFormArray: FormArray>; + + keyFiltersControl: FormControl>; - private destroy$ = new Subject(); private propagateChange = null; - constructor(private fb: UntypedFormBuilder, - private dialog: MatDialog) { + constructor(private fb: FormBuilder, + private dialog: MatDialog, + private destroyRef: DestroyRef) { } ngOnInit(): void { - this.keyFilterListFormGroup = this.fb.group({ - keyFilters: this.fb.array([]) - }); + this.keyFiltersFormArray = this.fb.array>([]); this.keyFiltersControl = this.fb.control(null); - this.keyFilterListFormGroup.valueChanges.pipe( - takeUntil(this.destroy$) + this.keyFiltersFormArray.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) ).subscribe(() => this.updateModel()); } - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } - - get keyFiltersFormArray(): UntypedFormArray { - return this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray; - } - registerOnChange(fn: any): void { this.propagateChange = fn; } @@ -120,61 +108,61 @@ export class KeyFilterListComponent implements ControlValueAccessor, Validator, setDisabledState?(isDisabled: boolean): void { this.disabled = isDisabled; if (this.disabled) { - this.keyFilterListFormGroup.disable({emitEvent: false}); + this.keyFiltersFormArray.disable({emitEvent: false}); this.keyFiltersControl.disable({emitEvent: false}); } else { - this.keyFilterListFormGroup.enable({emitEvent: false}); + this.keyFiltersFormArray.enable({emitEvent: false}); this.keyFiltersControl.enable({emitEvent: false}); } } validate(): ValidationErrors | null { - return this.keyFilterListFormGroup.valid && this.keyFiltersControl.valid ? null : { + return this.keyFiltersFormArray.valid && this.keyFiltersControl.valid ? null : { keyFilterList: {valid: false} }; } writeValue(keyFilters: Array): void { - if (keyFilters.length === this.keyFiltersFormArray.length) { + if (keyFilters?.length === this.keyFiltersFormArray.length) { this.keyFiltersFormArray.patchValue(keyFilters, {emitEvent: false}); } else { - const keyFilterControls: Array = []; + this.keyFiltersFormArray.clear({emitEvent: false}); if (keyFilters) { for (const keyFilter of keyFilters) { - keyFilterControls.push(this.fb.control(keyFilter, [Validators.required])); + this.keyFiltersFormArray.push( + this.fb.control(keyFilter, [Validators.required]), + {emitEvent: false} + ); } } - this.keyFilterListFormGroup.setControl('keyFilters', this.fb.array(keyFilterControls), {emitEvent: false}); if (this.disabled) { - this.keyFilterListFormGroup.disable({emitEvent: false}); + this.keyFiltersFormArray.disable({emitEvent: false}); } else { - this.keyFilterListFormGroup.enable({emitEvent: false}); + this.keyFiltersFormArray.enable({emitEvent: false}); } } const keyFiltersArray = keyFilterInfosToKeyFilters(keyFilters); this.keyFiltersControl.patchValue(keyFiltersArray, {emitEvent: false}); } - public removeKeyFilter(index: number) { - (this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray).removeAt(index); + removeKeyFilter(index: number) { + this.keyFiltersFormArray.removeAt(index); } - public addKeyFilter() { - const keyFiltersFormArray = this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray; + addKeyFilter() { this.openKeyFilterDialog(null).subscribe((result) => { if (result) { - keyFiltersFormArray.push(this.fb.control(result, [Validators.required])); + this.keyFiltersFormArray.push(this.fb.control(result, [Validators.required])); } }); } - public editKeyFilter(index: number) { - const keyFilter: KeyFilterInfo = - (this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray).at(index).value; + editKeyFilter(index: number) { + const keyFilter = this.keyFiltersFormArray.at(index).value; this.openKeyFilterDialog(keyFilter).subscribe( (result) => { if (result) { - (this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray).at(index).patchValue(result); + this.keyFiltersFormArray.at(index).patchValue(result); } } ); @@ -210,12 +198,8 @@ export class KeyFilterListComponent implements ControlValueAccessor, Validator, } private updateModel() { - const keyFilters: Array = this.keyFilterListFormGroup.getRawValue().keyFilters; - if (keyFilters.length) { - this.propagateChange(keyFilters); - } else { - this.propagateChange(null); - } + const keyFilters = this.keyFiltersFormArray.getRawValue(); + this.propagateChange(keyFilters.length ? keyFilters : null); const keyFiltersArray = keyFilterInfosToKeyFilters(keyFilters); this.keyFiltersControl.patchValue(keyFiltersArray, {emitEvent: false}); } diff --git a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html index 8ca43427e9..f1836ae17e 100644 --- a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html @@ -15,64 +15,61 @@ limitations under the License. --> - - + +

{{ filter.filter | customTranslate }}

-
- -
-
-
-
- - - {{ userInputControl.get('label').value | customTranslate }} - - - - - - {{ userInputControl.get('label').value | customTranslate }} - -
- {{ userInputControl.get('unitSymbol').value }} + @for (userInputControl of userInputsFormArray.controls; track userInputControl) { + @switch (userInputControl.controls.valueType.value) { + @case (valueTypeEnum.STRING) { + + {{ userInputControl.controls.label.value | customTranslate }} + + + } + @case (valueTypeEnum.NUMERIC) { + + {{ userInputControl.controls.label.value | customTranslate }} + + @if (userInputControl.controls.unitSymbol.value) { +
+ {{ userInputControl.controls.unitSymbol.value }}
-
- - - - - - - - {{ userInputControl.get('label').value | customTranslate }} - - -
-
-
+ } + + } + @case (valueTypeEnum.DATE_TIME) { + + } + @case (valueTypeEnum.BOOLEAN) { + + + {{ userInputControl.controls.label.value | customTranslate }} + + + } + } + }
diff --git a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.scss b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.scss index d3b1d316ab..552c49b56a 100644 --- a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.scss @@ -14,14 +14,14 @@ * limitations under the License. */ :host ::ng-deep { - .user-filter-dialog { - .mat-mdc-form-field.mat-mdc-form-field-has-icon-suffix.mat-form-field-hide-placeholder { + .numeric-field { + &.mat-mdc-form-field-has-icon-suffix.mat-form-field-hide-placeholder { .mat-mdc-form-field-icon-suffix { place-self: center; } } - .mat-mdc-form-field.mat-mdc-form-field-has-icon-suffix { + &.mat-mdc-form-field-has-icon-suffix { .mat-mdc-form-field-icon-suffix { place-self: baseline; } diff --git a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.ts index 6e068fdcf3..59231f8b80 100644 --- a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.ts @@ -14,12 +14,11 @@ /// limitations under the License. /// -import { Component, DestroyRef, Inject, SkipSelf } from '@angular/core'; -import { ErrorStateMatcher } from '@angular/material/core'; +import { Component, DestroyRef, Inject } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { FormArray, FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; +import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { DialogComponent } from '@app/shared/components/dialog.component'; import { TranslateService } from '@ngx-translate/core'; @@ -39,29 +38,31 @@ export interface UserFilterDialogData { filter: Filter; } +interface UserInputForm { + label: FormControl; + valueType: FormControl; + unitSymbol: FormControl; + value: FormControl; +} + @Component({ selector: 'tb-user-filter-dialog', templateUrl: './user-filter-dialog.component.html', - providers: [{ provide: ErrorStateMatcher, useExisting: UserFilterDialogComponent }], styleUrls: ['./user-filter-dialog.component.scss'], standalone: false }) -export class UserFilterDialogComponent extends DialogComponent - implements ErrorStateMatcher { +export class UserFilterDialogComponent extends DialogComponent { filter: Filter; - userFilterFormGroup: FormGroup; + userInputsFormArray: FormArray>; valueTypeEnum = EntityKeyValueType; - submitted = false; - constructor(protected store: Store, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: UserFilterDialogData, - @SkipSelf() private errorStateMatcher: ErrorStateMatcher, - public dialogRef: MatDialogRef, + protected dialogRef: MatDialogRef, private fb: FormBuilder, private translate: TranslateService, private destroyRef: DestroyRef, @@ -70,19 +71,13 @@ export class UserFilterDialogComponent extends DialogComponent = []; - for (const userInput of userInputs) { - userInputControls.push(this.createUserInputFormControl(userInput)); - } - - this.userFilterFormGroup = this.fb.group({ - userInputs: this.fb.array(userInputControls) - }); + const userInputControls = userInputs.map(input => this.createUserInputFormControl(input)); + this.userInputsFormArray = this.fb.array(userInputControls); } - private createUserInputFormControl(userInput: UserFilterInputInfo): FormGroup { + private createUserInputFormControl(userInput: UserFilterInputInfo): FormGroup { const predicateValue: FilterPredicateValue = (userInput.info.keyFilterPredicate as any).value; - let value = isDefinedAndNotNull(predicateValue.userValue) ? predicateValue.userValue : predicateValue.defaultValue; + let value: string | number | boolean = isDefinedAndNotNull(predicateValue.userValue) ? predicateValue.userValue : predicateValue.defaultValue; let unitSymbol = ''; let valueConvertor: TbUnitConverter; if (userInput.valueType === EntityKeyValueType.NUMERIC) { @@ -91,42 +86,31 @@ export class UserFilterDialogComponent extends DialogComponent({ + label: this.fb.control(userInput.label), + valueType: this.fb.control(userInput.valueType), + unitSymbol: this.fb.control(unitSymbol), + value: this.fb.control(value, userInput.valueType === EntityKeyValueType.NUMERIC || - userInput.valueType === EntityKeyValueType.DATE_TIME ? [Validators.required] : []] + userInput.valueType === EntityKeyValueType.DATE_TIME ? [Validators.required] : []) }); - userInputControl.get('value').valueChanges.pipe( + userInputControl.controls.value.valueChanges.pipe( takeUntilDestroyed(this.destroyRef) ).subscribe(userValue => { - let value = userValue; + let val = userValue; if (valueConvertor) { - value = valueConvertor(value as number); + val = valueConvertor(val as number); } - (userInput.info.keyFilterPredicate as any).value.userValue = value; + (userInput.info.keyFilterPredicate as any).value.userValue = val; }); return userInputControl; } - userInputsFormArray(): FormArray { - return this.userFilterFormGroup.get('userInputs') as FormArray; - } - - isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { - const originalErrorState = this.errorStateMatcher.isErrorState(control, form); - const customErrorState = !!(control && control.invalid && this.submitted); - return originalErrorState || customErrorState; - } - cancel(): void { this.dialogRef.close(null); } save(): void { - this.submitted = true; this.dialogRef.close(this.filter); } } diff --git a/ui-ngx/src/app/shared/components/time/datetime.component.ts b/ui-ngx/src/app/shared/components/time/datetime.component.ts index 6d89185702..8306b14872 100644 --- a/ui-ngx/src/app/shared/components/time/datetime.component.ts +++ b/ui-ngx/src/app/shared/components/time/datetime.component.ts @@ -40,7 +40,7 @@ export class DatetimeComponent implements OnInit, ControlValueAccessor { return this.requiredValue; } @Input() - set required(value: boolean) { + set required(value: unknown) { this.requiredValue = coerceBooleanProperty(value); } diff --git a/ui-ngx/src/app/shared/models/query/query.models.ts b/ui-ngx/src/app/shared/models/query/query.models.ts index 258f09a535..cd35c410dc 100644 --- a/ui-ngx/src/app/shared/models/query/query.models.ts +++ b/ui-ngx/src/app/shared/models/query/query.models.ts @@ -411,8 +411,7 @@ export interface FiltersInfo { export function keyFiltersToText(translate: TranslateService, datePipe: DatePipe, keyFilters: Array, operation: ComplexOperation = ComplexOperation.AND): string { const filtersText = keyFilters.map(keyFilter => - keyFilterToText(translate, datePipe, keyFilter, - keyFilters.length > 1 ? operation : undefined)); + keyFilterToText(translate, datePipe, keyFilter, operation)); let result: string; if (filtersText.length > 1) { const opText = translate.instant(complexOperationTranslationMap.get(operation)); From 055db10939e671a8635b5363db756f6a0f05a21b Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Thu, 9 Apr 2026 14:24:09 +0200 Subject: [PATCH 25/28] Moved dashboard filters to new style (#15394) * Moved dashboard filters to new style * Clean up * Fixed table alignment * Added validators --- ...lex-filter-predicate-dialog.component.html | 23 +- .../filter-predicate-list.component.html | 112 ++++------ .../filter-predicate-list.component.scss | 42 +++- .../filter-predicate-value.component.html | 121 ++++++----- .../filter/filter-predicate.component.html | 101 ++++++--- .../filter/filter-predicate.component.ts | 142 ++++++++++-- .../filter/filters-dialog.component.html | 111 +++++----- .../filter/filters-dialog.component.scss | 42 +--- .../filter/filters-dialog.component.ts | 2 + .../filter/key-filter-dialog.component.html | 205 +++++++++--------- 10 files changed, 513 insertions(+), 388 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.html index 59d5faa155..cc86556a26 100644 --- a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.html @@ -26,17 +26,16 @@
-
- - filter.operation.operation - - @for (operation of complexOperations; track operation) { - - {{complexOperationTranslations.get(complexOperationEnum[operation]) | translate}} - - } - - +
+
+
{{ 'filter.filters' | translate }} +
+ + {{ complexOperationTranslations.get(complexOperationEnum.AND) | translate }} + {{ complexOperationTranslations.get(complexOperationEnum.OR) | translate }} + +
-
+
+
+
+ + + +
-
- } - filter.no-filters + } +
-
- -
- diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.scss b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.scss index 5155866e84..9a895dc7e0 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.scss +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.scss @@ -13,18 +13,40 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@import "../../../../../scss/constants"; + :host { - .predicate-list { - overflow-y: auto; - overflow-x: hidden; - max-height: 350px; - .no-data-found { - height: 50px; - } + .filter-title { + padding: 12px 8px; + font-size: 14px; + font-weight: 500; + } + .no-data-found { + height: 50px; + font-size: 16px; + } + + .key-filter-list-divider { + border-top: 1px solid rgba(0, 0, 0, 0.12); } .filters-operation { - margin-top: -18px; - color: #666; - font-weight: 500; + display: flex; + justify-content: center; + margin-top: -14px; + &-container { + position: absolute; + top: -12px; + left: 10px; + background-color: white; + } + &-label { + font-size: 15px; + font-weight: 400; + color: $tb-primary-color; + padding: 0 8px; + border-radius: 4px; + border: 1px solid rgba($tb-primary-color, 0.32); + background-color: rgba($tb-primary-color, 0.04); + } } } diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.html b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.html index e0c3aa20c7..63554e8034 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.html @@ -15,70 +15,75 @@ limitations under the License. --> -
-
-
- @switch (valueType) { - @case (valueTypeEnum.STRING) { - - - - } - @case (valueTypeEnum.NUMERIC) { - - - - } - @case (valueTypeEnum.DATE_TIME) { - +
+
+
+
+ @switch (valueType) { + @case (valueTypeEnum.STRING) { + + {{ hintText | translate }} + + + } + @case (valueTypeEnum.NUMERIC) { + + {{ hintText | translate }} + + + } + @case (valueTypeEnum.DATE_TIME) { + + } + @case (valueTypeEnum.BOOLEAN) { + + {{ (filterPredicateValueFormGroup.get('defaultValue').value ? 'value.true' : 'value.false') | translate }} + + } } - @case (valueTypeEnum.BOOLEAN) { - - {{ (filterPredicateValueFormGroup.get('defaultValue').value ? 'value.true' : 'value.false') | translate }} - - } - } +
-
{{ hintText | translate }}
-
-
-
-
- - - - {{'filter.no-dynamic-value' | translate}} - - @for (sourceType of dynamicValueSourceTypes; track sourceType) { - - {{dynamicValueSourceTypeTranslations.get(sourceType) | translate}} +
+
+
+ + filter.dynamic-source-type + + + {{'filter.no-dynamic-value' | translate}} - } - - -
filter.dynamic-source-type
-
-
- - - -
filter.source-attribute
-
- @if (!allow && inheritMode) { -
- - {{ 'filter.inherit-owner' | translate}} - -
filter.source-attribute-not-set
+ @for (sourceType of dynamicValueSourceTypes; track sourceType) { + + {{dynamicValueSourceTypeTranslations.get(sourceType) | translate}} + + } + + +
+
+ + filter.source-attribute + +
- } + @if (!allow && inheritMode) { +
+ + {{ 'filter.inherit-owner' | translate}} + +
filter.source-attribute-not-set
+
+ } +
+ @if (!onlyUserDynamicSource) { +
} } + @if (type !== filterPredicateType.COMPLEX) { + + + }
@if (type !== filterPredicateType.COMPLEX && displayUserParameters) { - - + }
diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts index e4e327bf0a..1311176976 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts @@ -14,19 +14,35 @@ /// limitations under the License. /// -import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; +import { Component, DestroyRef, forwardRef, Input } from '@angular/core'; import { ControlValueAccessor, UntypedFormBuilder, - UntypedFormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator, - Validators + Validators, } from '@angular/forms'; -import { EntityKeyValueType, FilterPredicateType, KeyFilterPredicateInfo } from '@shared/models/query/query.models'; +import { + EntityKeyValueType, + FilterPredicateType, + KeyFilterPredicateInfo, + BooleanOperation, booleanOperationTranslationMap, + NumericOperation, numericOperationTranslationMap, + StringOperation, stringOperationTranslationMap, ComplexFilterPredicateInfo, KeyFilterPredicateUserInfo, + KeyFilterPredicate +} from '@shared/models/query/query.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ComplexFilterPredicateDialogData } from '@home/components/filter/filter-component.models'; +import { + ComplexFilterPredicateDialogComponent +} from '@home/components/filter/complex-filter-predicate-dialog.component'; +import { MatDialog } from '@angular/material/dialog'; +import { + FilterUserInfoDialogComponent, + FilterUserInfoDialogData +} from '@home/components/filter/filter-user-info-dialog.component'; @Component({ selector: 'tb-filter-predicate', @@ -46,7 +62,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; ], standalone: false }) -export class FilterPredicateComponent implements ControlValueAccessor, Validator, OnInit { +export class FilterPredicateComponent implements ControlValueAccessor, Validator { @Input() disabled: boolean; @@ -60,23 +76,35 @@ export class FilterPredicateComponent implements ControlValueAccessor, Validator @Input() onlyUserDynamicSource = false; - filterPredicateFormGroup: UntypedFormGroup; + filterPredicateFormGroup = this.fb.group({ + operation: [], + ignoreCase: false, + predicates: [], + value: [], + userInfo: [] + }); type: FilterPredicateType; filterPredicateType = FilterPredicateType; + stringOperations = Object.keys(StringOperation); + stringOperationEnum = StringOperation; + stringOperationTranslations = stringOperationTranslationMap; + + numericOperations = Object.keys(NumericOperation); + numericOperationEnum = NumericOperation; + numericOperationTranslations = numericOperationTranslationMap; + + booleanOperations = Object.keys(BooleanOperation); + booleanOperationEnum = BooleanOperation; + booleanOperationTranslations = booleanOperationTranslationMap; + private propagateChange = null; constructor(private fb: UntypedFormBuilder, - private destroyRef: DestroyRef) { - } - - ngOnInit(): void { - this.filterPredicateFormGroup = this.fb.group({ - predicate: [null, [Validators.required]], - userInfo: [null, []] - }); + private destroyRef: DestroyRef, + private dialog: MatDialog) { this.filterPredicateFormGroup.valueChanges.pipe( takeUntilDestroyed(this.destroyRef) ).subscribe(() => { @@ -93,7 +121,7 @@ export class FilterPredicateComponent implements ControlValueAccessor, Validator setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; - if (this.disabled) { + if (isDisabled) { this.filterPredicateFormGroup.disable({emitEvent: false}); } else { this.filterPredicateFormGroup.enable({emitEvent: false}); @@ -108,19 +136,97 @@ export class FilterPredicateComponent implements ControlValueAccessor, Validator writeValue(predicate: KeyFilterPredicateInfo): void { this.type = predicate.keyFilterPredicate.type; - this.filterPredicateFormGroup.get('predicate').patchValue(predicate.keyFilterPredicate, {emitEvent: false}); + this.updateValidators(); + this.filterPredicateFormGroup.patchValue(predicate.keyFilterPredicate, {emitEvent: false}); this.filterPredicateFormGroup.get('userInfo').patchValue(predicate.userInfo, {emitEvent: false}); } + private updateValidators(): void { + const operationCtrl = this.filterPredicateFormGroup.get('operation'); + const predicatesCtrl = this.filterPredicateFormGroup.get('predicates'); + operationCtrl.setValidators([Validators.required]); + if (this.type === FilterPredicateType.COMPLEX) { + predicatesCtrl.setValidators([Validators.required]); + } else { + predicatesCtrl.clearValidators(); + } + operationCtrl.updateValueAndValidity({emitEvent: false}); + predicatesCtrl.updateValueAndValidity({emitEvent: false}); + } + private updateModel() { let predicate: KeyFilterPredicateInfo = null; if (this.filterPredicateFormGroup.valid) { + const v = this.filterPredicateFormGroup.getRawValue(); + let keyFilterPredicate: KeyFilterPredicate; + if (this.type === FilterPredicateType.COMPLEX) { + keyFilterPredicate = { + type: FilterPredicateType.COMPLEX, + operation: v.operation, + predicates: v.predicates + } as KeyFilterPredicate; + } else { + keyFilterPredicate = { + type: this.type, + value: v.value, + operation: v.operation, + ignoreCase: !!v.ignoreCase + } as KeyFilterPredicate; + } predicate = { - keyFilterPredicate: this.filterPredicateFormGroup.getRawValue().predicate, - userInfo: this.filterPredicateFormGroup.getRawValue().userInfo + keyFilterPredicate, + userInfo: v.userInfo }; } this.propagateChange(predicate); } + public openComplexFilterDialog() { + this.dialog.open(ComplexFilterPredicateDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + complexPredicate: { + type: FilterPredicateType.COMPLEX, + operation: this.filterPredicateFormGroup.get('operation').value, + predicates: this.filterPredicateFormGroup.get('predicates').value + }, + readonly: this.disabled, + valueType: this.valueType, + isAdd: false, + key: this.key, + displayUserParameters: this.displayUserParameters, + allowUserDynamicSource: this.allowUserDynamicSource, + onlyUserDynamicSource: this.onlyUserDynamicSource + } + }).afterClosed().subscribe( + (result) => { + if (result) { + this.filterPredicateFormGroup.patchValue(result); + } + } + ); + } + + public openFilterUserInfoDialog() { + this.dialog.open(FilterUserInfoDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + keyFilterPredicateUserInfo: this.filterPredicateFormGroup.get('userInfo').value, + valueType: this.valueType, + key: this.key, + operation: this.filterPredicateFormGroup.get('operation').value, + readonly: this.disabled + } + }).afterClosed().subscribe( + (result) => { + if (result) { + this.filterPredicateFormGroup.get('userInfo').patchValue(result); + } + } + ); + } } diff --git a/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html index 59c4c47eea..452045d164 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html @@ -25,72 +25,61 @@ close - @if (isLoading$ | async) { - - - } - @if ((isLoading$ | async) === false) { -
- }
-
- -
-
filter.filter
-
filter.editable
-
+
+
+ + + filter.filter + + {{ control.get('filter').value }} + + + + filter.editable + + + + + + + +
+ + + +
+
+
+ + +
+
+
+ filter.no-filters
-
- - @for (filterControl of filtersFormArray().controls; track filterControl) { -
- {{$index + 1}}. -
- {{filterControl.get('filter').value}} -
- - -
- - - -
-
- } -
-
-
-
-
+
+
{{ 'filter.key-filter' | translate }}
+
+
- - filter.key-type.key-type - - @for (type of entityKeyTypes; track type) { - - {{entityKeyTypeTranslations.get(type) | translate}} - + formGroupName="key"> + + filter.key-type.key-type + + @for (type of entityKeyTypes; track type) { + + {{entityKeyTypeTranslations.get(type) | translate}} + + } + + + + filter.key-name + + @if (keyFilterFormGroup.get('key.key').value && showAutocomplete) { + } - - - - filter.key-name - - @if (keyFilterFormGroup.get('key.key').value && showAutocomplete) { - - } - - @for (keyName of filteredKeysName | async; track keyName) { - - + + @for (keyName of filteredKeysName | async; track keyName) { + + + + } + + @if (keyFilterFormGroup.get('key.key').hasError('required')) { + + {{ 'filter.key-name-required' | translate }} + + } + +
+ + filter.value-type.value-type + + + + {{ entityKeyValueTypes.get(keyFilterFormGroup.get('valueType').value)?.name | translate }} + + @for (valueType of entityKeyValueTypesKeys; track valueType) { + + + {{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).name | translate }} } - - @if (keyFilterFormGroup.get('key.key').hasError('required')) { + + @if (keyFilterFormGroup.get('valueType').hasError('required')) { - {{ 'filter.key-name-required' | translate }} + {{ 'filter.value-type-required' | translate }} } -
- - filter.value-type.value-type - - - - {{ entityKeyValueTypes.get(keyFilterFormGroup.get('valueType').value)?.name | translate }} - - @for (valueType of entityKeyValueTypesKeys; track valueType) { - - - {{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).name | translate }} - - } - - @if (keyFilterFormGroup.get('valueType').hasError('required')) { - - {{ 'filter.value-type-required' | translate }} - - } - - @if (isConstantKeyType) { -
-
- @switch (keyFilterFormGroup.get('valueType').value) { - @case (entityKeyValueTypeEnum.STRING) { - - filter.value - - - } - @case (entityKeyValueTypeEnum.NUMERIC) { - - filter.value - - - } - @case (entityKeyValueTypeEnum.DATE_TIME) { - - } - @case (entityKeyValueTypeEnum.BOOLEAN) { - - {{ (keyFilterFormGroup.get('value').value ? 'value.true' : 'value.false') | translate }} - + @if (isConstantKeyType) { +
+
+ @switch (keyFilterFormGroup.get('valueType').value) { + @case (entityKeyValueTypeEnum.STRING) { + + filter.value + + + } + @case (entityKeyValueTypeEnum.NUMERIC) { + + filter.value + + + } + @case (entityKeyValueTypeEnum.DATE_TIME) { + + } + @case (entityKeyValueTypeEnum.BOOLEAN) { + + {{ (keyFilterFormGroup.get('value').value ? 'value.true' : 'value.false') | translate }} + + } } - } +
-
- } + } +
@if (keyFilterFormGroup.get('valueType').value) { - - +
+
+
{{ 'filter.filters' | translate }} +
+
+ + +
} -
+