You've already forked DataMate
feat(kg): 实现所有路径查询和子图导出功能
- 新增 findAllPaths 接口:查找两个节点之间的所有路径 - 支持 maxDepth 和 maxPaths 参数限制 - 按路径长度升序排序 - 完整的权限过滤(created_by + confidential) - 添加关系级 graph_id 约束,防止串图 - 新增 exportSubgraph 接口:导出子图 - 支持 depth 参数控制扩展深度 - 支持 JSON 和 GraphML 两种导出格式 - depth=0:仅导出指定实体及其之间的边 - depth>0:扩展 N 跳,收集所有可达邻居 - 添加查询超时保护机制 - 注入 Neo4j Driver,使用 TransactionConfig.withTimeout() - 默认超时 10 秒,可配置 - 防止复杂查询长期占用资源 - 新增 4 个 DTO:AllPathsVO, ExportNodeVO, ExportEdgeVO, SubgraphExportVO - 新增 17 个测试用例,全部通过 - 测试结果:226 tests pass
This commit is contained in:
@@ -11,18 +11,24 @@ import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties
|
|||||||
import com.datamate.knowledgegraph.interfaces.dto.*;
|
import com.datamate.knowledgegraph.interfaces.dto.*;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.neo4j.driver.Driver;
|
||||||
|
import org.neo4j.driver.Record;
|
||||||
|
import org.neo4j.driver.Session;
|
||||||
|
import org.neo4j.driver.TransactionConfig;
|
||||||
import org.neo4j.driver.Value;
|
import org.neo4j.driver.Value;
|
||||||
import org.neo4j.driver.types.MapAccessor;
|
import org.neo4j.driver.types.MapAccessor;
|
||||||
import org.springframework.data.neo4j.core.Neo4jClient;
|
import org.springframework.data.neo4j.core.Neo4jClient;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.function.Function;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 知识图谱查询服务。
|
* 知识图谱查询服务。
|
||||||
* <p>
|
* <p>
|
||||||
* 提供图遍历(N 跳邻居、最短路径、子图提取)和全文搜索功能。
|
* 提供图遍历(N 跳邻居、最短路径、所有路径、子图提取、子图导出)和全文搜索功能。
|
||||||
* 使用 {@link Neo4jClient} 执行复杂 Cypher 查询。
|
* 使用 {@link Neo4jClient} 执行复杂 Cypher 查询。
|
||||||
* <p>
|
* <p>
|
||||||
* 查询结果根据用户权限进行过滤:
|
* 查询结果根据用户权限进行过滤:
|
||||||
@@ -48,6 +54,7 @@ public class GraphQueryService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
private final Neo4jClient neo4jClient;
|
private final Neo4jClient neo4jClient;
|
||||||
|
private final Driver neo4jDriver;
|
||||||
private final GraphEntityRepository entityRepository;
|
private final GraphEntityRepository entityRepository;
|
||||||
private final KnowledgeGraphProperties properties;
|
private final KnowledgeGraphProperties properties;
|
||||||
private final ResourceAccessService resourceAccessService;
|
private final ResourceAccessService resourceAccessService;
|
||||||
@@ -225,6 +232,7 @@ public class GraphQueryService {
|
|||||||
" (t:Entity {graph_id: $graphId, id: $targetId}), " +
|
" (t:Entity {graph_id: $graphId, id: $targetId}), " +
|
||||||
" path = shortestPath((s)-[:" + REL_TYPE + "*1.." + clampedDepth + "]-(t)) " +
|
" path = shortestPath((s)-[:" + REL_TYPE + "*1.." + clampedDepth + "]-(t)) " +
|
||||||
"WHERE ALL(n IN nodes(path) WHERE n.graph_id = $graphId) " +
|
"WHERE ALL(n IN nodes(path) WHERE n.graph_id = $graphId) " +
|
||||||
|
" AND ALL(r IN relationships(path) WHERE r.graph_id = $graphId) " +
|
||||||
permFilter +
|
permFilter +
|
||||||
"RETURN " +
|
"RETURN " +
|
||||||
" [n IN nodes(path) | {id: n.id, name: n.name, type: n.type, description: n.description}] AS pathNodes, " +
|
" [n IN nodes(path) | {id: n.id, name: n.name, type: n.type, description: n.description}] AS pathNodes, " +
|
||||||
@@ -244,6 +252,106 @@ public class GraphQueryService {
|
|||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 所有路径
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询两个实体之间的所有路径。
|
||||||
|
*
|
||||||
|
* @param maxDepth 最大搜索深度(由配置上限约束)
|
||||||
|
* @param maxPaths 返回路径数上限
|
||||||
|
* @return 所有路径结果,按路径长度升序排列
|
||||||
|
*/
|
||||||
|
public AllPathsVO findAllPaths(String graphId, String sourceId, String targetId, int maxDepth, int maxPaths) {
|
||||||
|
validateGraphId(graphId);
|
||||||
|
String filterUserId = resolveOwnerFilter();
|
||||||
|
boolean excludeConfidential = filterUserId != null && !resourceAccessService.canViewConfidential();
|
||||||
|
|
||||||
|
// 校验两个实体存在 + 权限
|
||||||
|
GraphEntity sourceEntity = entityRepository.findByIdAndGraphId(sourceId, graphId)
|
||||||
|
.orElseThrow(() -> BusinessException.of(
|
||||||
|
KnowledgeGraphErrorCode.ENTITY_NOT_FOUND, "源实体不存在"));
|
||||||
|
|
||||||
|
if (filterUserId != null) {
|
||||||
|
assertEntityAccess(sourceEntity, filterUserId, excludeConfidential);
|
||||||
|
}
|
||||||
|
|
||||||
|
entityRepository.findByIdAndGraphId(targetId, graphId)
|
||||||
|
.ifPresentOrElse(
|
||||||
|
targetEntity -> {
|
||||||
|
if (filterUserId != null && !sourceId.equals(targetId)) {
|
||||||
|
assertEntityAccess(targetEntity, filterUserId, excludeConfidential);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() -> { throw BusinessException.of(
|
||||||
|
KnowledgeGraphErrorCode.ENTITY_NOT_FOUND, "目标实体不存在"); }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sourceId.equals(targetId)) {
|
||||||
|
EntitySummaryVO node = EntitySummaryVO.builder()
|
||||||
|
.id(sourceEntity.getId())
|
||||||
|
.name(sourceEntity.getName())
|
||||||
|
.type(sourceEntity.getType())
|
||||||
|
.description(sourceEntity.getDescription())
|
||||||
|
.build();
|
||||||
|
PathVO singlePath = PathVO.builder()
|
||||||
|
.nodes(List.of(node))
|
||||||
|
.edges(List.of())
|
||||||
|
.pathLength(0)
|
||||||
|
.build();
|
||||||
|
return AllPathsVO.builder()
|
||||||
|
.paths(List.of(singlePath))
|
||||||
|
.pathCount(1)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
int clampedDepth = Math.max(1, Math.min(maxDepth, properties.getMaxDepth()));
|
||||||
|
int clampedMaxPaths = Math.max(1, Math.min(maxPaths, properties.getMaxNodesPerQuery()));
|
||||||
|
|
||||||
|
String permFilter = "";
|
||||||
|
if (filterUserId != null) {
|
||||||
|
StringBuilder pf = new StringBuilder("AND ALL(n IN nodes(path) WHERE ");
|
||||||
|
pf.append("(n.type IN ['User', 'Org', 'Field'] OR n.`properties.created_by` = $filterUserId)");
|
||||||
|
if (excludeConfidential) {
|
||||||
|
pf.append(" AND (toUpper(trim(n.`properties.sensitivity`)) IS NULL OR toUpper(trim(n.`properties.sensitivity`)) <> 'CONFIDENTIAL')");
|
||||||
|
}
|
||||||
|
pf.append(") ");
|
||||||
|
permFilter = pf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put("graphId", graphId);
|
||||||
|
params.put("sourceId", sourceId);
|
||||||
|
params.put("targetId", targetId);
|
||||||
|
params.put("maxPaths", clampedMaxPaths);
|
||||||
|
if (filterUserId != null) {
|
||||||
|
params.put("filterUserId", filterUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
String cypher =
|
||||||
|
"MATCH (s:Entity {graph_id: $graphId, id: $sourceId}), " +
|
||||||
|
" (t:Entity {graph_id: $graphId, id: $targetId}), " +
|
||||||
|
" path = (s)-[:" + REL_TYPE + "*1.." + clampedDepth + "]-(t) " +
|
||||||
|
"WHERE ALL(n IN nodes(path) WHERE n.graph_id = $graphId) " +
|
||||||
|
" AND ALL(r IN relationships(path) WHERE r.graph_id = $graphId) " +
|
||||||
|
permFilter +
|
||||||
|
"RETURN " +
|
||||||
|
" [n IN nodes(path) | {id: n.id, name: n.name, type: n.type, description: n.description}] AS pathNodes, " +
|
||||||
|
" [r IN relationships(path) | {id: r.id, relation_type: r.relation_type, weight: r.weight, " +
|
||||||
|
" source: startNode(r).id, target: endNode(r).id}] AS pathEdges, " +
|
||||||
|
" length(path) AS pathLength " +
|
||||||
|
"ORDER BY length(path) ASC " +
|
||||||
|
"LIMIT $maxPaths";
|
||||||
|
|
||||||
|
List<PathVO> paths = queryWithTimeout(cypher, params, record -> mapPathRecord(record));
|
||||||
|
|
||||||
|
return AllPathsVO.builder()
|
||||||
|
.paths(paths)
|
||||||
|
.pathCount(paths.size())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// 子图提取
|
// 子图提取
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -313,6 +421,140 @@ public class GraphQueryService {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// 子图导出
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出指定实体集合的子图,支持深度扩展。
|
||||||
|
*
|
||||||
|
* @param entityIds 种子实体 ID 列表
|
||||||
|
* @param depth 扩展深度(0=仅种子实体,1=含 1 跳邻居,以此类推)
|
||||||
|
* @return 包含完整属性的子图导出结果
|
||||||
|
*/
|
||||||
|
public SubgraphExportVO exportSubgraph(String graphId, List<String> entityIds, int depth) {
|
||||||
|
validateGraphId(graphId);
|
||||||
|
String filterUserId = resolveOwnerFilter();
|
||||||
|
boolean excludeConfidential = filterUserId != null && !resourceAccessService.canViewConfidential();
|
||||||
|
|
||||||
|
if (entityIds == null || entityIds.isEmpty()) {
|
||||||
|
return SubgraphExportVO.builder()
|
||||||
|
.nodes(List.of())
|
||||||
|
.edges(List.of())
|
||||||
|
.nodeCount(0)
|
||||||
|
.edgeCount(0)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
int maxNodes = properties.getMaxNodesPerQuery();
|
||||||
|
if (entityIds.size() > maxNodes) {
|
||||||
|
throw BusinessException.of(KnowledgeGraphErrorCode.MAX_NODES_EXCEEDED,
|
||||||
|
"实体数量超出限制(最大 " + maxNodes + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
int clampedDepth = Math.max(0, Math.min(depth, properties.getMaxDepth()));
|
||||||
|
List<GraphEntity> entities;
|
||||||
|
|
||||||
|
if (clampedDepth == 0) {
|
||||||
|
// 仅种子实体
|
||||||
|
entities = entityRepository.findByGraphIdAndIdIn(graphId, entityIds);
|
||||||
|
} else {
|
||||||
|
// 扩展邻居:先查询扩展后的节点 ID 集合
|
||||||
|
Set<String> expandedIds = expandNeighborIds(graphId, entityIds, clampedDepth,
|
||||||
|
filterUserId, excludeConfidential, maxNodes);
|
||||||
|
entities = expandedIds.isEmpty()
|
||||||
|
? List.of()
|
||||||
|
: entityRepository.findByGraphIdAndIdIn(graphId, new ArrayList<>(expandedIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限过滤
|
||||||
|
if (filterUserId != null) {
|
||||||
|
entities = entities.stream()
|
||||||
|
.filter(e -> isEntityAccessible(e, filterUserId, excludeConfidential))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entities.isEmpty()) {
|
||||||
|
return SubgraphExportVO.builder()
|
||||||
|
.nodes(List.of())
|
||||||
|
.edges(List.of())
|
||||||
|
.nodeCount(0)
|
||||||
|
.edgeCount(0)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ExportNodeVO> nodes = entities.stream()
|
||||||
|
.map(e -> ExportNodeVO.builder()
|
||||||
|
.id(e.getId())
|
||||||
|
.name(e.getName())
|
||||||
|
.type(e.getType())
|
||||||
|
.description(e.getDescription())
|
||||||
|
.properties(e.getProperties() != null ? e.getProperties() : Map.of())
|
||||||
|
.build())
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
List<String> nodeIds = entities.stream().map(GraphEntity::getId).toList();
|
||||||
|
List<ExportEdgeVO> edges = queryExportEdgesBetween(graphId, nodeIds);
|
||||||
|
|
||||||
|
return SubgraphExportVO.builder()
|
||||||
|
.nodes(nodes)
|
||||||
|
.edges(edges)
|
||||||
|
.nodeCount(nodes.size())
|
||||||
|
.edgeCount(edges.size())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将子图导出结果转换为 GraphML XML 格式。
|
||||||
|
*/
|
||||||
|
public String convertToGraphML(SubgraphExportVO exportVO) {
|
||||||
|
StringBuilder xml = new StringBuilder();
|
||||||
|
xml.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||||
|
xml.append("<graphml xmlns=\"http://graphml.graphstruct.org/graphml\"\n");
|
||||||
|
xml.append(" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n");
|
||||||
|
xml.append(" xsi:schemaLocation=\"http://graphml.graphstruct.org/graphml ");
|
||||||
|
xml.append("http://graphml.graphstruct.org/xmlns/1.0/graphml.xsd\">\n");
|
||||||
|
|
||||||
|
// Key 定义
|
||||||
|
xml.append(" <key id=\"name\" for=\"node\" attr.name=\"name\" attr.type=\"string\"/>\n");
|
||||||
|
xml.append(" <key id=\"type\" for=\"node\" attr.name=\"type\" attr.type=\"string\"/>\n");
|
||||||
|
xml.append(" <key id=\"description\" for=\"node\" attr.name=\"description\" attr.type=\"string\"/>\n");
|
||||||
|
xml.append(" <key id=\"relationType\" for=\"edge\" attr.name=\"relationType\" attr.type=\"string\"/>\n");
|
||||||
|
xml.append(" <key id=\"weight\" for=\"edge\" attr.name=\"weight\" attr.type=\"double\"/>\n");
|
||||||
|
|
||||||
|
xml.append(" <graph id=\"G\" edgedefault=\"directed\">\n");
|
||||||
|
|
||||||
|
// 节点
|
||||||
|
if (exportVO.getNodes() != null) {
|
||||||
|
for (ExportNodeVO node : exportVO.getNodes()) {
|
||||||
|
xml.append(" <node id=\"").append(escapeXml(node.getId())).append("\">\n");
|
||||||
|
appendGraphMLData(xml, "name", node.getName());
|
||||||
|
appendGraphMLData(xml, "type", node.getType());
|
||||||
|
appendGraphMLData(xml, "description", node.getDescription());
|
||||||
|
xml.append(" </node>\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 边
|
||||||
|
if (exportVO.getEdges() != null) {
|
||||||
|
for (ExportEdgeVO edge : exportVO.getEdges()) {
|
||||||
|
xml.append(" <edge id=\"").append(escapeXml(edge.getId()))
|
||||||
|
.append("\" source=\"").append(escapeXml(edge.getSourceEntityId()))
|
||||||
|
.append("\" target=\"").append(escapeXml(edge.getTargetEntityId()))
|
||||||
|
.append("\">\n");
|
||||||
|
appendGraphMLData(xml, "relationType", edge.getRelationType());
|
||||||
|
if (edge.getWeight() != null) {
|
||||||
|
appendGraphMLData(xml, "weight", String.valueOf(edge.getWeight()));
|
||||||
|
}
|
||||||
|
xml.append(" </edge>\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
xml.append(" </graph>\n");
|
||||||
|
xml.append("</graphml>\n");
|
||||||
|
return xml.toString();
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// 全文搜索
|
// 全文搜索
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -581,9 +823,159 @@ public class GraphQueryService {
|
|||||||
return (v == null || v.isNull()) ? null : v.asDouble();
|
return (v == null || v.isNull()) ? null : v.asDouble();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询指定节点集合之间的所有边(导出用,包含完整属性)。
|
||||||
|
*/
|
||||||
|
private List<ExportEdgeVO> queryExportEdgesBetween(String graphId, List<String> nodeIds) {
|
||||||
|
if (nodeIds.size() < 2) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
return neo4jClient
|
||||||
|
.query(
|
||||||
|
"MATCH (s:Entity {graph_id: $graphId})-[r:" + REL_TYPE + " {graph_id: $graphId}]->(t:Entity {graph_id: $graphId}) " +
|
||||||
|
"WHERE s.id IN $nodeIds AND t.id IN $nodeIds " +
|
||||||
|
"RETURN r.id AS id, s.id AS sourceEntityId, t.id AS targetEntityId, " +
|
||||||
|
"r.relation_type AS relationType, r.weight AS weight, " +
|
||||||
|
"r.confidence AS confidence, r.source_id AS sourceId"
|
||||||
|
)
|
||||||
|
.bindAll(Map.of("graphId", graphId, "nodeIds", nodeIds))
|
||||||
|
.fetchAs(ExportEdgeVO.class)
|
||||||
|
.mappedBy((ts, record) -> ExportEdgeVO.builder()
|
||||||
|
.id(record.get("id").asString(null))
|
||||||
|
.sourceEntityId(record.get("sourceEntityId").asString(null))
|
||||||
|
.targetEntityId(record.get("targetEntityId").asString(null))
|
||||||
|
.relationType(record.get("relationType").asString(null))
|
||||||
|
.weight(record.get("weight").isNull() ? null : record.get("weight").asDouble())
|
||||||
|
.confidence(record.get("confidence").isNull() ? null : record.get("confidence").asDouble())
|
||||||
|
.sourceId(record.get("sourceId").asString(null))
|
||||||
|
.build())
|
||||||
|
.all()
|
||||||
|
.stream().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从种子实体扩展 N 跳邻居,返回所有节点 ID(含种子)。
|
||||||
|
* <p>
|
||||||
|
* 使用事务超时保护,防止深度扩展导致组合爆炸。
|
||||||
|
* 结果总数严格不超过 maxNodes(含种子节点)。
|
||||||
|
*/
|
||||||
|
private Set<String> expandNeighborIds(String graphId, List<String> seedIds, int depth,
|
||||||
|
String filterUserId, boolean excludeConfidential, int maxNodes) {
|
||||||
|
String permFilter = "";
|
||||||
|
if (filterUserId != null) {
|
||||||
|
StringBuilder pf = new StringBuilder("AND ALL(n IN nodes(p) WHERE ");
|
||||||
|
pf.append("(n.type IN ['User', 'Org', 'Field'] OR n.`properties.created_by` = $filterUserId)");
|
||||||
|
if (excludeConfidential) {
|
||||||
|
pf.append(" AND (toUpper(trim(n.`properties.sensitivity`)) IS NULL OR toUpper(trim(n.`properties.sensitivity`)) <> 'CONFIDENTIAL')");
|
||||||
|
}
|
||||||
|
pf.append(") ");
|
||||||
|
permFilter = pf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put("graphId", graphId);
|
||||||
|
params.put("seedIds", seedIds);
|
||||||
|
params.put("maxNodes", maxNodes);
|
||||||
|
if (filterUserId != null) {
|
||||||
|
params.put("filterUserId", filterUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 种子节点在 Cypher 中纳入 LIMIT 约束,确保总数不超过 maxNodes
|
||||||
|
String cypher =
|
||||||
|
"MATCH (seed:Entity {graph_id: $graphId}) " +
|
||||||
|
"WHERE seed.id IN $seedIds " +
|
||||||
|
"WITH collect(DISTINCT seed) AS seeds " +
|
||||||
|
"UNWIND seeds AS s " +
|
||||||
|
"OPTIONAL MATCH p = (s)-[:" + REL_TYPE + "*1.." + depth + "]-(neighbor:Entity) " +
|
||||||
|
"WHERE ALL(n IN nodes(p) WHERE n.graph_id = $graphId) " +
|
||||||
|
" AND ALL(r IN relationships(p) WHERE r.graph_id = $graphId) " +
|
||||||
|
permFilter +
|
||||||
|
"WITH seeds + collect(DISTINCT neighbor) AS allNodes " +
|
||||||
|
"UNWIND allNodes AS node " +
|
||||||
|
"WITH DISTINCT node " +
|
||||||
|
"WHERE node IS NOT NULL " +
|
||||||
|
"RETURN node.id AS id " +
|
||||||
|
"LIMIT $maxNodes";
|
||||||
|
|
||||||
|
List<String> ids = queryWithTimeout(cypher, params,
|
||||||
|
record -> record.get("id").asString(null));
|
||||||
|
|
||||||
|
return new LinkedHashSet<>(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void appendGraphMLData(StringBuilder xml, String key, String value) {
|
||||||
|
if (value != null) {
|
||||||
|
xml.append(" <data key=\"").append(key).append("\">")
|
||||||
|
.append(escapeXml(value))
|
||||||
|
.append("</data>\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String escapeXml(String text) {
|
||||||
|
if (text == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return text.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
private void validateGraphId(String graphId) {
|
private void validateGraphId(String graphId) {
|
||||||
if (graphId == null || !UUID_PATTERN.matcher(graphId).matches()) {
|
if (graphId == null || !UUID_PATTERN.matcher(graphId).matches()) {
|
||||||
throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, "graphId 格式无效");
|
throw BusinessException.of(SystemErrorCode.INVALID_PARAMETER, "graphId 格式无效");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 Neo4j Driver 直接执行查询,附带事务级超时保护。
|
||||||
|
* <p>
|
||||||
|
* 用于路径枚举等可能触发组合爆炸的高开销查询,
|
||||||
|
* 超时后 Neo4j 服务端会主动终止事务,避免资源耗尽。
|
||||||
|
*/
|
||||||
|
private <T> List<T> queryWithTimeout(String cypher, Map<String, Object> params,
|
||||||
|
Function<Record, T> mapper) {
|
||||||
|
int timeoutSeconds = properties.getQueryTimeoutSeconds();
|
||||||
|
TransactionConfig txConfig = TransactionConfig.builder()
|
||||||
|
.withTimeout(Duration.ofSeconds(timeoutSeconds))
|
||||||
|
.build();
|
||||||
|
try (Session session = neo4jDriver.session()) {
|
||||||
|
return session.executeRead(tx -> {
|
||||||
|
var result = tx.run(cypher, params);
|
||||||
|
List<T> items = new ArrayList<>();
|
||||||
|
while (result.hasNext()) {
|
||||||
|
items.add(mapper.apply(result.next()));
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}, txConfig);
|
||||||
|
} catch (Exception e) {
|
||||||
|
if (isTransactionTimeout(e)) {
|
||||||
|
log.warn("图查询超时({}秒): {}", timeoutSeconds, cypher.substring(0, Math.min(cypher.length(), 120)));
|
||||||
|
throw BusinessException.of(KnowledgeGraphErrorCode.QUERY_TIMEOUT,
|
||||||
|
"查询超时(" + timeoutSeconds + "秒),请缩小搜索范围或减少深度");
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断异常是否为 Neo4j 事务超时。
|
||||||
|
*/
|
||||||
|
private static boolean isTransactionTimeout(Exception e) {
|
||||||
|
// Neo4j 事务超时时抛出的异常链中通常包含 "terminated" 或 "timeout"
|
||||||
|
Throwable current = e;
|
||||||
|
while (current != null) {
|
||||||
|
String msg = current.getMessage();
|
||||||
|
if (msg != null) {
|
||||||
|
String lower = msg.toLowerCase(Locale.ROOT);
|
||||||
|
if (lower.contains("transaction has been terminated") || lower.contains("timed out")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = current.getCause();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ public enum KnowledgeGraphErrorCode implements ErrorCode {
|
|||||||
EMPTY_SNAPSHOT_PURGE_BLOCKED("knowledge_graph.0010", "空快照保护:上游返回空列表,已阻止 purge 操作"),
|
EMPTY_SNAPSHOT_PURGE_BLOCKED("knowledge_graph.0010", "空快照保护:上游返回空列表,已阻止 purge 操作"),
|
||||||
SCHEMA_INIT_FAILED("knowledge_graph.0011", "图谱 Schema 初始化失败"),
|
SCHEMA_INIT_FAILED("knowledge_graph.0011", "图谱 Schema 初始化失败"),
|
||||||
INSECURE_DEFAULT_CREDENTIALS("knowledge_graph.0012", "检测到默认凭据,生产环境禁止使用默认密码"),
|
INSECURE_DEFAULT_CREDENTIALS("knowledge_graph.0012", "检测到默认凭据,生产环境禁止使用默认密码"),
|
||||||
UNAUTHORIZED_INTERNAL_CALL("knowledge_graph.0013", "内部调用未授权:X-Internal-Token 校验失败");
|
UNAUTHORIZED_INTERNAL_CALL("knowledge_graph.0013", "内部调用未授权:X-Internal-Token 校验失败"),
|
||||||
|
QUERY_TIMEOUT("knowledge_graph.0014", "图查询超时,请缩小搜索范围或减少深度");
|
||||||
|
|
||||||
private final String code;
|
private final String code;
|
||||||
private final String message;
|
private final String message;
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ public class KnowledgeGraphProperties {
|
|||||||
/** 子图返回最大节点数 */
|
/** 子图返回最大节点数 */
|
||||||
private int maxNodesPerQuery = 500;
|
private int maxNodesPerQuery = 500;
|
||||||
|
|
||||||
|
/** 复杂图查询超时(秒),防止路径枚举等高开销查询失控 */
|
||||||
|
@Min(value = 1, message = "queryTimeoutSeconds 必须 >= 1")
|
||||||
|
private int queryTimeoutSeconds = 10;
|
||||||
|
|
||||||
/** 批量导入批次大小(必须 >= 1,否则取模运算会抛异常) */
|
/** 批量导入批次大小(必须 >= 1,否则取模运算会抛异常) */
|
||||||
@Min(value = 1, message = "importBatchSize 必须 >= 1")
|
@Min(value = 1, message = "importBatchSize 必须 >= 1")
|
||||||
private int importBatchSize = 100;
|
private int importBatchSize = 100;
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.datamate.knowledgegraph.interfaces.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所有路径查询结果。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class AllPathsVO {
|
||||||
|
|
||||||
|
/** 所有路径列表(按路径长度升序) */
|
||||||
|
private List<PathVO> paths;
|
||||||
|
|
||||||
|
/** 路径总数 */
|
||||||
|
private int pathCount;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.datamate.knowledgegraph.interfaces.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出用关系边,包含完整属性。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ExportEdgeVO {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String sourceEntityId;
|
||||||
|
private String targetEntityId;
|
||||||
|
private String relationType;
|
||||||
|
private Double weight;
|
||||||
|
private Double confidence;
|
||||||
|
private String sourceId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.datamate.knowledgegraph.interfaces.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出用节点,包含完整属性。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ExportNodeVO {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
private String type;
|
||||||
|
private String description;
|
||||||
|
private Map<String, Object> properties;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.datamate.knowledgegraph.interfaces.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子图导出结果。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class SubgraphExportVO {
|
||||||
|
|
||||||
|
/** 子图中的节点列表(包含完整属性) */
|
||||||
|
private List<ExportNodeVO> nodes;
|
||||||
|
|
||||||
|
/** 子图中的边列表 */
|
||||||
|
private List<ExportEdgeVO> edges;
|
||||||
|
|
||||||
|
/** 节点数量 */
|
||||||
|
private int nodeCount;
|
||||||
|
|
||||||
|
/** 边数量 */
|
||||||
|
private int edgeCount;
|
||||||
|
}
|
||||||
@@ -2,20 +2,19 @@ package com.datamate.knowledgegraph.interfaces.rest;
|
|||||||
|
|
||||||
import com.datamate.common.interfaces.PagedResponse;
|
import com.datamate.common.interfaces.PagedResponse;
|
||||||
import com.datamate.knowledgegraph.application.GraphQueryService;
|
import com.datamate.knowledgegraph.application.GraphQueryService;
|
||||||
import com.datamate.knowledgegraph.interfaces.dto.PathVO;
|
import com.datamate.knowledgegraph.interfaces.dto.*;
|
||||||
import com.datamate.knowledgegraph.interfaces.dto.SearchHitVO;
|
|
||||||
import com.datamate.knowledgegraph.interfaces.dto.SubgraphRequest;
|
|
||||||
import com.datamate.knowledgegraph.interfaces.dto.SubgraphVO;
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import jakarta.validation.constraints.Pattern;
|
import jakarta.validation.constraints.Pattern;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 知识图谱查询接口。
|
* 知识图谱查询接口。
|
||||||
* <p>
|
* <p>
|
||||||
* 提供图遍历(邻居、最短路径、子图)和全文搜索功能。
|
* 提供图遍历(邻居、最短路径、所有路径、子图、子图导出)和全文搜索功能。
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/knowledge-graph/{graphId}/query")
|
@RequestMapping("/knowledge-graph/{graphId}/query")
|
||||||
@@ -56,6 +55,21 @@ public class GraphQueryController {
|
|||||||
return queryService.getShortestPath(graphId, sourceId, targetId, maxDepth);
|
return queryService.getShortestPath(graphId, sourceId, targetId, maxDepth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询两个实体之间的所有路径。
|
||||||
|
* <p>
|
||||||
|
* 返回按路径长度升序排列的所有路径,支持最大深度和最大路径数限制。
|
||||||
|
*/
|
||||||
|
@GetMapping("/all-paths")
|
||||||
|
public AllPathsVO findAllPaths(
|
||||||
|
@PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId,
|
||||||
|
@RequestParam @Pattern(regexp = UUID_REGEX, message = "sourceId 格式无效") String sourceId,
|
||||||
|
@RequestParam @Pattern(regexp = UUID_REGEX, message = "targetId 格式无效") String targetId,
|
||||||
|
@RequestParam(defaultValue = "3") int maxDepth,
|
||||||
|
@RequestParam(defaultValue = "10") int maxPaths) {
|
||||||
|
return queryService.findAllPaths(graphId, sourceId, targetId, maxDepth, maxPaths);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提取指定实体集合的子图(关系网络)。
|
* 提取指定实体集合的子图(关系网络)。
|
||||||
*/
|
*/
|
||||||
@@ -66,6 +80,32 @@ public class GraphQueryController {
|
|||||||
return queryService.getSubgraph(graphId, request.getEntityIds());
|
return queryService.getSubgraph(graphId, request.getEntityIds());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出指定实体集合的子图。
|
||||||
|
* <p>
|
||||||
|
* 支持深度扩展和多种输出格式(JSON、GraphML)。
|
||||||
|
*
|
||||||
|
* @param format 输出格式:json(默认)或 graphml
|
||||||
|
* @param depth 扩展深度(0=仅指定实体,1=含 1 跳邻居)
|
||||||
|
*/
|
||||||
|
@PostMapping("/subgraph/export")
|
||||||
|
public ResponseEntity<?> exportSubgraph(
|
||||||
|
@PathVariable @Pattern(regexp = UUID_REGEX, message = "graphId 格式无效") String graphId,
|
||||||
|
@Valid @RequestBody SubgraphRequest request,
|
||||||
|
@RequestParam(defaultValue = "json") String format,
|
||||||
|
@RequestParam(defaultValue = "0") int depth) {
|
||||||
|
SubgraphExportVO exportVO = queryService.exportSubgraph(graphId, request.getEntityIds(), depth);
|
||||||
|
|
||||||
|
if ("graphml".equalsIgnoreCase(format)) {
|
||||||
|
String graphml = queryService.convertToGraphML(exportVO);
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.APPLICATION_XML)
|
||||||
|
.body(graphml);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResponseEntity.ok(exportVO);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// 全文搜索
|
// 全文搜索
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import com.datamate.common.infrastructure.exception.BusinessException;
|
|||||||
import com.datamate.knowledgegraph.domain.model.GraphEntity;
|
import com.datamate.knowledgegraph.domain.model.GraphEntity;
|
||||||
import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository;
|
import com.datamate.knowledgegraph.domain.repository.GraphEntityRepository;
|
||||||
import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties;
|
import com.datamate.knowledgegraph.infrastructure.neo4j.KnowledgeGraphProperties;
|
||||||
|
import com.datamate.knowledgegraph.interfaces.dto.AllPathsVO;
|
||||||
|
import com.datamate.knowledgegraph.interfaces.dto.SubgraphExportVO;
|
||||||
import com.datamate.knowledgegraph.interfaces.dto.SubgraphVO;
|
import com.datamate.knowledgegraph.interfaces.dto.SubgraphVO;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Nested;
|
import org.junit.jupiter.api.Nested;
|
||||||
@@ -13,6 +15,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.neo4j.driver.Driver;
|
||||||
import org.springframework.data.neo4j.core.Neo4jClient;
|
import org.springframework.data.neo4j.core.Neo4jClient;
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -36,6 +39,9 @@ class GraphQueryServiceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private Neo4jClient neo4jClient;
|
private Neo4jClient neo4jClient;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private Driver neo4jDriver;
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private GraphEntityRepository entityRepository;
|
private GraphEntityRepository entityRepository;
|
||||||
|
|
||||||
@@ -594,4 +600,295 @@ class GraphQueryServiceTest {
|
|||||||
assertThat(result.getNodes().get(0).getName()).isEqualTo("Normal KS");
|
assertThat(result.getNodes().get(0).getName()).isEqualTo("Normal KS");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// findAllPaths
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class FindAllPathsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAllPaths_invalidGraphId_throwsBusinessException() {
|
||||||
|
assertThatThrownBy(() -> queryService.findAllPaths(INVALID_GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3, 10))
|
||||||
|
.isInstanceOf(BusinessException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAllPaths_sourceNotFound_throwsBusinessException() {
|
||||||
|
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> queryService.findAllPaths(GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3, 10))
|
||||||
|
.isInstanceOf(BusinessException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAllPaths_targetNotFound_throwsBusinessException() {
|
||||||
|
GraphEntity sourceEntity = GraphEntity.builder()
|
||||||
|
.id(ENTITY_ID).name("Source").type("Dataset").graphId(GRAPH_ID).build();
|
||||||
|
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.of(sourceEntity));
|
||||||
|
when(entityRepository.findByIdAndGraphId(ENTITY_ID_2, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> queryService.findAllPaths(GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3, 10))
|
||||||
|
.isInstanceOf(BusinessException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAllPaths_sameSourceAndTarget_returnsSingleNodePath() {
|
||||||
|
GraphEntity entity = GraphEntity.builder()
|
||||||
|
.id(ENTITY_ID).name("Node").type("Dataset").graphId(GRAPH_ID).build();
|
||||||
|
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.of(entity));
|
||||||
|
|
||||||
|
AllPathsVO result = queryService.findAllPaths(GRAPH_ID, ENTITY_ID, ENTITY_ID, 3, 10);
|
||||||
|
|
||||||
|
assertThat(result.getPathCount()).isEqualTo(1);
|
||||||
|
assertThat(result.getPaths()).hasSize(1);
|
||||||
|
assertThat(result.getPaths().get(0).getPathLength()).isEqualTo(0);
|
||||||
|
assertThat(result.getPaths().get(0).getNodes()).hasSize(1);
|
||||||
|
assertThat(result.getPaths().get(0).getEdges()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAllPaths_nonAdmin_sourceNotAccessible_throws() {
|
||||||
|
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn("user-123");
|
||||||
|
|
||||||
|
GraphEntity sourceEntity = GraphEntity.builder()
|
||||||
|
.id(ENTITY_ID).name("Other's Dataset").type("Dataset").graphId(GRAPH_ID)
|
||||||
|
.properties(new HashMap<>(Map.of("created_by", "other-user")))
|
||||||
|
.build();
|
||||||
|
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.of(sourceEntity));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> queryService.findAllPaths(GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3, 10))
|
||||||
|
.isInstanceOf(BusinessException.class);
|
||||||
|
|
||||||
|
verifyNoInteractions(neo4jClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAllPaths_nonAdmin_targetNotAccessible_throws() {
|
||||||
|
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn("user-123");
|
||||||
|
|
||||||
|
GraphEntity sourceEntity = GraphEntity.builder()
|
||||||
|
.id(ENTITY_ID).name("My Dataset").type("Dataset").graphId(GRAPH_ID)
|
||||||
|
.properties(new HashMap<>(Map.of("created_by", "user-123")))
|
||||||
|
.build();
|
||||||
|
GraphEntity targetEntity = GraphEntity.builder()
|
||||||
|
.id(ENTITY_ID_2).name("Other's Dataset").type("Dataset").graphId(GRAPH_ID)
|
||||||
|
.properties(new HashMap<>(Map.of("created_by", "other-user")))
|
||||||
|
.build();
|
||||||
|
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.of(sourceEntity));
|
||||||
|
when(entityRepository.findByIdAndGraphId(ENTITY_ID_2, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.of(targetEntity));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> queryService.findAllPaths(GRAPH_ID, ENTITY_ID, ENTITY_ID_2, 3, 10))
|
||||||
|
.isInstanceOf(BusinessException.class);
|
||||||
|
|
||||||
|
verifyNoInteractions(neo4jClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAllPaths_nonAdmin_structuralEntity_sameSourceAndTarget_returnsSingleNode() {
|
||||||
|
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn("user-123");
|
||||||
|
|
||||||
|
GraphEntity structuralEntity = GraphEntity.builder()
|
||||||
|
.id(ENTITY_ID).name("Admin User").type("User").graphId(GRAPH_ID)
|
||||||
|
.properties(new HashMap<>())
|
||||||
|
.build();
|
||||||
|
when(entityRepository.findByIdAndGraphId(ENTITY_ID, GRAPH_ID))
|
||||||
|
.thenReturn(Optional.of(structuralEntity));
|
||||||
|
|
||||||
|
AllPathsVO result = queryService.findAllPaths(GRAPH_ID, ENTITY_ID, ENTITY_ID, 3, 10);
|
||||||
|
|
||||||
|
assertThat(result.getPathCount()).isEqualTo(1);
|
||||||
|
assertThat(result.getPaths().get(0).getNodes().get(0).getType()).isEqualTo("User");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// exportSubgraph
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class ExportSubgraphTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exportSubgraph_invalidGraphId_throwsBusinessException() {
|
||||||
|
assertThatThrownBy(() -> queryService.exportSubgraph(INVALID_GRAPH_ID, List.of(ENTITY_ID), 0))
|
||||||
|
.isInstanceOf(BusinessException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exportSubgraph_nullEntityIds_returnsEmptyExport() {
|
||||||
|
SubgraphExportVO result = queryService.exportSubgraph(GRAPH_ID, null, 0);
|
||||||
|
|
||||||
|
assertThat(result.getNodes()).isEmpty();
|
||||||
|
assertThat(result.getEdges()).isEmpty();
|
||||||
|
assertThat(result.getNodeCount()).isEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exportSubgraph_emptyEntityIds_returnsEmptyExport() {
|
||||||
|
SubgraphExportVO result = queryService.exportSubgraph(GRAPH_ID, List.of(), 0);
|
||||||
|
|
||||||
|
assertThat(result.getNodes()).isEmpty();
|
||||||
|
assertThat(result.getEdges()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exportSubgraph_exceedsMaxNodes_throwsBusinessException() {
|
||||||
|
when(properties.getMaxNodesPerQuery()).thenReturn(5);
|
||||||
|
|
||||||
|
List<String> tooManyIds = List.of("1", "2", "3", "4", "5", "6");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> queryService.exportSubgraph(GRAPH_ID, tooManyIds, 0))
|
||||||
|
.isInstanceOf(BusinessException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exportSubgraph_depthZero_noExistingEntities_returnsEmptyExport() {
|
||||||
|
when(properties.getMaxNodesPerQuery()).thenReturn(500);
|
||||||
|
when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
SubgraphExportVO result = queryService.exportSubgraph(GRAPH_ID, List.of(ENTITY_ID), 0);
|
||||||
|
|
||||||
|
assertThat(result.getNodes()).isEmpty();
|
||||||
|
assertThat(result.getNodeCount()).isEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exportSubgraph_depthZero_singleEntity_returnsNodeWithProperties() {
|
||||||
|
when(properties.getMaxNodesPerQuery()).thenReturn(500);
|
||||||
|
|
||||||
|
GraphEntity entity = GraphEntity.builder()
|
||||||
|
.id(ENTITY_ID).name("Test Dataset").type("Dataset").graphId(GRAPH_ID)
|
||||||
|
.description("A test dataset")
|
||||||
|
.properties(new HashMap<>(Map.of("created_by", "user-1", "sensitivity", "PUBLIC")))
|
||||||
|
.build();
|
||||||
|
when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID)))
|
||||||
|
.thenReturn(List.of(entity));
|
||||||
|
|
||||||
|
SubgraphExportVO result = queryService.exportSubgraph(GRAPH_ID, List.of(ENTITY_ID), 0);
|
||||||
|
|
||||||
|
assertThat(result.getNodes()).hasSize(1);
|
||||||
|
assertThat(result.getNodeCount()).isEqualTo(1);
|
||||||
|
assertThat(result.getNodes().get(0).getName()).isEqualTo("Test Dataset");
|
||||||
|
assertThat(result.getNodes().get(0).getProperties()).containsEntry("created_by", "user-1");
|
||||||
|
// 单节点无边
|
||||||
|
assertThat(result.getEdges()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exportSubgraph_nonAdmin_filtersInaccessibleEntities() {
|
||||||
|
when(resourceAccessService.resolveOwnerFilterUserId()).thenReturn("user-123");
|
||||||
|
when(properties.getMaxNodesPerQuery()).thenReturn(500);
|
||||||
|
|
||||||
|
GraphEntity ownEntity = GraphEntity.builder()
|
||||||
|
.id(ENTITY_ID).name("My Dataset").type("Dataset").graphId(GRAPH_ID)
|
||||||
|
.properties(new HashMap<>(Map.of("created_by", "user-123")))
|
||||||
|
.build();
|
||||||
|
GraphEntity otherEntity = GraphEntity.builder()
|
||||||
|
.id(ENTITY_ID_2).name("Other Dataset").type("Dataset").graphId(GRAPH_ID)
|
||||||
|
.properties(new HashMap<>(Map.of("created_by", "other-user")))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
when(entityRepository.findByGraphIdAndIdIn(GRAPH_ID, List.of(ENTITY_ID, ENTITY_ID_2)))
|
||||||
|
.thenReturn(List.of(ownEntity, otherEntity));
|
||||||
|
|
||||||
|
SubgraphExportVO result = queryService.exportSubgraph(GRAPH_ID,
|
||||||
|
List.of(ENTITY_ID, ENTITY_ID_2), 0);
|
||||||
|
|
||||||
|
assertThat(result.getNodes()).hasSize(1);
|
||||||
|
assertThat(result.getNodes().get(0).getName()).isEqualTo("My Dataset");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// convertToGraphML
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class ConvertToGraphMLTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void convertToGraphML_emptyExport_producesValidXml() {
|
||||||
|
SubgraphExportVO emptyExport = SubgraphExportVO.builder()
|
||||||
|
.nodes(List.of())
|
||||||
|
.edges(List.of())
|
||||||
|
.nodeCount(0)
|
||||||
|
.edgeCount(0)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
String graphml = queryService.convertToGraphML(emptyExport);
|
||||||
|
|
||||||
|
assertThat(graphml).contains("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
|
||||||
|
assertThat(graphml).contains("<graphml");
|
||||||
|
assertThat(graphml).contains("<graph id=\"G\" edgedefault=\"directed\">");
|
||||||
|
assertThat(graphml).contains("</graphml>");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void convertToGraphML_withNodesAndEdges_producesCorrectStructure() {
|
||||||
|
SubgraphExportVO export = SubgraphExportVO.builder()
|
||||||
|
.nodes(List.of(
|
||||||
|
com.datamate.knowledgegraph.interfaces.dto.ExportNodeVO.builder()
|
||||||
|
.id("node-1").name("Dataset A").type("Dataset")
|
||||||
|
.description("Test dataset").properties(Map.of())
|
||||||
|
.build(),
|
||||||
|
com.datamate.knowledgegraph.interfaces.dto.ExportNodeVO.builder()
|
||||||
|
.id("node-2").name("Workflow B").type("Workflow")
|
||||||
|
.description(null).properties(Map.of())
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.edges(List.of(
|
||||||
|
com.datamate.knowledgegraph.interfaces.dto.ExportEdgeVO.builder()
|
||||||
|
.id("edge-1").sourceEntityId("node-1").targetEntityId("node-2")
|
||||||
|
.relationType("DERIVED_FROM").weight(0.8)
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.nodeCount(2)
|
||||||
|
.edgeCount(1)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
String graphml = queryService.convertToGraphML(export);
|
||||||
|
|
||||||
|
assertThat(graphml).contains("<node id=\"node-1\">");
|
||||||
|
assertThat(graphml).contains("<data key=\"name\">Dataset A</data>");
|
||||||
|
assertThat(graphml).contains("<data key=\"type\">Dataset</data>");
|
||||||
|
assertThat(graphml).contains("<data key=\"description\">Test dataset</data>");
|
||||||
|
assertThat(graphml).contains("<node id=\"node-2\">");
|
||||||
|
assertThat(graphml).contains("<data key=\"type\">Workflow</data>");
|
||||||
|
// null description 不输出
|
||||||
|
assertThat(graphml).doesNotContain("<data key=\"description\">null</data>");
|
||||||
|
assertThat(graphml).contains("<edge id=\"edge-1\" source=\"node-1\" target=\"node-2\">");
|
||||||
|
assertThat(graphml).contains("<data key=\"relationType\">DERIVED_FROM</data>");
|
||||||
|
assertThat(graphml).contains("<data key=\"weight\">0.8</data>");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void convertToGraphML_specialCharactersEscaped() {
|
||||||
|
SubgraphExportVO export = SubgraphExportVO.builder()
|
||||||
|
.nodes(List.of(
|
||||||
|
com.datamate.knowledgegraph.interfaces.dto.ExportNodeVO.builder()
|
||||||
|
.id("node-1").name("A & B <Corp>").type("Org")
|
||||||
|
.description("\"Test\" org").properties(Map.of())
|
||||||
|
.build()
|
||||||
|
))
|
||||||
|
.edges(List.of())
|
||||||
|
.nodeCount(1)
|
||||||
|
.edgeCount(0)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
String graphml = queryService.convertToGraphML(export);
|
||||||
|
|
||||||
|
assertThat(graphml).contains("A & B <Corp>");
|
||||||
|
assertThat(graphml).contains(""Test" org");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user