Browse Source

Merge pull request #14089 from ShvaykaD/geofencing-cf/improvements

Geofencing CF bugfixes & improvements
pull/14125/head
Viacheslav Klimov 10 months ago
committed by GitHub
parent
commit
eee71f2c59
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      application/src/main/data/upgrade/basic/schema_update.sql
  2. 3
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java
  3. 50
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
  4. 3
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActor.java
  5. 62
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java
  6. 37
      application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldDynamicArgumentsRefreshMsg.java
  7. 23
      application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java
  8. 4
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java
  9. 42
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
  10. 7
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java
  11. 5
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java
  12. 10
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java
  13. 87
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java
  14. 35
      application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java
  15. 12
      application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
  16. 66
      application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java
  17. 23
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java
  18. 3
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java
  19. 3
      common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java
  20. 2
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java
  21. 2
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java
  22. 71
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java
  23. 86
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java
  24. 3
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java
  25. 7
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java
  26. 24
      common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelationPathQuery.java
  27. 25
      common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationPathLevel.java
  28. 2
      common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java
  29. 2
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ArgumentTest.java
  30. 126
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfigurationTest.java
  31. 216
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfigurationTest.java
  32. 27
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java
  33. 4
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java
  34. 5
      common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java
  35. 28
      dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java
  36. 3
      dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java
  37. 6
      dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java
  38. 99
      dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java
  39. 109
      dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java
  40. 53
      dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java
  41. 18
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java

2
application/src/main/data/upgrade/basic/schema_update.sql

