new relation path query

This commit is contained in:
dshvaika 2025-10-01 15:35:55 +03:00
parent 2c06aa475f
commit 909497703a
13 changed files with 295 additions and 13 deletions

View File

@ -26,6 +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.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
@ -178,6 +179,11 @@ public abstract class AbstractCalculatedFieldProcessingService {
yield Futures.transform(relationService.findByQuery(tenantId, configuration.toEntityRelationsQuery(entityId)),
configuration::resolveEntityIds, calculatedFieldCallbackExecutor);
}
case RELATION_PATH_QUERY -> {
var configuration = (RelationPathQueryDynamicSourceConfiguration) refDynamicSourceConfiguration;
yield Futures.transform(relationService.findByRelationPathQueryAsync(tenantId, configuration.toRelationPathQuery(entityId)),
configuration::resolveEntityIds, calculatedFieldCallbackExecutor);
}
};
}

View File

@ -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);

View File

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

View File

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

View File

@ -0,0 +1,73 @@
/**
* 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, RelationQueryBased {
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();
};
}
@Override
@JsonIgnore
public int getMaxLevel() {
return levels != null ? levels.size() : 0;
}
public EntityRelationPathQuery toRelationPathQuery(EntityId entityId) {
return new EntityRelationPathQuery(entityId, levels);
}
private RelationPathLevel getLastLevel() {
if (CollectionsUtil.isEmpty(levels)) {
throw new NoSuchElementException();
}
return levels.get(levels.size() - 1);
}
}

View File

@ -0,0 +1,29 @@
/**
* 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;
public interface RelationQueryBased {
int getMaxLevel();
default void validateMaxRelationLevel(String argumentName, int maxAllowedRelationLevel) {
if (getMaxLevel() > maxAllowedRelationLevel) {
throw new IllegalArgumentException("Max relation level is greater than configured " +
"maximum allowed relation level in tenant profile: " + maxAllowedRelationLevel + " for argument: " + argumentName);
}
}
}

View File

@ -29,7 +29,7 @@ import java.util.Collections;
import java.util.List;
@Data
public class RelationQueryDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration {
public class RelationQueryDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration, RelationQueryBased {
private int maxLevel;
private boolean fetchLastLevelOnly;
@ -59,13 +59,6 @@ public class RelationQueryDynamicSourceConfiguration implements CfArgumentDynami
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!");

View File

@ -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) {
}

View File

@ -0,0 +1,30 @@
/**
* 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.StringUtils;
public record RelationPathLevel(EntitySearchDirection direction, String relationType) {
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!");
}
}
}

View File

@ -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,23 @@ 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);
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!");

View File

@ -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);
}

View File

@ -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.RelationQueryBased;
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, RelationQueryBased> 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 -> (RelationQueryBased) entry.getValue().getRefDynamicSourceConfiguration()));
if (relationQueryBasedArguments.isEmpty()) {
return;
}

View File

@ -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.info("[{}] 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();
}
}