diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java index 8709e0cb68..bd3546082a 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java @@ -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); + } }; } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java index 1db5739e94..a0bc9a72e6 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java +++ b/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 findRuleNodeToRuleChainRelations(TenantId tenantId, RuleChainType ruleChainType, int limit); + ListenableFuture> findByRelationPathQueryAsync(TenantId tenantId, EntityRelationPathQuery relationPathQuery); + // TODO: This method may be useful for some validations in the future // ListenableFuture checkRecursiveRelation(EntityId from, EntityId to); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java index bd2e9b0c00..35c6cdf562 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java +++ b/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_QUERY, RELATION_PATH_QUERY } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java index f36071615e..397b1d016e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java @@ -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 { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java new file mode 100644 index 0000000000..c7889be19d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java @@ -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 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 resolveEntityIds(List 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); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryBased.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryBased.java new file mode 100644 index 0000000000..e3248b264f --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryBased.java @@ -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); + } + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java index 4e9b4252c9..120d04d40d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java @@ -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!"); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelationPathQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelationPathQuery.java new file mode 100644 index 0000000000..a81e3e0c86 --- /dev/null +++ b/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 levels) { + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationPathLevel.java b/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationPathLevel.java new file mode 100644 index 0000000000..c28135204f --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/relation/RelationPathLevel.java @@ -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!"); + } + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java b/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java index 87f7e44a1f..0ec1257bcc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java +++ b/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,23 @@ public class BaseRelationService implements RelationService { return relationDao.findRuleNodeToRuleChainRelations(ruleChainType, limit); } + @Override + public ListenableFuture> 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 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!"); diff --git a/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java b/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java index c061faed5e..ad53164ad7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java +++ b/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 findRuleNodeToRuleChainRelations(RuleChainType ruleChainType, int limit); + List findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery relationPathQuery); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java index 05b782c26c..85c30ca74e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java +++ b/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.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 if (!(calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration argumentsBasedCfg)) { return; } - Map relationQueryBasedArguments = argumentsBasedCfg.getArguments().entrySet() + Map 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; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java index 7417418f54..782c9b9d53 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java +++ b/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 findRuleNodeToRuleChainRelations(RuleChainType ruleChainType, int limit) { return DaoUtil.convertDataList(relationRepository.findRuleNodeToRuleChainRelations(ruleChainType, PageRequest.of(0, limit))); } + + @Override + public List findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery query) { + List 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 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 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(); + } + }