@ -27,7 +27,7 @@ SET profile_data = jsonb_set(
CASE
WHEN (profile_data -> 'configuration') ? 'minAllowedScheduledUpdateIntervalInSecForCF'
THEN NULL
ELSE to_jsonb(3600)
ELSE to_jsonb(60)
END,
'maxRelationLevelPerCfArgument',
CASE

3
application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java

@ -75,9 +75,6 @@ public class CalculatedFieldEntityActor extends AbstractCalculatedFieldActor {
case CF_LINKED_TELEMETRY_MSG:
processor.process((EntityCalculatedFieldLinkedTelemetryMsg) msg);
break;
case CF_ENTITY_DYNAMIC_ARGUMENTS_REFRESH_MSG:
processor.process((EntityCalculatedFieldDynamicArgumentsRefreshMsg) msg);
break;
default:
return false;
}

50
application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java

@ -49,6 +49,7 @@ import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import java.util.ArrayList;
import java.util.Collection;
@ -227,18 +228,6 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
}
}
public void process(EntityCalculatedFieldDynamicArgumentsRefreshMsg msg) throws CalculatedFieldException {
log.debug("[{}][{}] Processing CF dynamic arguments refresh msg.", entityId, msg.getCfId());
CalculatedFieldState currentState = states.get(msg.getCfId());
if (currentState == null) {
log.debug("[{}][{}] Failed to find CF state for entity.", entityId, msg.getCfId());
} else {
currentState.setDirty(true);
log.debug("[{}][{}] CF state marked as dirty.", entityId, msg.getCfId());
}
msg.getCallback().onSuccess();
}
private void processTelemetry(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List<CalculatedFieldId> cfIdList, MultipleTbCallback callback) throws CalculatedFieldException {
processArgumentValuesUpdate(ctx, cfIdList, callback, mapToArguments(ctx, proto.getTsDataList()), toTbMsgId(proto), toTbMsgType(proto));
}
@ -266,12 +255,13 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
if (state == null) {
state = getOrInitState(ctx);
justRestored = true;
} else if (state.isDirty()) {
log.debug("[{}][{}] Going to update dirty CF state.", entityId, ctx.getCfId());
} else if (ctx.shouldFetchDynamicArgumentsFromDb(state)) {
log.debug("[{}][{}] Going to update dynamic arguments for CF.", entityId, ctx.getCfId());
try {
Map<String, ArgumentEntry> dynamicArgsFromDb = cfService.fetchDynamicArgsFromDb(ctx, entityId);
dynamicArgsFromDb.forEach(newArgValues::putIfAbsent);
state.setDirty(false);
var geofencingState = (GeofencingCalculatedFieldState) state;
geofencingState.setLastDynamicArgumentsRefreshTs(System.currentTimeMillis());
} catch (Exception e) {
throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build();
}
@ -403,7 +393,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
return mapToArguments(entityId, argNames, geofencingArgumentNames, scope, attrDataList);
}
private Map<String, ArgumentEntry> mapToArguments(EntityId entityId, Map<ReferencedEntityKey, String> argNames, List<String> geoArgNames, AttributeScopeProto scope, List<AttributeValueProto> attrDataList) {
private Map<String, ArgumentEntry> mapToArguments(EntityId entityId, Map<ReferencedEntityKey, String> argNames, List<String> geofencingArgNames, AttributeScopeProto scope, List<AttributeValueProto> attrDataList) {
Map<String, ArgumentEntry> arguments = new HashMap<>();
for (AttributeValueProto item : attrDataList) {
ReferencedEntityKey key = new ReferencedEntityKey(item.getKey(), ArgumentType.ATTRIBUTE, AttributeScope.valueOf(scope.name()));
@ -411,7 +401,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
if (argName == null) {
continue;
}
if (geoArgNames.contains(argName)) {
if (geofencingArgNames.contains(argName)) {
arguments.put(argName, new GeofencingArgumentEntry(entityId, item));
continue;
}
@ -425,26 +415,32 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
if (argNames.isEmpty()) {
return Collections.emptyMap();
}
return mapToArgumentsWithDefaultValue(argNames, ctx.getArguments(), scope, removedAttrKeys);
List<String> geofencingArgumentNames = ctx.getLinkedEntityGeofencingArgumentNames();
return mapToArgumentsWithDefaultValue(argNames, ctx.getArguments(), geofencingArgumentNames, scope, removedAttrKeys);
}
private Map<String, ArgumentEntry> mapToArgumentsWithDefaultValue(CalculatedFieldCtx ctx, AttributeScopeProto scope, List<String> removedAttrKeys) {
return mapToArgumentsWithDefaultValue(ctx.getMainEntityArguments(), ctx.getArguments(), scope, removedAttrKeys);
return mapToArgumentsWithDefaultValue(ctx.getMainEntityArguments(), ctx.getArguments(), ctx.getMainEntityGeofencingArgumentNames(), scope, removedAttrKeys);
}
private Map<String, ArgumentEntry> mapToArgumentsWithDefaultValue(Map<ReferencedEntityKey, String> argNames, Map<String, Argument> configArguments, AttributeScopeProto scope, List<String> removedAttrKeys) {
private Map<String, ArgumentEntry> mapToArgumentsWithDefaultValue(Map<ReferencedEntityKey, String> argNames, Map<String, Argument> configArguments, List<String> geofencingArgNames, AttributeScopeProto scope, List<String> removedAttrKeys) {
Map<String, ArgumentEntry> arguments = new HashMap<>();
for (String removedKey : removedAttrKeys) {
ReferencedEntityKey key = new ReferencedEntityKey(removedKey, ArgumentType.ATTRIBUTE, AttributeScope.valueOf(scope.name()));
String argName = argNames.get(key);
if (argName != null) {
Argument argument = configArguments.get(argName);
String defaultValue = (argument != null) ? argument.getDefaultValue() : null;
arguments.put(argName, StringUtils.isNotEmpty(defaultValue)
? new SingleValueArgumentEntry(System.currentTimeMillis(), new StringDataEntry(removedKey, defaultValue), null)
: new SingleValueArgumentEntry());
if (argName == null) {
continue;
}
if (geofencingArgNames.contains(argName)) {
arguments.put(argName, new GeofencingArgumentEntry());
continue;
}
Argument argument = configArguments.get(argName);
String defaultValue = (argument != null) ? argument.getDefaultValue() : null;
arguments.put(argName, StringUtils.isNotEmpty(defaultValue)
? new SingleValueArgumentEntry(System.currentTimeMillis(), new StringDataEntry(removedKey, defaultValue), null)
: new SingleValueArgumentEntry());
}
return arguments;
}

3
application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActor.java

@ -79,9 +79,6 @@ public class CalculatedFieldManagerActor extends AbstractCalculatedFieldActor {
case CF_LINKED_TELEMETRY_MSG:
processor.onLinkedTelemetryMsg((CalculatedFieldLinkedTelemetryMsg) msg);
break;
case CF_DYNAMIC_ARGUMENTS_REFRESH_MSG:
processor.onDynamicArgumentsRefreshMsg((CalculatedFieldDynamicArgumentsRefreshMsg) msg);
break;
default:
return false;
}

62
application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java

@ -27,7 +27,6 @@ import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ProfileEntityIdInfo;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.DeviceId;
@ -57,10 +56,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import static org.thingsboard.server.utils.CalculatedFieldUtils.fromProto;
@ -74,7 +70,6 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
private final Map<CalculatedFieldId, CalculatedFieldCtx> calculatedFields = new HashMap<>();
private final Map<EntityId, List<CalculatedFieldCtx>> entityIdCalculatedFields = new HashMap<>();
private final Map<EntityId, List<CalculatedFieldLink>> entityIdCalculatedFieldLinks = new HashMap<>();
private final Map<CalculatedFieldId, ScheduledFuture<?>> cfDynamicArgumentsRefreshTasks = new ConcurrentHashMap<>();
private final CalculatedFieldProcessingService cfExecService;
private final CalculatedFieldStateService cfStateService;
@ -113,8 +108,6 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
calculatedFields.clear();
entityIdCalculatedFields.clear();
entityIdCalculatedFieldLinks.clear();
cfDynamicArgumentsRefreshTasks.values().forEach(future -> future.cancel(true));
cfDynamicArgumentsRefreshTasks.clear();
ctx.stop(ctx.getSelf());
}
@ -274,7 +267,6 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
// Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead)
entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cfCtx);
addLinks(cf);
scheduleDynamicArgumentsRefreshTaskForCfIfNeeded(cfCtx);
applyToTargetCfEntityActors(cfCtx, callback, (id, cb) -> initCfForEntity(id, cfCtx, false, cb));
}
}
@ -304,12 +296,6 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
calculatedFields.put(newCf.getId(), newCfCtx);
List<CalculatedFieldCtx> oldCfList = entityIdCalculatedFields.get(newCf.getEntityId());
boolean hasSchedulingConfigChanges = newCfCtx.hasSchedulingConfigChanges(oldCfCtx);
if (hasSchedulingConfigChanges) {
cancelCfDynamicArgumentsRefreshTaskIfExists(cfId, false);
scheduleDynamicArgumentsRefreshTaskForCfIfNeeded(newCfCtx);
}
List<CalculatedFieldCtx> newCfList = new CopyOnWriteArrayList<>();
boolean found = false;
for (CalculatedFieldCtx oldCtx : oldCfList) {
@ -350,19 +336,9 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
}
entityIdCalculatedFields.get(cfCtx.getEntityId()).remove(cfCtx);
deleteLinks(cfCtx);
cancelCfDynamicArgumentsRefreshTaskIfExists(cfId, true);
applyToTargetCfEntityActors(cfCtx, callback, (id, cb) -> deleteCfForEntity(id, cfId, cb));
}
private void cancelCfDynamicArgumentsRefreshTaskIfExists(CalculatedFieldId cfId, boolean cfDeleted) {
var existingTask = cfDynamicArgumentsRefreshTasks.remove(cfId);
if (existingTask != null) {
existingTask.cancel(false);
String reason = cfDeleted ? "deletion" : "update";
log.debug("[{}][{}] Cancelled dynamic arguments refresh task due to CF {}!", tenantId, cfId, reason);
}
}
public void onTelemetryMsg(CalculatedFieldTelemetryMsg msg) {
EntityId entityId = msg.getEntityId();
log.debug("Received telemetry msg from entity [{}]", entityId);
@ -442,43 +418,6 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
return result;
}
private void scheduleDynamicArgumentsRefreshTaskForCfIfNeeded(CalculatedFieldCtx cfCtx) {
CalculatedField cf = cfCtx.getCalculatedField();
if (!(cf.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration scheduledCfConfig)) {
return;
}
if (!scheduledCfConfig.isScheduledUpdateEnabled()) {
return;
}
if (cfDynamicArgumentsRefreshTasks.containsKey(cf.getId())) {
log.debug("[{}][{}] Dynamic arguments refresh task for CF already exists!", tenantId, cf.getId());
return;
}
long refreshDynamicSourceInterval = TimeUnit.SECONDS.toMillis(scheduledCfConfig.getScheduledUpdateInterval());
var scheduledMsg = new CalculatedFieldDynamicArgumentsRefreshMsg(tenantId, cfCtx.getCfId());
ScheduledFuture<?> scheduledFuture = systemContext
.schedulePeriodicMsgWithDelay(ctx, scheduledMsg, refreshDynamicSourceInterval, refreshDynamicSourceInterval);
cfDynamicArgumentsRefreshTasks.put(cf.getId(), scheduledFuture);
log.debug("[{}][{}] Scheduled dynamic arguments refresh task for CF!", tenantId, cf.getId());
}
public void onDynamicArgumentsRefreshMsg(CalculatedFieldDynamicArgumentsRefreshMsg msg) {
log.debug("[{}] [{}] Processing CF dynamic arguments refresh task.", tenantId, msg.getCfId());
CalculatedFieldCtx cfCtx = calculatedFields.get(msg.getCfId());
if (cfCtx == null) {
log.debug("[{}][{}] Failed to find CF context, going to stop dynamic arguments refresh task for CF.", tenantId, msg.getCfId());
cancelCfDynamicArgumentsRefreshTaskIfExists(msg.getCfId(), true);
return;
}
applyToTargetCfEntityActors(cfCtx, msg.getCallback(), (id, cb) -> refreshDynamicArgumentsForEntity(id, msg.getCfId(), cb));
}
private void refreshDynamicArgumentsForEntity(EntityId entityId, CalculatedFieldId cfId, TbCallback callback) {
log.debug("Pushing CF dynamic arguments refresh msg to specific actor [{}]", entityId);
getOrCreateActor(entityId).tell(new EntityCalculatedFieldDynamicArgumentsRefreshMsg(tenantId, cfId, callback));
}
private void linkedTelemetryMsgForEntity(EntityId entityId, EntityCalculatedFieldLinkedTelemetryMsg msg) {
log.debug("Pushing linked telemetry msg to specific actor [{}]", entityId);
getOrCreateActor(entityId).tell(msg);
@ -565,7 +504,6 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
// We use copy on write lists to safely pass the reference to another actor for the iteration.
// Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead)
entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cfCtx);
scheduleDynamicArgumentsRefreshTaskForCfIfNeeded(cfCtx);
}
private void initCalculatedFieldLink(CalculatedFieldLink link) {

37
application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldDynamicArgumentsRefreshMsg.java

@ -1,37 +0,0 @@
/**
* Copyright © 2016-2025 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.actors.calculatedField;
import lombok.Data;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.msg.MsgType;
import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg;
import org.thingsboard.server.common.msg.queue.TbCallback;
@Data
public class EntityCalculatedFieldDynamicArgumentsRefreshMsg implements ToCalculatedFieldSystemMsg {
private final TenantId tenantId;
private final CalculatedFieldId cfId;
private final TbCallback callback;
@Override
public MsgType getMsgType() {
return MsgType.CF_ENTITY_DYNAMIC_ARGUMENTS_REFRESH_MSG;
}
}

23
application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java

@ -26,7 +26,7 @@ import lombok.extern.slf4j.Slf4j;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.Aggregation;
@ -45,6 +45,7 @@ import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import java.util.HashMap;
import java.util.List;
@ -102,6 +103,10 @@ public abstract class AbstractCalculatedFieldProcessingService {
return Futures.whenAllComplete(argFutures.values()).call(() -> {
var result = createStateByType(ctx);
result.updateState(ctx, resolveArgumentFutures(argFutures));
// TODO: move to state.init() method after merge with alarm rules 2.0
if (ctx.hasRelationQueryDynamicArguments() && result instanceof GeofencingCalculatedFieldState geofencingCalculatedFieldState) {
geofencingCalculatedFieldState.setLastDynamicArgumentsRefreshTs(System.currentTimeMillis());
}
return result;
}, MoreExecutors.directExecutor());
}
@ -159,19 +164,9 @@ public abstract class AbstractCalculatedFieldProcessingService {
}
var refDynamicSourceConfiguration = value.getRefDynamicSourceConfiguration();
return switch (refDynamicSourceConfiguration.getType()) {
case RELATION_QUERY -> {
var configuration = (RelationQueryDynamicSourceConfiguration) refDynamicSourceConfiguration;
if (configuration.isSimpleRelation()) {
yield switch (configuration.getDirection()) {
case FROM ->
Futures.transform(relationService.findByFromAndTypeAsync(tenantId, entityId, configuration.getRelationType(), RelationTypeGroup.COMMON),
configuration::resolveEntityIds, calculatedFieldCallbackExecutor);
case TO ->
Futures.transform(relationService.findByToAndTypeAsync(tenantId, entityId, configuration.getRelationType(), RelationTypeGroup.COMMON),
configuration::resolveEntityIds, calculatedFieldCallbackExecutor);
};
}
yield Futures.transform(relationService.findByQuery(tenantId, configuration.toEntityRelationsQuery(entityId)),
case RELATION_PATH_QUERY -> {
var configuration = (RelationPathQueryDynamicSourceConfiguration) refDynamicSourceConfiguration;
yield Futures.transform(relationService.findByRelationPathQueryAsync(tenantId, configuration.toRelationPathQuery(entityId)),
configuration::resolveEntityIds, calculatedFieldCallbackExecutor);
}
};

4
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java

@ -62,7 +62,7 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState {
boolean entryUpdated;
if (existingEntry == null || newEntry.isForceResetPrevious()) {
validateNewEntry(newEntry);
validateNewEntry(key, newEntry);
arguments.put(key, newEntry);
entryUpdated = true;
} else {
@ -93,7 +93,7 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState {
}
}
protected void validateNewEntry(ArgumentEntry newEntry) {}
protected void validateNewEntry(String key, ArgumentEntry newEntry) {}
private void updateLastUpdateTimestamp(ArgumentEntry entry) {
long newTs = this.latestTimestamp;

42
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java

@ -43,11 +43,13 @@ import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.thingsboard.common.util.ExpressionFunctionsUtil.userDefinedFunctions;
@ -78,9 +80,12 @@ public class CalculatedFieldCtx {
private long maxStateSize;
private long maxSingleValueArgumentSize;
private boolean relationQueryDynamicArguments;
private List<String> mainEntityGeofencingArgumentNames;
private List<String> linkedEntityGeofencingArgumentNames;
private long scheduledUpdateIntervalMillis;
public CalculatedFieldCtx(CalculatedField calculatedField, TbelInvokeService tbelInvokeService, ApiLimitService apiLimitService, RelationService relationService) {
this.calculatedField = calculatedField;
@ -101,6 +106,7 @@ public class CalculatedFieldCtx {
var refId = entry.getValue().getRefEntityId();
var refKey = entry.getValue().getRefEntityKey();
if (refId == null && entry.getValue().hasDynamicSource()) {
relationQueryDynamicArguments = true;
continue;
}
if (refId == null || refId.equals(calculatedField.getEntityId())) {
@ -126,6 +132,9 @@ public class CalculatedFieldCtx {
});
}
}
if (calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration scheduledConfig) {
this.scheduledUpdateIntervalMillis = scheduledConfig.isScheduledUpdateEnabled() ? TimeUnit.SECONDS.toMillis(scheduledConfig.getScheduledUpdateInterval()) : -1L;
}
this.tbelInvokeService = tbelInvokeService;
this.relationService = relationService;
@ -329,25 +338,42 @@ public class CalculatedFieldCtx {
public boolean hasOtherSignificantChanges(CalculatedFieldCtx other) {
boolean expressionChanged = calculatedField.getConfiguration() instanceof ExpressionBasedCalculatedFieldConfiguration && !expression.equals(other.expression);
boolean outputChanged = !output.equals(other.output);
return expressionChanged || outputChanged;
boolean scheduledUpdatesConfigChanged = scheduledUpdateIntervalMillis != other.scheduledUpdateIntervalMillis;
return expressionChanged || outputChanged || scheduledUpdatesConfigChanged;
}
public boolean hasStateChanges(CalculatedFieldCtx other) {
boolean typeChanged = !cfType.equals(other.cfType);
boolean argumentsChanged = !arguments.equals(other.arguments);
return typeChanged || argumentsChanged;
boolean geoZoneGroupsConfigChanged = hasGeofencingZoneGroupConfigurationChanges(other);
return typeChanged || argumentsChanged || geoZoneGroupsConfigChanged;
}
public boolean hasSchedulingConfigChanges(CalculatedFieldCtx other) {
if (calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration thisConfig
&& other.calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration otherConfig) {
boolean refreshTriggerChanged = thisConfig.isScheduledUpdateEnabled() != otherConfig.isScheduledUpdateEnabled();
boolean refreshIntervalChanged = thisConfig.getScheduledUpdateInterval() != otherConfig.getScheduledUpdateInterval();
return refreshTriggerChanged || refreshIntervalChanged;
private boolean hasGeofencingZoneGroupConfigurationChanges(CalculatedFieldCtx other) {
if (calculatedField.getConfiguration() instanceof GeofencingCalculatedFieldConfiguration thisConfig
&& other.calculatedField.getConfiguration() instanceof GeofencingCalculatedFieldConfiguration otherConfig) {
return !thisConfig.getZoneGroups().equals(otherConfig.getZoneGroups());
}
return false;
}
public boolean hasRelationQueryDynamicArguments() {
return relationQueryDynamicArguments && scheduledUpdateIntervalMillis != -1;
}
public boolean shouldFetchDynamicArgumentsFromDb(CalculatedFieldState state) {
if (!hasRelationQueryDynamicArguments()) {
return false;
}
if (!(state instanceof GeofencingCalculatedFieldState geofencingState)) {
return false;
}
if (geofencingState.getLastDynamicArgumentsRefreshTs() == -1L) {
return true;
}
return geofencingState.getLastDynamicArgumentsRefreshTs() < System.currentTimeMillis() - scheduledUpdateIntervalMillis;
}
public String getSizeExceedsLimitMessage() {
return "Failed to init CF state. State size exceeds limit of " + (maxStateSize / 1024) + "Kb!";
}

7
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java

@ -50,13 +50,6 @@ public interface CalculatedFieldState {
long getLatestTimestamp();
default void setDirty(boolean dirty) {
}
default boolean isDirty() {
return false;
}
void setRequiredArguments(List<String> requiredArguments);
boolean updateState(CalculatedFieldCtx ctx, Map<String, ArgumentEntry> argumentValues);

5
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java

@ -48,9 +48,10 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState {
}
@Override
protected void validateNewEntry(ArgumentEntry newEntry) {
protected void validateNewEntry(String key, ArgumentEntry newEntry) {
if (newEntry instanceof TsRollingArgumentEntry) {
throw new IllegalArgumentException("Rolling argument entry is not supported for simple calculated fields.");
throw new IllegalArgumentException("Unsupported argument type detected for argument: " + key + ". " +
"Rolling argument entry is not supported for simple calculated fields.");
}
}

10
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java

@ -41,7 +41,7 @@ public class GeofencingArgumentEntry implements ArgumentEntry {
}
public GeofencingArgumentEntry(EntityId entityId, TransportProtos.AttributeValueProto entry) {
this.zoneStates = toZones(Map.of(entityId, ProtoUtils.fromProto(entry)));
this(Map.of(entityId, ProtoUtils.fromProto(entry)));
}
public GeofencingArgumentEntry(Map<EntityId, KvEntry> entityIdkvEntryMap) {
@ -63,6 +63,10 @@ public class GeofencingArgumentEntry implements ArgumentEntry {
if (!(entry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) {
throw new IllegalArgumentException("Unsupported argument entry type for geofencing argument entry: " + entry.getType());
}
if (geofencingArgumentEntry.isEmpty()) {
zoneStates.clear();
return true;
}
boolean updated = false;
for (var zoneEntry : geofencingArgumentEntry.getZoneStates().entrySet()) {
if (updateZone(zoneEntry)) {
@ -97,6 +101,10 @@ public class GeofencingArgumentEntry implements ArgumentEntry {
zoneStates.put(zoneId, newZoneState);
return true;
}
if (newZoneState.getPerimeterDefinition() == null) {
zoneStates.remove(zoneId);
return true;
}
return existingZoneState.update(newZoneState);
}

87
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java

@ -15,16 +15,19 @@
*/
package org.thingsboard.server.service.cf.ctx.state.geofencing;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.geo.Coordinates;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent;
@ -39,7 +42,6 @@ import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@ -51,18 +53,14 @@ import static org.thingsboard.server.common.data.cf.configuration.geofencing.Geo
@Data
@Slf4j
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
private boolean dirty;
private long lastDynamicArgumentsRefreshTs = -1;
public GeofencingCalculatedFieldState() {
super(new ArrayList<>(), new HashMap<>(), false, -1);
this.dirty = false;
}
public GeofencingCalculatedFieldState(List<String> argNames) {
super(argNames);
public GeofencingCalculatedFieldState(List<String> requiredArguments) {
super(requiredArguments);
}
@Override
@ -71,49 +69,21 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
}
@Override
public boolean updateState(CalculatedFieldCtx ctx, Map<String, ArgumentEntry> argumentValues) {
if (arguments == null) {
arguments = new HashMap<>();
}
boolean stateUpdated = false;
for (var entry : argumentValues.entrySet()) {
String key = entry.getKey();
ArgumentEntry newEntry = entry.getValue();
checkArgumentSize(key, newEntry, ctx);
ArgumentEntry existingEntry = arguments.get(key);
boolean entryUpdated;
if (existingEntry == null || newEntry.isForceResetPrevious()) {
entryUpdated = switch (key) {
case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> {
if (!(newEntry instanceof SingleValueArgumentEntry singleValueArgumentEntry)) {
throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " +
"Only SINGLE_VALUE type is allowed.");
}
arguments.put(key, singleValueArgumentEntry);
yield true;
}
default -> {
if (!(newEntry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) {
throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " +
"Only GEOFENCING type is allowed.");
}
arguments.put(key, geofencingArgumentEntry);
yield true;
}
};
} else {
entryUpdated = existingEntry.updateEntry(newEntry);
protected void validateNewEntry(String key, ArgumentEntry newEntry) {
switch (key) {
case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> {
if (!(newEntry instanceof SingleValueArgumentEntry)) {
throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " +
"Only SINGLE_VALUE type is allowed.");
}
}
if (entryUpdated) {
stateUpdated = true;
default -> {
if (!(newEntry instanceof GeofencingArgumentEntry)) {
throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " +
"Only GEOFENCING type is allowed.");
}
}
}
return stateUpdated;
}
@Override
@ -125,7 +95,7 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
var geofencingCfg = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
Map<String, ZoneGroupConfiguration> zoneGroups = geofencingCfg.getZoneGroups();
ObjectNode resultNode = JacksonUtil.newObjectNode();
ObjectNode valuesNode = JacksonUtil.newObjectNode();
List<ListenableFuture<Boolean>> relationFutures = new ArrayList<>();
getGeofencingArguments().forEach((argumentKey, argumentEntry) -> {
@ -154,10 +124,11 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
relationFutures.add(f);
}
});
updateResultNode(argumentKey, zoneResults, zoneGroupCfg.getReportStrategy(), resultNode);
updateValuesNode(argumentKey, zoneResults, zoneGroupCfg.getReportStrategy(), valuesNode);
});
var result = new CalculatedFieldResult(ctx.getOutput().getType(), ctx.getOutput().getScope(), resultNode);
OutputType outputType = ctx.getOutput().getType();
var result = new CalculatedFieldResult(ctx.getOutput().getType(), ctx.getOutput().getScope(), toResultNode(outputType, valuesNode));
if (relationFutures.isEmpty()) {
return Futures.immediateFuture(result);
}
@ -171,7 +142,7 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
.collect(Collectors.toMap(Map.Entry::getKey, entry -> (GeofencingArgumentEntry) entry.getValue()));
}
private void updateResultNode(String argumentKey, List<GeofencingEvalResult> zoneResults, GeofencingReportStrategy geofencingReportStrategy, ObjectNode resultNode) {
private void updateValuesNode(String argumentKey, List<GeofencingEvalResult> zoneResults, GeofencingReportStrategy geofencingReportStrategy, ObjectNode resultNode) {
GeofencingEvalResult aggregationResult = aggregateZoneGroup(zoneResults);
final String eventKey = argumentKey + "Event";
final String statusKey = argumentKey + "Status";
@ -185,6 +156,16 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
}
}
private JsonNode toResultNode(OutputType outputType, ObjectNode valuesNode) {
if (OutputType.ATTRIBUTES.equals(outputType) || latestTimestamp == -1) {
return valuesNode;
}
ObjectNode resultNode = JacksonUtil.newObjectNode();
resultNode.put("ts", latestTimestamp);
resultNode.set("values", valuesNode);
return resultNode;
}
private GeofencingEvalResult aggregateZoneGroup(List<GeofencingEvalResult> zoneResults) {
boolean nowInside = zoneResults.stream().anyMatch(r -> INSIDE.equals(r.status()));
boolean prevInside = zoneResults.stream()

35
application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java

@ -36,7 +36,7 @@ import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfig
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates;
@ -47,10 +47,12 @@ import org.thingsboard.server.common.data.id.AssetProfileId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.controller.CalculatedFieldControllerTest;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@ -760,19 +762,13 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
// Zone groups: ATTRIBUTE on specific assets (one zone per group)
ZoneGroupConfiguration allowedZonesGroup = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
var allowedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
allowedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
allowedZoneDynamicSourceConfiguration.setRelationType("AllowedZone");
allowedZoneDynamicSourceConfiguration.setMaxLevel(1);
allowedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true);
var allowedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
allowedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "AllowedZone")));
allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration);
ZoneGroupConfiguration restrictedZonesGroup = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
var restrictedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
restrictedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
restrictedZoneDynamicSourceConfiguration.setRelationType("RestrictedZone");
restrictedZoneDynamicSourceConfiguration.setMaxLevel(1);
restrictedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true);
var restrictedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
restrictedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "RestrictedZone")));
restrictedZonesGroup.setRefDynamicSourceConfiguration(restrictedZoneDynamicSourceConfiguration);
cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup));
@ -831,10 +827,11 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
TenantProfile foundTenantProfile = doGet("/api/tenantProfile/" + tenantProfileEntityInfo.getId().getId().toString(), TenantProfile.class);
assertThat(foundTenantProfile).isNotNull();
assertThat(foundTenantProfile.getDefaultProfileConfiguration()).isNotNull();
foundTenantProfile.getDefaultProfileConfiguration().setMinAllowedScheduledUpdateIntervalInSecForCF(TIMEOUT / 10);
int minAllowedScheduledUpdateIntervalInSecForCF = TIMEOUT / 10;
foundTenantProfile.getDefaultProfileConfiguration().setMinAllowedScheduledUpdateIntervalInSecForCF(minAllowedScheduledUpdateIntervalInSecForCF);
TenantProfile savedTenantProfile = doPost("/api/tenantProfile", foundTenantProfile, TenantProfile.class);
assertThat(savedTenantProfile).isNotNull();
assertThat(savedTenantProfile.getDefaultProfileConfiguration().getMinAllowedScheduledUpdateIntervalInSecForCF()).isEqualTo(TIMEOUT / 10);
assertThat(savedTenantProfile.getDefaultProfileConfiguration().getMinAllowedScheduledUpdateIntervalInSecForCF()).isEqualTo(minAllowedScheduledUpdateIntervalInSecForCF);
loginTenantAdmin();
// --- Arrange entities ---
@ -869,11 +866,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY));
var allowedZonesGroup = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
var allowedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
allowedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
allowedZoneDynamicSourceConfiguration.setRelationType("AllowedZone");
allowedZoneDynamicSourceConfiguration.setMaxLevel(1);
allowedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true);
var allowedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
allowedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "AllowedZone")));
allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration);
cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup));
@ -884,7 +878,8 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
cfg.setOutput(out);
// Enable scheduled refresh with a 6-second interval
cfg.setScheduledUpdateInterval(6);
cfg.setScheduledUpdateInterval(minAllowedScheduledUpdateIntervalInSecForCF);
cfg.setScheduledUpdateEnabled(true);
cf.setConfiguration(cfg);
CalculatedField savedCalculatedField = doPost("/api/calculatedField", cf, CalculatedField.class);
@ -935,7 +930,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
relAllowedB.setType("AllowedZone");
doPost("/api/relation", relAllowedB).andExpect(status().isOk());
awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsDirty(device.getId(), savedCalculatedField.getId());
awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsReadyToRefreshDynamicArguments(device.getId(), savedCalculatedField.getId(), minAllowedScheduledUpdateIntervalInSecForCF);
// --- Same coordinates as before, but now we expect ENTERED since a new zone is registered ---
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",

12
application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java

@ -155,6 +155,7 @@ import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.queue.memory.InMemoryStorage;
import org.thingsboard.server.service.cf.CfRocksDb;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import org.thingsboard.server.service.entitiy.tenant.profile.TbTenantProfileService;
import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRequest;
import org.thingsboard.server.service.security.auth.rest.LoginRequest;
@ -1104,14 +1105,15 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
});
}
protected void awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsDirty(EntityId entityId, CalculatedFieldId cfId) {
protected void awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsReadyToRefreshDynamicArguments(EntityId entityId, CalculatedFieldId cfId, int scheduledUpdateInterval) {
CalculatedFieldEntityMessageProcessor processor = getCalculatedFieldEntityMessageProcessor(entityId);
Map<CalculatedFieldId, CalculatedFieldState> statesMap = (Map<CalculatedFieldId, CalculatedFieldState>) ReflectionTestUtils.getField(processor, "states");
Awaitility.await("CF state for entity actor marked as dirty").atMost(5, TimeUnit.SECONDS).until(() -> {
Awaitility.await("CF state for entity actor ready to refresh dynamic arguments").atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> {
CalculatedFieldState calculatedFieldState = statesMap.get(cfId);
boolean stateDirty = calculatedFieldState != null && calculatedFieldState.isDirty();
log.warn("entityId {}, cfId {}, state dirty == {}", entityId, cfId, stateDirty);
return stateDirty;
boolean isReady = calculatedFieldState != null && ((GeofencingCalculatedFieldState) calculatedFieldState).getLastDynamicArgumentsRefreshTs()
< System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(scheduledUpdateInterval);
log.warn("entityId {}, cfId {}, state ready to refresh == {}", entityId, cfId, isReady);
return isReady;
});
}

66
application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java

@ -29,15 +29,23 @@ import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfig
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS;
@DaoSqlTest
public class CalculatedFieldControllerTest extends AbstractControllerTest {
@ -84,7 +92,35 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId());
assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType());
assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName());
assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getCalculatedFieldConfig());
assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getSimpleCalculatedFieldConfig());
assertThat(savedCalculatedField.getVersion()).isEqualTo(1L);
savedCalculatedField.setName("Test CF");
CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class);
assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName());
assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1);
doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString())
.andExpect(status().isOk());
}
@Test
public void testSaveGeofencingCalculatedField() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
CalculatedField calculatedField = getCalculatedField(testDevice.getId(), getGeofencingCalculatedFieldConfig());
CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
assertThat(savedCalculatedField).isNotNull();
assertThat(savedCalculatedField.getId()).isNotNull();
assertThat(savedCalculatedField.getCreatedTime()).isGreaterThan(0);
assertThat(savedCalculatedField.getTenantId()).isEqualTo(savedTenant.getId());
assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId());
assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType());
assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName());
assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getGeofencingCalculatedFieldConfig());
assertThat(savedCalculatedField.getVersion()).isEqualTo(1L);
savedCalculatedField.setName("Test CF");
@ -128,17 +164,41 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
}
private CalculatedField getCalculatedField(DeviceId deviceId) {
return getCalculatedField(deviceId, getSimpleCalculatedFieldConfig());
}
private CalculatedField getCalculatedField(DeviceId deviceId, CalculatedFieldConfiguration configuration) {
CalculatedField calculatedField = new CalculatedField();
calculatedField.setEntityId(deviceId);
calculatedField.setType(CalculatedFieldType.SIMPLE);
calculatedField.setName("Test Calculated Field");
calculatedField.setConfigurationVersion(1);
calculatedField.setConfiguration(getCalculatedFieldConfig());
calculatedField.setConfiguration(configuration);
calculatedField.setVersion(1L);
return calculatedField;
}
private CalculatedFieldConfiguration getCalculatedFieldConfig() {
private CalculatedFieldConfiguration getGeofencingCalculatedFieldConfig() {
var config = new GeofencingCalculatedFieldConfiguration();
var refDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
refDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.TO, "FromSafeArea")));
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
zoneGroupConfiguration.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration);
Output output = new Output();
output.setType(OutputType.TIME_SERIES);
config.setEntityCoordinates(new EntityCoordinates("latitide", "longitude"));
config.setZoneGroups(Map.of("safeArea", zoneGroupConfiguration));
config.setScheduledUpdateEnabled(false);
config.setOutput(output);
return config;
}
private CalculatedFieldConfiguration getSimpleCalculatedFieldConfig() {
SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration();
Argument argument = new Argument();

23
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java

@ -28,7 +28,7 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy;
@ -41,6 +41,7 @@ import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.kv.JsonDataEntry;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
@ -257,7 +258,7 @@ public class GeofencingCalculatedFieldStateTest {
assertThat(result2).isNotNull();
assertThat(result2.getType()).isEqualTo(output.getType());
assertThat(result2.getScope()).isEqualTo(output.getScope());
assertThat(result2.getResult()).isEqualTo(
assertThat(result2.getResult().get("values")).isEqualTo(
JacksonUtil.newObjectNode()
.put("allowedZonesEvent", "LEFT")
.put("allowedZonesStatus", "OUTSIDE")
@ -329,7 +330,7 @@ public class GeofencingCalculatedFieldStateTest {
assertThat(result2).isNotNull();
assertThat(result2.getType()).isEqualTo(output.getType());
assertThat(result2.getScope()).isEqualTo(output.getScope());
assertThat(result2.getResult()).isEqualTo(
assertThat(result2.getResult().get("values")).isEqualTo(
JacksonUtil.newObjectNode()
.put("allowedZonesEvent", "LEFT")
.put("restrictedZonesEvent", "ENTERED")
@ -401,7 +402,7 @@ public class GeofencingCalculatedFieldStateTest {
assertThat(result2).isNotNull();
assertThat(result2.getType()).isEqualTo(output.getType());
assertThat(result2.getScope()).isEqualTo(output.getScope());
assertThat(result2.getResult()).isEqualTo(
assertThat(result2.getResult().get("values")).isEqualTo(
JacksonUtil.newObjectNode()
.put("allowedZonesStatus", "OUTSIDE")
.put("restrictedZonesStatus", "INSIDE")
@ -453,21 +454,15 @@ public class GeofencingCalculatedFieldStateTest {
config.setEntityCoordinates(entityCoordinates);
ZoneGroupConfiguration allowedZonesGroup = new ZoneGroupConfiguration("zone", reportStrategy, true);
var allowedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
allowedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.TO);
allowedZoneDynamicSourceConfiguration.setRelationType("AllowedZone");
allowedZoneDynamicSourceConfiguration.setMaxLevel(1);
allowedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true);
var allowedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
allowedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.TO, "AllowedZone")));
allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration);
allowedZonesGroup.setRelationType("CurrentZone");
allowedZonesGroup.setDirection(EntitySearchDirection.TO);
ZoneGroupConfiguration restrictedZonesGroup = new ZoneGroupConfiguration("zone", reportStrategy, true);
var restrictedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
restrictedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.TO);
restrictedZoneDynamicSourceConfiguration.setRelationType("RestrictedZone");
restrictedZoneDynamicSourceConfiguration.setMaxLevel(1);
restrictedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true);
var restrictedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
restrictedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.TO, "RestrictedZone")));
restrictedZonesGroup.setRefDynamicSourceConfiguration(restrictedZoneDynamicSourceConfiguration);
restrictedZonesGroup.setRelationType("CurrentZone");
restrictedZonesGroup.setDirection(EntitySearchDirection.TO);

3
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java

@ -123,7 +123,8 @@ public class SimpleCalculatedFieldStateTest {
Map<String, ArgumentEntry> newArgs = Map.of("key3", new TsRollingArgumentEntry(10, 30000L));
assertThatThrownBy(() -> state.updateState(ctx, newArgs))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Rolling argument entry is not supported for simple calculated fields.");
.hasMessage("Unsupported argument type detected for argument: key3. " +
"Rolling argument entry is not supported for simple calculated fields.");
}
@Test

3
common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java

@ -20,6 +20,7 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntityRelationInfo;
import org.thingsboard.server.common.data.relation.EntityRelationPathQuery;
import org.thingsboard.server.common.data.relation.EntityRelationsQuery;
import org.thingsboard.server.common.data.relation.RelationTypeGroup;
import org.thingsboard.server.common.data.rule.RuleChainType;
@ -83,6 +84,8 @@ public interface RelationService {
List<EntityRelation> findRuleNodeToRuleChainRelations(TenantId tenantId, RuleChainType ruleChainType, int limit);
ListenableFuture<List<EntityRelation>> findByRelationPathQueryAsync(TenantId tenantId, EntityRelationPathQuery relationPathQuery);
// TODO: This method may be useful for some validations in the future
// ListenableFuture<Boolean> checkRecursiveRelation(EntityId from, EntityId to);

2
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java

@ -17,6 +17,6 @@ package org.thingsboard.server.common.data.cf.configuration;
public enum CFArgumentDynamicSourceType {
RELATION_QUERY
RELATION_PATH_QUERY
}

2
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java

@ -26,7 +26,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
property = "type"
)
@JsonSubTypes({
@JsonSubTypes.Type(value = RelationQueryDynamicSourceConfiguration.class, name = "RELATION_QUERY")
@JsonSubTypes.Type(value = RelationPathQueryDynamicSourceConfiguration.class, name = "RELATION_PATH_QUERY")
})
@JsonIgnoreProperties(ignoreUnknown = true)
public interface CfArgumentDynamicSourceConfiguration {

71
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java

@ -0,0 +1,71 @@
/**
* Copyright © 2016-2025 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.common.data.cf.configuration;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntityRelationPathQuery;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.common.data.util.CollectionsUtil;
import java.util.List;
import java.util.NoSuchElementException;
@Data
public class RelationPathQueryDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration {
private List<RelationPathLevel> levels;
@Override
public CFArgumentDynamicSourceType getType() {
return CFArgumentDynamicSourceType.RELATION_PATH_QUERY;
}
@Override
public void validate() {
if (CollectionsUtil.isEmpty(levels)) {
throw new IllegalArgumentException("At least one relation level must be specified!");
}
levels.forEach(RelationPathLevel::validate);
}
public List<EntityId> resolveEntityIds(List<EntityRelation> relations) {
EntitySearchDirection lastLevelDirection = getLastLevel().direction();
return switch (lastLevelDirection) {
case FROM -> relations.stream().map(EntityRelation::getTo).toList();
case TO -> relations.stream().map(EntityRelation::getFrom).toList();
};
}
public void validateMaxRelationLevel(String argumentName, int maxAllowedRelationLevel) {
if (levels.size() > maxAllowedRelationLevel) {
throw new IllegalArgumentException("Max relation level is greater than configured " +
"maximum allowed relation level in tenant profile: " + maxAllowedRelationLevel + " for argument: " + argumentName);
}
}
public EntityRelationPathQuery toRelationPathQuery(EntityId entityId) {
return new EntityRelationPathQuery(entityId, levels);
}
private RelationPathLevel getLastLevel() {
return levels.get(levels.size() - 1);
}
}

86
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java

@ -1,86 +0,0 @@
/**
* Copyright © 2016-2025 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.common.data.cf.configuration;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntityRelationsQuery;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
import org.thingsboard.server.common.data.relation.RelationsSearchParameters;
import java.util.Collections;
import java.util.List;
@Data
public class RelationQueryDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration {
private int maxLevel;
private boolean fetchLastLevelOnly;
private EntitySearchDirection direction;
private String relationType;
@Override
public CFArgumentDynamicSourceType getType() {
return CFArgumentDynamicSourceType.RELATION_QUERY;
}
@Override
public void validate() {
if (maxLevel < 1) {
throw new IllegalArgumentException("Relation query dynamic source configuration max relation level can't be less than 1!");
}
if (direction == null) {
throw new IllegalArgumentException("Relation query dynamic source configuration direction must be specified!");
}
if (StringUtils.isBlank(relationType)) {
throw new IllegalArgumentException("Relation query dynamic source configuration relation type must be specified!");
}
}
@JsonIgnore
public boolean isSimpleRelation() {
return maxLevel == 1;
}
public void validateMaxRelationLevel(String argumentName, int maxAllowedRelationLevel) {
if (maxLevel > maxAllowedRelationLevel) {
throw new IllegalArgumentException("Max relation level is greater than configured " +
"maximum allowed relation level in tenant profile: " + maxAllowedRelationLevel + " for argument: " + argumentName);
}
}
public EntityRelationsQuery toEntityRelationsQuery(EntityId rootEntityId) {
if (isSimpleRelation()) {
throw new IllegalArgumentException("Entity relations query can't be created for a simple relation!");
}
var entityRelationsQuery = new EntityRelationsQuery();
entityRelationsQuery.setParameters(new RelationsSearchParameters(rootEntityId, direction, maxLevel, fetchLastLevelOnly));
entityRelationsQuery.setFilters(Collections.singletonList(new RelationEntityTypeFilter(relationType, Collections.emptyList())));
return entityRelationsQuery;
}
public List<EntityId> resolveEntityIds(List<EntityRelation> relations) {
return switch (direction) {
case FROM -> relations.stream().map(EntityRelation::getTo).toList();
case TO -> relations.stream().map(EntityRelation::getFrom).toList();
};
}
}

3
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java

@ -15,11 +15,8 @@
*/
package org.thingsboard.server.common.data.cf.configuration;
import com.fasterxml.jackson.annotation.JsonIgnore;
public interface ScheduledUpdateSupportedCalculatedFieldConfiguration extends CalculatedFieldConfiguration {
@JsonIgnore
boolean isScheduledUpdateEnabled();
int getScheduledUpdateInterval();

7
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java

@ -34,6 +34,8 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal
private EntityCoordinates entityCoordinates;
private Map<String, ZoneGroupConfiguration> zoneGroups;
private boolean scheduledUpdateEnabled;
private int scheduledUpdateInterval;
private Output output;
@ -61,11 +63,6 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal
return output;
}
@Override
public boolean isScheduledUpdateEnabled() {
return scheduledUpdateInterval > 0 && zoneGroups.values().stream().anyMatch(ZoneGroupConfiguration::hasDynamicSource);
}
@Override
public void validate() {
if (entityCoordinates == null) {

24
common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelationPathQuery.java

@ -0,0 +1,24 @@
/**
* Copyright © 2016-2025 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.common.data.relation;
import org.thingsboard.server.common.data.id.EntityId;
import java.util.List;
public record EntityRelationPathQuery(EntityId rootEntityId, List<RelationPathLevel> levels) {
}

25
application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldDynamicArgumentsRefreshMsg.java → common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationPathLevel.java

@ -13,23 +13,18 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.actors.calculatedField;
package org.thingsboard.server.common.data.relation;
import lombok.Data;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.msg.MsgType;
import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg;
import org.thingsboard.server.common.data.StringUtils;
@Data
public class CalculatedFieldDynamicArgumentsRefreshMsg implements ToCalculatedFieldSystemMsg {
public record RelationPathLevel(EntitySearchDirection direction, String relationType) {
private final TenantId tenantId;
private final CalculatedFieldId cfId;
@Override
public MsgType getMsgType() {
return MsgType.CF_DYNAMIC_ARGUMENTS_REFRESH_MSG;
public void validate() {
if (direction == null) {
throw new IllegalArgumentException("Direction must be specified!");
}
if (StringUtils.isBlank(relationType)) {
throw new IllegalArgumentException("Relation type must be specified!");
}
}
}

2
common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java

@ -173,7 +173,7 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura
@Schema(example = "10")
private long maxArgumentsPerCF = 10;
@Schema(example = "3600")
private int minAllowedScheduledUpdateIntervalInSecForCF = 3600;
private int minAllowedScheduledUpdateIntervalInSecForCF = 60;
@Schema(example = "10")
private int maxRelationLevelPerCfArgument = 10;
@Builder.Default

2
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ArgumentTest.java

@ -31,7 +31,7 @@ public class ArgumentTest {
@Test
void validateShouldReturnTrueIfDynamicSourceConfigurationIsNotNull() {
var argument = new Argument();
argument.setRefDynamicSourceConfiguration(new RelationQueryDynamicSourceConfiguration());
argument.setRefDynamicSourceConfiguration(new RelationPathQueryDynamicSourceConfiguration());
assertThat(argument.hasDynamicSource()).isTrue();
}

126
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfigurationTest.java

@ -0,0 +1,126 @@
/**
* Copyright © 2016-2025 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.common.data.cf.configuration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationPathLevel;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class RelationPathQueryDynamicSourceConfigurationTest {
@Test
void typeShouldBeRelationQuery() {
var cfg = new RelationPathQueryDynamicSourceConfiguration();
assertThat(cfg.getType()).isEqualTo(CFArgumentDynamicSourceType.RELATION_PATH_QUERY);
}
@ParameterizedTest
@NullAndEmptySource
void validateShouldThrowWhenLevelsIsNull(List<RelationPathLevel> levels) {
var cfg = new RelationPathQueryDynamicSourceConfiguration();
cfg.setLevels(levels);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("At least one relation level must be specified!");
}
@Test
void validateShouldCallValidateForPathLevels() {
List<RelationPathLevel> levels = new ArrayList<>();
RelationPathLevel lvl1 = mock(RelationPathLevel.class);
RelationPathLevel lvl2 = mock(RelationPathLevel.class);
levels.add(lvl1);
levels.add(lvl2);
var cfg = new RelationPathQueryDynamicSourceConfiguration();
cfg.setLevels(levels);
assertThatCode(cfg::validate).doesNotThrowAnyException();
verify(lvl1).validate();
verify(lvl2).validate();
}
@Test
void resolveEntityIds_whenDirectionFROM_thenReturnsToIds() {
List<RelationPathLevel> levels = new ArrayList<>();
RelationPathLevel lvl1 = mock(RelationPathLevel.class);
RelationPathLevel lvl2 = mock(RelationPathLevel.class);
levels.add(lvl1);
levels.add(lvl2);
when(lvl2.direction()).thenReturn(EntitySearchDirection.FROM);
EntityRelation rel1 = mock(EntityRelation.class);
EntityRelation rel2 = mock(EntityRelation.class);
when(rel1.getTo()).thenReturn(mock(EntityId.class));
when(rel2.getTo()).thenReturn(mock(EntityId.class));
var cfg = new RelationPathQueryDynamicSourceConfiguration();
cfg.setLevels(levels);
var out = cfg.resolveEntityIds(List.of(rel1, rel2));
assertThat(out).containsExactly(rel1.getTo(), rel2.getTo());
}
@Test
void resolveEntityIds_whenDirectionTO_thenReturnsFromIds() {
List<RelationPathLevel> levels = new ArrayList<>();
RelationPathLevel lvl1 = mock(RelationPathLevel.class);
RelationPathLevel lvl2 = mock(RelationPathLevel.class);
levels.add(lvl1);
levels.add(lvl2);
when(lvl2.direction()).thenReturn(EntitySearchDirection.TO);
EntityRelation rel1 = mock(EntityRelation.class);
EntityRelation rel2 = mock(EntityRelation.class);
when(rel1.getFrom()).thenReturn(mock(EntityId.class));
when(rel2.getFrom()).thenReturn(mock(EntityId.class));
var cfg = new RelationPathQueryDynamicSourceConfiguration();
cfg.setLevels(levels);
var out = cfg.resolveEntityIds(List.of(rel1, rel2));
assertThat(out).containsExactly(rel1.getFrom(), rel2.getFrom());
}
}

216
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfigurationTest.java

@ -1,216 +0,0 @@
/**
* Copyright © 2016-2025 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.common.data.cf.configuration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
import org.thingsboard.server.common.data.relation.RelationsSearchParameters;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class RelationQueryDynamicSourceConfigurationTest {
@Mock
EntityId rootEntityId;
@Mock
EntityRelation rel1;
@Mock
EntityRelation rel2;
@Test
void typeShouldBeRelationQuery() {
var cfg = new RelationQueryDynamicSourceConfiguration();
assertThat(cfg.getType()).isEqualTo(CFArgumentDynamicSourceType.RELATION_QUERY);
}
@Test
void validateShouldThrowWhenMaxLevelLessThanOne() {
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(0);
cfg.setDirection(EntitySearchDirection.FROM);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Relation query dynamic source configuration max relation level can't be less than 1!");
}
@Test
void validateShouldThrowWhenMaxLevelGreaterThanMaxAllowedLevelFromTenantProfile() {
int maxAllowedRelationLevel = 2;
int argumentMaxRelationLevel = 3;
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(argumentMaxRelationLevel);
cfg.setDirection(EntitySearchDirection.FROM);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
String testRelationArgument = "testRelationArgument";
assertThatThrownBy(() -> cfg.validateMaxRelationLevel(testRelationArgument, maxAllowedRelationLevel))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Max relation level is greater than configured " +
"maximum allowed relation level in tenant profile: " + maxAllowedRelationLevel + " for argument: " + testRelationArgument);
}
@Test
void validateShouldPassValidationWhenMaxLevelLessThanMaxAllowedLevelFromTenantProfile() {
int maxAllowedRelationLevel = 5;
int argumentMaxRelationLevel = 2;
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(argumentMaxRelationLevel);
cfg.setDirection(EntitySearchDirection.FROM);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
String testRelationArgument = "testRelationArgument";
assertThatCode(() -> cfg.validateMaxRelationLevel(testRelationArgument, maxAllowedRelationLevel)).doesNotThrowAnyException();
}
@Test
void validateShouldThrowWhenDirectionIsNull() {
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(1);
cfg.setDirection(null);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Relation query dynamic source configuration direction must be specified!");
}
@ParameterizedTest
@ValueSource(strings = {" "})
@NullAndEmptySource
void validateShouldThrowWhenRelationTypeIsNull(String relationType) {
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(1);
cfg.setDirection(EntitySearchDirection.TO);
cfg.setRelationType(relationType);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Relation query dynamic source configuration relation type must be specified!");
}
@ParameterizedTest
@NullAndEmptySource
void isSimpleRelationTrueWhenLevelIsOneAndEntityTypesEmptyOrNull(List<EntityType> entityTypes) {
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(1);
assertThat(cfg.isSimpleRelation()).isTrue();
}
@Test
void isSimpleRelationFalseWhenMaxLevelNotOne() {
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(2);
assertThat(cfg.isSimpleRelation()).isFalse();
}
@ParameterizedTest
@NullAndEmptySource
void toEntityRelationsQueryShouldThrowForSimpleRelation(List<EntityType> entityTypes) {
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(1);
cfg.setFetchLastLevelOnly(false);
cfg.setDirection(EntitySearchDirection.FROM);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
assertThatThrownBy(() -> cfg.toEntityRelationsQuery(rootEntityId))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Entity relations query can't be created for a simple relation!");
}
@Test
void toEntityRelationsQueryShouldBuildQueryForNonSimpleRelation() {
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(2);
cfg.setFetchLastLevelOnly(true);
cfg.setDirection(EntitySearchDirection.TO);
cfg.setRelationType(EntityRelation.MANAGES_TYPE);
var query = cfg.toEntityRelationsQuery(rootEntityId);
assertThat(query).isNotNull();
RelationsSearchParameters params = query.getParameters();
assertThat(params).isNotNull();
assertThat(params.getRootId()).isEqualTo(rootEntityId.getId());
assertThat(params.getDirection()).isEqualTo(EntitySearchDirection.TO);
assertThat(params.getMaxLevel()).isEqualTo(2);
assertThat(params.isFetchLastLevelOnly()).isTrue();
assertThat(query.getFilters()).hasSize(1);
assertThat(query.getFilters().get(0)).isInstanceOf(RelationEntityTypeFilter.class);
RelationEntityTypeFilter filter = query.getFilters().get(0);
assertThat(filter.getRelationType()).isEqualTo(EntityRelation.MANAGES_TYPE);
}
@Test
void resolveEntityIdsFromDirectionFROMReturnsToIds() {
when(rel1.getTo()).thenReturn(mock(EntityId.class));
when(rel2.getTo()).thenReturn(mock(EntityId.class));
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setDirection(EntitySearchDirection.FROM);
var out = cfg.resolveEntityIds(List.of(rel1, rel2));
assertThat(out).containsExactly(rel1.getTo(), rel2.getTo());
}
@Test
void resolveEntityIdsFromDirectionTOReturnsFromIds() {
when(rel1.getFrom()).thenReturn(mock(EntityId.class));
when(rel2.getFrom()).thenReturn(mock(EntityId.class));
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setDirection(EntitySearchDirection.TO);
var out = cfg.resolveEntityIds(List.of(rel1, rel2));
assertThat(out).containsExactly(rel1.getFrom(), rel2.getFrom());
}
@Test
void validateShouldPassForValidConfig() {
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(2);
cfg.setFetchLastLevelOnly(false);
cfg.setDirection(EntitySearchDirection.FROM);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
assertThatCode(cfg::validate).doesNotThrowAnyException();
}
}

27
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java

@ -101,33 +101,6 @@ public class GeofencingCalculatedFieldConfigurationTest {
verify(zoneGroupConfigurationB).validate(zoneGroupBName);
}
@Test
void scheduledUpdateDisabledWhenIntervalIsZero() {
var cfg = new GeofencingCalculatedFieldConfiguration();
cfg.setScheduledUpdateInterval(0);
assertThat(cfg.isScheduledUpdateEnabled()).isFalse();
}
@Test
void scheduledUpdateDisabledWhenIntervalIsGreaterThanZeroButNoZonesWithDynamicArguments() {
var cfg = new GeofencingCalculatedFieldConfiguration();
var zoneGroupConfigurationMock = mock(ZoneGroupConfiguration.class);
when(zoneGroupConfigurationMock.hasDynamicSource()).thenReturn(false);
cfg.setZoneGroups(Map.of("someGroupName", zoneGroupConfigurationMock));
cfg.setScheduledUpdateInterval(60);
assertThat(cfg.isScheduledUpdateEnabled()).isFalse();
}
@Test
void scheduledUpdateEnabledWhenIntervalIsGreaterThanZeroAndDynamicArgumentsPresent() {
var cfg = new GeofencingCalculatedFieldConfiguration();
var zoneGroupConfigurationMock = mock(ZoneGroupConfiguration.class);
when(zoneGroupConfigurationMock.hasDynamicSource()).thenReturn(true);
cfg.setZoneGroups(Map.of("someGroupName", zoneGroupConfigurationMock));
cfg.setScheduledUpdateInterval(60);
assertThat(cfg.isScheduledUpdateEnabled()).isTrue();
}
@Test
void testGetArgumentsOverride() {
var cfg = new GeofencingCalculatedFieldConfiguration();

4
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java

@ -23,7 +23,7 @@ import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
@ -100,7 +100,7 @@ public class ZoneGroupConfigurationTest {
@Test
void whenHasDynamicSourceCalled_shouldReturnTrueIfDynamicSourceConfigurationIsNotNull() {
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
zoneGroupConfiguration.setRefDynamicSourceConfiguration(new RelationQueryDynamicSourceConfiguration());
zoneGroupConfiguration.setRefDynamicSourceConfiguration(new RelationPathQueryDynamicSourceConfiguration());
assertThat(zoneGroupConfiguration.hasDynamicSource()).isTrue();
}

5
common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java

@ -147,10 +147,7 @@ public enum MsgType {
/* CF Manager Actor -> CF Entity actor */
CF_ENTITY_TELEMETRY_MSG,
CF_ENTITY_INIT_CF_MSG,
CF_ENTITY_DELETE_MSG,
CF_DYNAMIC_ARGUMENTS_REFRESH_MSG,
CF_ENTITY_DYNAMIC_ARGUMENTS_REFRESH_MSG;
CF_ENTITY_DELETE_MSG;
@Getter
private final boolean ignoreOnStart;

28
dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java

@ -32,17 +32,21 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.util.CollectionUtils;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.cache.TbTransactionalCache;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UUIDBased;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntityRelationInfo;
import org.thingsboard.server.common.data.relation.EntityRelationPathQuery;
import org.thingsboard.server.common.data.relation.EntityRelationsQuery;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.common.data.relation.RelationTypeGroup;
import org.thingsboard.server.common.data.relation.RelationsSearchParameters;
import org.thingsboard.server.common.data.rule.RuleChainType;
@ -495,6 +499,30 @@ public class BaseRelationService implements RelationService {
return relationDao.findRuleNodeToRuleChainRelations(ruleChainType, limit);
}
@Override
public ListenableFuture<List<EntityRelation>> findByRelationPathQueryAsync(TenantId tenantId, EntityRelationPathQuery relationPathQuery) {
log.trace("Executing findByRelationPathQuery, tenantId [{}], relationPathQuery {}", tenantId, relationPathQuery);
validateId(tenantId, id -> "Invalid tenant id: " + id);
validate(relationPathQuery);
if (relationPathQuery.levels().size() == 1) {
RelationPathLevel relationPathLevel = relationPathQuery.levels().get(0);
return switch (relationPathLevel.direction()) {
case FROM -> findByFromAndTypeAsync(tenantId, relationPathQuery.rootEntityId(), relationPathLevel.relationType(), RelationTypeGroup.COMMON);
case TO -> findByToAndTypeAsync(tenantId, relationPathQuery.rootEntityId(), relationPathLevel.relationType(), RelationTypeGroup.COMMON);
};
}
return executor.submit(() -> relationDao.findByRelationPathQuery(tenantId, relationPathQuery));
}
private void validate(EntityRelationPathQuery relationPathQuery) {
validateId((UUIDBased) relationPathQuery.rootEntityId(), id -> "Invalid root entity id: " + id);
List<RelationPathLevel> levels = relationPathQuery.levels();
if (CollectionUtils.isEmpty(levels)) {
throw new DataValidationException("Relation path levels should be specified!");
}
levels.forEach(RelationPathLevel::validate);
}
protected void validate(EntityRelation relation) {
if (relation == null) {
throw new DataValidationException("Relation type should be specified!");

3
dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java

@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntityRelationPathQuery;
import org.thingsboard.server.common.data.relation.RelationTypeGroup;
import org.thingsboard.server.common.data.rule.RuleChainType;
@ -71,4 +72,6 @@ public interface RelationDao {
List<EntityRelation> findRuleNodeToRuleChainRelations(RuleChainType ruleChainType, int limit);
List<EntityRelation> findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery relationPathQuery);
}

6
dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java

@ -19,7 +19,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
@ -98,10 +98,10 @@ public class CalculatedFieldDataValidator extends DataValidator<CalculatedField>
if (!(calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration argumentsBasedCfg)) {
return;
}
Map<String, RelationQueryDynamicSourceConfiguration> relationQueryBasedArguments = argumentsBasedCfg.getArguments().entrySet()
Map<String, RelationPathQueryDynamicSourceConfiguration> relationQueryBasedArguments = argumentsBasedCfg.getArguments().entrySet()
.stream()
.filter(entry -> entry.getValue().hasDynamicSource())
.collect(Collectors.toMap(Map.Entry::getKey, entry -> (RelationQueryDynamicSourceConfiguration) entry.getValue().getRefDynamicSourceConfiguration()));
.collect(Collectors.toMap(Map.Entry::getKey, entry -> (RelationPathQueryDynamicSourceConfiguration) entry.getValue().getRefDynamicSourceConfiguration()));
if (relationQueryBasedArguments.isEmpty()) {
return;
}

99
dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java

@ -25,6 +25,9 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityIdFactory;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntityRelationPathQuery;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.common.data.relation.RelationTypeGroup;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.dao.DaoUtil;
@ -43,6 +46,7 @@ import java.util.stream.Collectors;
import static org.thingsboard.server.dao.model.ModelConstants.RELATION_FROM_ID_PROPERTY;
import static org.thingsboard.server.dao.model.ModelConstants.RELATION_FROM_TYPE_PROPERTY;
import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TABLE_NAME;
import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TO_ID_PROPERTY;
import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TO_TYPE_PROPERTY;
import static org.thingsboard.server.dao.model.ModelConstants.RELATION_TYPE_GROUP_PROPERTY;
@ -293,4 +297,99 @@ public class JpaRelationDao extends JpaAbstractDaoListeningExecutorService imple
public List<EntityRelation> findRuleNodeToRuleChainRelations(RuleChainType ruleChainType, int limit) {
return DaoUtil.convertDataList(relationRepository.findRuleNodeToRuleChainRelations(ruleChainType, PageRequest.of(0, limit)));
}
@Override
public List<EntityRelation> findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery query) {
List<RelationPathLevel> levels = query.levels();
if (levels == null || levels.isEmpty()) {
return Collections.emptyList();
}
String sql = buildRelationPathSql(query);
Object[] params = buildRelationPathParams(query);
log.trace("[{}] relation path query: {}", tenantId, sql);
return jdbcTemplate.queryForList(sql, params).stream()
.map(row -> {
var entityRelation = new EntityRelation();
var fromId = (UUID) row.get(RELATION_FROM_ID_PROPERTY);
var fromType = (String) row.get(RELATION_FROM_TYPE_PROPERTY);
var toId = (UUID) row.get(RELATION_TO_ID_PROPERTY);
var toType = (String) row.get(RELATION_TO_TYPE_PROPERTY);
var grp = (String) row.get(RELATION_TYPE_GROUP_PROPERTY);
var type = (String) row.get(RELATION_TYPE_PROPERTY);
var version = (Long) row.get(VERSION_COLUMN);
entityRelation.setFrom(EntityIdFactory.getByTypeAndUuid(fromType, fromId));
entityRelation.setTo(EntityIdFactory.getByTypeAndUuid(toType, toId));
entityRelation.setType(type);
entityRelation.setTypeGroup(RelationTypeGroup.valueOf(grp));
entityRelation.setVersion(version);
return entityRelation;
})
.collect(Collectors.toList());
}
private Object[] buildRelationPathParams(EntityRelationPathQuery query) {
final List<Object> params = new ArrayList<>();
// seed
params.add(query.rootEntityId().getId());
params.add(query.rootEntityId().getEntityType().name());
// levels
for (var lvl : query.levels()) {
params.add(lvl.relationType());
}
return params.toArray();
}
private static String buildRelationPathSql(EntityRelationPathQuery query) {
List<RelationPathLevel> levels = query.levels();
StringBuilder sb = new StringBuilder();
sb.append("WITH seed AS (\n")
.append(" SELECT ?::uuid AS id, ?::varchar AS type\n")
.append(")");
String prev = "seed";
for (int i = 0; i < levels.size() - 1; i++) {
RelationPathLevel lvl = levels.get(i);
boolean down = lvl.direction() == EntitySearchDirection.FROM;
String cur = "lvl" + (i + 1);
String joinCond = down
? "r.from_id = p.id AND r.from_type = p.type"
: "r.to_id = p.id AND r.to_type = p.type";
String selectNext = down
? "r.to_id AS id, r.to_type AS type"
: "r.from_id AS id, r.from_type AS type";
sb.append(",\n").append(cur).append(" AS (\n")
.append(" SELECT ").append(selectNext).append("\n")
.append(" FROM ").append(RELATION_TABLE_NAME).append(" r\n")
.append(" JOIN ").append(prev).append(" p ON ").append(joinCond).append("\n")
.append(" WHERE r.relation_type_group = '").append(RelationTypeGroup.COMMON).append("'\n")
.append(" AND r.relation_type = ?\n")
.append(")");
prev = cur;
}
RelationPathLevel last = levels.get(levels.size() - 1);
boolean lastDown = last.direction() == EntitySearchDirection.FROM;
String prevForLast = (levels.size() == 1) ? "seed" : prev;
String lastJoin = lastDown
? "r.from_id = p.id AND r.from_type = p.type"
: "r.to_id = p.id AND r.to_type = p.type";
sb.append("\n")
.append("SELECT r.from_id, r.from_type, r.to_id, r.to_type,\n")
.append(" r.relation_type_group, r.relation_type, r.version\n")
.append("FROM ").append(RELATION_TABLE_NAME).append(" r\n")
.append("JOIN ").append(prevForLast).append(" p ON ").append(lastJoin).append("\n")
.append("WHERE r.relation_type_group = '").append(RelationTypeGroup.COMMON).append("'\n")
.append(" AND r.relation_type = ?");
return sb.toString();
}
}

109
dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java

@ -31,7 +31,7 @@ import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfig
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
@ -39,15 +39,19 @@ import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupC
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.dao.cf.CalculatedFieldService;
import org.thingsboard.server.dao.device.DeviceService;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS;
@DaoSqlTest
@ -99,53 +103,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest {
}
@Test
public void testSaveGeofencingCalculatedField_shouldNotChangeScheduledInterval() {
// Arrange a device
Device device = createTestDevice();
// Build a valid Geofencing configuration
GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
// Coordinates: TS_LATEST, no dynamic source
EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
cfg.setEntityCoordinates(entityCoordinates);
// Zone-group argument (ATTRIBUTE) — no DYNAMIC configuration, so no scheduling even if the scheduled interval is set
ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration("allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
zoneGroupConfiguration.setRefEntityId(device.getId());
cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration));
// Set a scheduled interval to some value
cfg.setScheduledUpdateInterval(600);
// Create & save Calculated Field
CalculatedField cf = new CalculatedField();
cf.setTenantId(tenantId);
cf.setEntityId(device.getId());
cf.setType(CalculatedFieldType.GEOFENCING);
cf.setName("GF clamp test");
cf.setConfigurationVersion(0);
cf.setConfiguration(cfg);
CalculatedField saved = calculatedFieldService.save(cf);
assertThat(saved).isNotNull();
assertThat(saved.getConfiguration()).isInstanceOf(GeofencingCalculatedFieldConfiguration.class);
var geofencingCalculatedFieldConfiguration = (GeofencingCalculatedFieldConfiguration) saved.getConfiguration();
// Assert: the interval is saved, but scheduling is not enabled
int savedInterval = geofencingCalculatedFieldConfiguration.getScheduledUpdateInterval();
boolean scheduledUpdateEnabled = geofencingCalculatedFieldConfiguration.isScheduledUpdateEnabled();
assertThat(savedInterval).isEqualTo(600);
assertThat(scheduledUpdateEnabled).isFalse();
calculatedFieldService.deleteCalculatedField(tenantId, saved.getId());
}
@Test
public void testSaveGeofencingCalculatedField_shouldThrowWhenScheduledIntervalIsLessThanMinAllowedIntervalInTenantProfile() {
public void testSaveGeofencingCalculatedField_shouldThrowWhenScheduledIntervalLessThanMinAllowedIntervalInTenantProfile() {
// Arrange a device
Device device = createTestDevice();
@ -158,22 +116,27 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest {
// Zone-group argument (ATTRIBUTE) — make it DYNAMIC so scheduling is enabled
ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration("allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
var dynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
dynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
dynamicSourceConfiguration.setMaxLevel(1);
dynamicSourceConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE);
var dynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
dynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE)));
zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration);
cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration));
// Get tenant profile min.
int min = tbTenantProfileCache.get(tenantId)
.getDefaultProfileConfiguration()
.getMinAllowedScheduledUpdateIntervalInSecForCF();
int valueFromConfig = min - 10;
// Enable scheduling with an interval below tenant min
cfg.setScheduledUpdateInterval(600);
cfg.setScheduledUpdateEnabled(true);
cfg.setScheduledUpdateInterval(valueFromConfig);
// Create & save Calculated Field
CalculatedField cf = new CalculatedField();
cf.setTenantId(tenantId);
cf.setEntityId(device.getId());
cf.setType(CalculatedFieldType.GEOFENCING);
cf.setName("GF clamp test");
cf.setName("GF min allowed scheduled update interval test");
cf.setConfigurationVersion(0);
cf.setConfiguration(cfg);
@ -185,23 +148,30 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest {
}
@Test
public void testSaveGeofencingCalculatedField_shouldThrowWhenRelationLevelIsGreaterThanMaxAllowedRelationLevelInTenantProfile() {
public void testSaveGeofencingCalculatedField_shouldThrowWhenRelationLevelGreaterThanMaxAllowedRelationLevelInTenantProfile() {
// Arrange a device
Device device = createTestDevice();
// Build a valid Geofencing configuration
GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
// Coordinates: TS_LATEST, no dynamic source
EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
cfg.setEntityCoordinates(entityCoordinates);
// Zone-group argument (ATTRIBUTE) — make it DYNAMIC so scheduling is enabled
int maxRelationLevel = tbTenantProfileCache.get(tenantId)
.getDefaultProfileConfiguration()
.getMaxRelationLevelPerCfArgument();
// Zone-group argument (ATTRIBUTE)
ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration( "allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
var dynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
dynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
dynamicSourceConfiguration.setMaxLevel(Integer.MAX_VALUE);
dynamicSourceConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE);
var dynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
List<RelationPathLevel> levels = new ArrayList<>();
for (int i = 0; i < maxRelationLevel + 1; i++) {
levels.add(mock(RelationPathLevel.class));
}
dynamicSourceConfiguration.setLevels(levels);
zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration);
cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration));
@ -210,7 +180,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest {
cf.setTenantId(tenantId);
cf.setEntityId(device.getId());
cf.setType(CalculatedFieldType.GEOFENCING);
cf.setName("GF clamp test");
cf.setName("GF max relation level test");
cf.setConfigurationVersion(0);
cf.setConfiguration(cfg);
@ -221,7 +191,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest {
}
@Test
public void testSaveGeofencingCalculatedField_shouldUseScheduledIntervalFromConfig() {
public void testSaveGeofencingCalculatedField_shouldSaveWithoutDataValidationExceptionOnScheduledUpdateInterval() {
// Arrange a device
Device device = createTestDevice();
@ -234,10 +204,8 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest {
// Zone-group argument (ATTRIBUTE) — make it DYNAMIC so scheduling is enabled
ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration( "allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
var dynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
dynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
dynamicSourceConfiguration.setMaxLevel(1);
dynamicSourceConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE);
var dynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
dynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE)));
zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration);
cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration));
@ -245,10 +213,10 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest {
int min = tbTenantProfileCache.get(tenantId)
.getDefaultProfileConfiguration()
.getMinAllowedScheduledUpdateIntervalInSecForCF();
int valueFromConfig = min + 100;
// Enable scheduling with an interval greater than tenant min
int valueFromConfig = min + 100;
cfg.setScheduledUpdateEnabled(true);
cfg.setScheduledUpdateInterval(valueFromConfig);
// Create & save Calculated Field
@ -256,7 +224,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest {
cf.setTenantId(tenantId);
cf.setEntityId(device.getId());
cf.setType(CalculatedFieldType.GEOFENCING);
cf.setName("GF no clamp test");
cf.setName("GF no validation error test");
cf.setConfigurationVersion(0);
cf.setConfiguration(cfg);
@ -267,7 +235,6 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest {
var geofencingCalculatedFieldConfiguration = (GeofencingCalculatedFieldConfiguration) saved.getConfiguration();
// Assert: the interval is clamped up to tenant profile min (or stays >= original if already >= min)
int savedInterval = geofencingCalculatedFieldConfiguration.getScheduledUpdateInterval();
assertThat(savedInterval).isEqualTo(valueFromConfig);

53
dao/src/test/java/org/thingsboard/server/dao/service/RelationServiceTest.java

@ -28,9 +28,11 @@ import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntityRelationPathQuery;
import org.thingsboard.server.common.data.relation.EntityRelationsQuery;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.common.data.relation.RelationTypeGroup;
import org.thingsboard.server.common.data.relation.RelationsSearchParameters;
import org.thingsboard.server.dao.exception.DataValidationException;
@ -42,6 +44,8 @@ import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import static org.assertj.core.api.Assertions.assertThat;
@DaoSqlTest
public class RelationServiceTest extends AbstractServiceTest {
@ -348,14 +352,14 @@ public class RelationServiceTest extends AbstractServiceTest {
query.setFilters(Collections.singletonList(new RelationEntityTypeFilter(EntityRelation.CONTAINS_TYPE, Collections.singletonList(EntityType.ASSET))));
List<EntityRelation> relations = relationService.findByQuery(SYSTEM_TENANT_ID, query).get();
Assert.assertEquals(expected.size(), relations.size());
for(EntityRelation r : expected){
for (EntityRelation r : expected) {
Assert.assertTrue(relations.contains(r));
}
//Test from cache
relations = relationService.findByQuery(SYSTEM_TENANT_ID, query).get();
Assert.assertEquals(expected.size(), relations.size());
for(EntityRelation r : expected){
for (EntityRelation r : expected) {
Assert.assertTrue(relations.contains(r));
}
}
@ -623,6 +627,51 @@ public class RelationServiceTest extends AbstractServiceTest {
Assert.assertTrue(relations.contains(relationF));
}
@Test
public void testFindByPathQuery() throws Exception {
/*
A
[firstLevel, TO] B
[secondLevel, TO] C
[thirdLevel, FROM] D
[thirdLevel, FROM] E
[thirdLevel, FROM] F
*/
// rootEntity
AssetId assetA = new AssetId(Uuids.timeBased());
// firstLevelEntity
AssetId assetB = new AssetId(Uuids.timeBased());
// secondLevelEntity
AssetId assetC = new AssetId(Uuids.timeBased());
// thirdLevelEntities
AssetId assetD = new AssetId(Uuids.timeBased());
AssetId assetE = new AssetId(Uuids.timeBased());
AssetId assetF = new AssetId(Uuids.timeBased());
EntityRelation firstLevelRelation = new EntityRelation(assetB, assetA, "firstLevel");
EntityRelation secondLevelRelation = new EntityRelation(assetC, assetB, "secondLevel");
EntityRelation thirdLevelRelation1 = new EntityRelation(assetC, assetD, "thirdLevel");
EntityRelation thirdLevelRelation2 = new EntityRelation(assetC, assetE, "thirdLevel");
EntityRelation thirdLevelRelation3 = new EntityRelation(assetC, assetF, "thirdLevel");
firstLevelRelation = saveRelation(firstLevelRelation);
secondLevelRelation = saveRelation(secondLevelRelation);
thirdLevelRelation1 = saveRelation(thirdLevelRelation1);
thirdLevelRelation2 = saveRelation(thirdLevelRelation2);
thirdLevelRelation3 = saveRelation(thirdLevelRelation3);
List<EntityRelation> expectedRelations = List.of(thirdLevelRelation1, thirdLevelRelation2, thirdLevelRelation3);
EntityRelationPathQuery relationPathQuery = new EntityRelationPathQuery(assetA, List.of(
new RelationPathLevel(EntitySearchDirection.TO, "firstLevel"),
new RelationPathLevel(EntitySearchDirection.TO, "secondLevel"),
new RelationPathLevel(EntitySearchDirection.FROM, "thirdLevel")
));
List<EntityRelation> entityRelations = relationService.findByRelationPathQueryAsync(tenantId, relationPathQuery).get();
assertThat(expectedRelations).containsExactlyInAnyOrderElementsOf(entityRelations);
}
@Test
public void testFindByQueryLargeHierarchyFetchAllWithUnlimLvl() throws Exception {
AssetId rootAsset = new AssetId(Uuids.timeBased());

18
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java

@ -33,7 +33,7 @@ import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates;
@ -50,10 +50,12 @@ import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.msa.AbstractContainerTest;
import org.thingsboard.server.msa.ui.utils.EntityPrototypes;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@ -366,19 +368,13 @@ public class CalculatedFieldTest extends AbstractContainerTest {
// Dynamic groups via relations
ZoneGroupConfiguration allowedZoneGroupConfiguration = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
var allowedDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
allowedDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
allowedDynamicSourceConfiguration.setMaxLevel(1);
allowedDynamicSourceConfiguration.setFetchLastLevelOnly(true);
allowedDynamicSourceConfiguration.setRelationType("AllowedZone");
var allowedDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
allowedDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "AllowedZone")));
allowedZoneGroupConfiguration.setRefDynamicSourceConfiguration(allowedDynamicSourceConfiguration);
ZoneGroupConfiguration restrictedZoneGroupConfiguration = new ZoneGroupConfiguration("zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
var restrictedDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
restrictedDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
restrictedDynamicSourceConfiguration.setMaxLevel(1);
restrictedDynamicSourceConfiguration.setFetchLastLevelOnly(true);
restrictedDynamicSourceConfiguration.setRelationType("RestrictedZone");
var restrictedDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
restrictedDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, "RestrictedZone")));
restrictedZoneGroupConfiguration.setRefDynamicSourceConfiguration(restrictedDynamicSourceConfiguration);
cfg.setZoneGroups(Map.of("allowedZones", allowedZoneGroupConfiguration, "restrictedZones", restrictedZoneGroupConfiguration));

Loading…
Cancel
Save