feat: 新增统一数据空间与联邦查询能力
- 提供数据连接、元数据、逻辑表与关联编排能力 - 接入联邦查询、执行分析、统计估算和 SQL 补全接口 - 增加查询预算、凭据保护、租户隔离和 V61 初始化迁移
This commit is contained in:
@@ -40,6 +40,10 @@
|
|||||||
<groupId>tech.easyflow</groupId>
|
<groupId>tech.easyflow</groupId>
|
||||||
<artifactId>easyflow-module-job</artifactId>
|
<artifactId>easyflow-module-job</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>tech.easyflow</groupId>
|
||||||
|
<artifactId>easyflow-module-dataspace</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>tech.easyflow</groupId>
|
<groupId>tech.easyflow</groupId>
|
||||||
<artifactId>easyflow-common-captcha</artifactId>
|
<artifactId>easyflow-common-captcha</artifactId>
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package tech.easyflow.admin.controller.dataspace;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import tech.easyflow.common.domain.Result;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.dataspace.model.ConnectionDefinition;
|
||||||
|
import tech.easyflow.dataspace.model.ConnectionView;
|
||||||
|
import tech.easyflow.dataspace.model.ObjectView;
|
||||||
|
import tech.easyflow.dataspace.provider.DataspaceProbe;
|
||||||
|
import tech.easyflow.dataspace.service.DataspaceConnectionService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间物理连接管理端 API。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/dataspaceConnection")
|
||||||
|
public class DataspaceConnectionController {
|
||||||
|
|
||||||
|
private final DataspaceConnectionService connectionService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建连接控制器。
|
||||||
|
*
|
||||||
|
* @param connectionService 连接服务
|
||||||
|
*/
|
||||||
|
public DataspaceConnectionController(DataspaceConnectionService connectionService) {
|
||||||
|
this.connectionService = connectionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前租户连接列表。
|
||||||
|
*
|
||||||
|
* @param keyword 搜索关键词
|
||||||
|
* @return 连接列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceConnection/query")
|
||||||
|
public Result<List<ConnectionView>> list(String keyword) {
|
||||||
|
return Result.ok(connectionService.list(keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取连接详情。
|
||||||
|
*
|
||||||
|
* @param id 连接 ID
|
||||||
|
* @return 连接详情
|
||||||
|
*/
|
||||||
|
@GetMapping("/detail")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceConnection/query")
|
||||||
|
public Result<ConnectionView> detail(BigInteger id) {
|
||||||
|
return Result.ok(connectionService.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试候选或已保存连接。
|
||||||
|
*
|
||||||
|
* @param definition 连接定义
|
||||||
|
* @return 测试结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/test")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceConnection/test")
|
||||||
|
public Result<DataspaceProbe> test(
|
||||||
|
@JsonBody(required = true, skipConvertError = false) ConnectionDefinition definition) {
|
||||||
|
return Result.ok(connectionService.test(definition));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建或更新连接。
|
||||||
|
*
|
||||||
|
* @param definition 连接定义
|
||||||
|
* @return 保存后的连接
|
||||||
|
*/
|
||||||
|
@PostMapping("/save")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceConnection/save")
|
||||||
|
public Result<ConnectionView> save(
|
||||||
|
@JsonBody(required = true, skipConvertError = false) ConnectionDefinition definition) {
|
||||||
|
return Result.ok(connectionService.save(definition));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用或禁用连接。
|
||||||
|
*
|
||||||
|
* @param request 状态变更请求
|
||||||
|
* @return 变更后的连接
|
||||||
|
*/
|
||||||
|
@PostMapping("/status")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceConnection/save")
|
||||||
|
public Result<ConnectionView> status(
|
||||||
|
@JsonBody(required = true, skipConvertError = false) StatusRequest request) {
|
||||||
|
if (request == null || request.enabled() == null) {
|
||||||
|
throw new BusinessException("连接状态不能为空");
|
||||||
|
}
|
||||||
|
return Result.ok(connectionService.setEnabled(request.id(), request.enabled()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前连接的对象树数据。
|
||||||
|
*
|
||||||
|
* @param connectionId 连接 ID
|
||||||
|
* @param keyword Schema 或表名关键词
|
||||||
|
* @return 对象列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/objects")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceConnection/query")
|
||||||
|
public Result<List<ObjectView>> objects(BigInteger connectionId, String keyword) {
|
||||||
|
return Result.ok(connectionService.objects(connectionId, keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新连接元数据。
|
||||||
|
*
|
||||||
|
* @param request 刷新请求
|
||||||
|
* @return 新 revision 对象列表
|
||||||
|
*/
|
||||||
|
@PostMapping("/refreshMetadata")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceConnection/metadata")
|
||||||
|
public Result<List<ObjectView>> refreshMetadata(
|
||||||
|
@JsonBody(required = true, skipConvertError = false) RefreshRequest request) {
|
||||||
|
return Result.ok(connectionService.refreshMetadata(
|
||||||
|
request.connectionId(), request.expectedRevision()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除未被引用的连接。
|
||||||
|
*
|
||||||
|
* @param id 连接 ID
|
||||||
|
* @return 成功结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/remove")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceConnection/remove")
|
||||||
|
public Result<Void> remove(
|
||||||
|
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
|
||||||
|
connectionService.remove(id);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 元数据刷新请求。
|
||||||
|
*
|
||||||
|
* @param connectionId 连接 ID
|
||||||
|
* @param expectedRevision 期望 revision
|
||||||
|
*/
|
||||||
|
public record RefreshRequest(BigInteger connectionId, long expectedRevision) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接状态变更请求。
|
||||||
|
*
|
||||||
|
* @param id 连接 ID
|
||||||
|
* @param enabled 是否启用
|
||||||
|
*/
|
||||||
|
public record StatusRequest(BigInteger id, Boolean enabled) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package tech.easyflow.admin.controller.dataspace;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import tech.easyflow.common.domain.Result;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceDefinition;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceSummary;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceView;
|
||||||
|
import tech.easyflow.dataspace.service.DataspaceService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 虚拟数据空间管理端 API。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/dataspace")
|
||||||
|
public class DataspaceController {
|
||||||
|
|
||||||
|
private final DataspaceService dataspaceService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建数据空间控制器。
|
||||||
|
*
|
||||||
|
* @param dataspaceService 数据空间服务
|
||||||
|
*/
|
||||||
|
public DataspaceController(DataspaceService dataspaceService) {
|
||||||
|
this.dataspaceService = dataspaceService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询数据空间列表。
|
||||||
|
*
|
||||||
|
* @param keyword 搜索关键词
|
||||||
|
* @return 数据空间摘要
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
@SaCheckPermission("/api/v1/dataspace/query")
|
||||||
|
public Result<List<DataspaceSummary>> list(String keyword) {
|
||||||
|
return Result.ok(dataspaceService.list(keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据空间当前 revision 详情。
|
||||||
|
*
|
||||||
|
* @param id 数据空间 ID
|
||||||
|
* @return 数据空间详情
|
||||||
|
*/
|
||||||
|
@GetMapping("/detail")
|
||||||
|
@SaCheckPermission("/api/v1/dataspace/detail")
|
||||||
|
public Result<DataspaceView> detail(BigInteger id) {
|
||||||
|
return Result.ok(dataspaceService.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存数据空间并生成新 revision。
|
||||||
|
*
|
||||||
|
* @param definition 数据空间定义
|
||||||
|
* @return 保存后的详情
|
||||||
|
*/
|
||||||
|
@PostMapping("/save")
|
||||||
|
@SaCheckPermission("/api/v1/dataspace/save")
|
||||||
|
public Result<DataspaceView> save(
|
||||||
|
@RequestBody DataspaceDefinition definition) {
|
||||||
|
return Result.ok(dataspaceService.save(definition));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用或禁用数据空间。
|
||||||
|
*
|
||||||
|
* @param request 状态变更请求
|
||||||
|
* @return 成功结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/status")
|
||||||
|
@SaCheckPermission("/api/v1/dataspace/save")
|
||||||
|
public Result<Void> status(
|
||||||
|
@JsonBody(required = true, skipConvertError = false) StatusRequest request) {
|
||||||
|
if (request == null || request.enabled() == null) {
|
||||||
|
throw new BusinessException("数据空间状态不能为空");
|
||||||
|
}
|
||||||
|
dataspaceService.setEnabled(request.id(), request.enabled());
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逻辑删除数据空间。
|
||||||
|
*
|
||||||
|
* @param id 数据空间 ID
|
||||||
|
* @return 成功结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/remove")
|
||||||
|
@SaCheckPermission("/api/v1/dataspace/remove")
|
||||||
|
public Result<Void> remove(
|
||||||
|
@JsonBody(value = "id", required = true, skipConvertError = false) BigInteger id) {
|
||||||
|
dataspaceService.remove(id);
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间状态变更请求。
|
||||||
|
*
|
||||||
|
* @param id 数据空间 ID
|
||||||
|
* @param enabled 是否启用
|
||||||
|
*/
|
||||||
|
public record StatusRequest(BigInteger id, Boolean enabled) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package tech.easyflow.admin.controller.dataspace;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import tech.easyflow.common.domain.Result;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceExplainResult;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceQueryRequest;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceQueryResult;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceSqlCompletionRequest;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceSqlCompletionResult;
|
||||||
|
import tech.easyflow.dataspace.service.DataspaceQueryService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间 SQL 工作台 Query、Explain、Complete 与 Cancel API。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/dataspaceSql")
|
||||||
|
public class DataspaceSqlController {
|
||||||
|
|
||||||
|
private final DataspaceQueryService queryService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 SQL 控制器。
|
||||||
|
*
|
||||||
|
* @param queryService 查询服务
|
||||||
|
*/
|
||||||
|
public DataspaceSqlController(DataspaceQueryService queryService) {
|
||||||
|
this.queryService = queryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行只读 SQL。
|
||||||
|
*
|
||||||
|
* @param request 查询请求
|
||||||
|
* @return 查询结果与指标
|
||||||
|
*/
|
||||||
|
@PostMapping("/query")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceSql/query")
|
||||||
|
public Result<DataspaceQueryResult> query(
|
||||||
|
@JsonBody(required = true, skipConvertError = false) DataspaceQueryRequest request) {
|
||||||
|
return Result.ok(queryService.query(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显式执行非 ANALYZE Explain。
|
||||||
|
*
|
||||||
|
* @param request Explain 请求
|
||||||
|
* @return Explain 与索引信息
|
||||||
|
*/
|
||||||
|
@PostMapping("/explain")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceSql/explain")
|
||||||
|
public Result<DataspaceExplainResult> explain(
|
||||||
|
@JsonBody(required = true, skipConvertError = false) DataspaceQueryRequest request) {
|
||||||
|
return Result.ok(queryService.explain(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回当前数据空间内的 Calcite SQL 补全候选。
|
||||||
|
*
|
||||||
|
* @param request 补全请求
|
||||||
|
* @return 补全替换区间与候选
|
||||||
|
*/
|
||||||
|
@PostMapping("/complete")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceSql/query")
|
||||||
|
public Result<DataspaceSqlCompletionResult> complete(
|
||||||
|
@JsonBody(required = true, skipConvertError = false)
|
||||||
|
DataspaceSqlCompletionRequest request) {
|
||||||
|
return Result.ok(queryService.complete(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试取消当前节点查询。
|
||||||
|
*
|
||||||
|
* @param queryId 查询 ID
|
||||||
|
* @return 是否找到并发起取消
|
||||||
|
*/
|
||||||
|
@PostMapping("/cancel")
|
||||||
|
@SaCheckPermission("/api/v1/dataspaceSql/query")
|
||||||
|
public Result<Boolean> cancel(
|
||||||
|
@JsonBody(value = "queryId", required = true, skipConvertError = false) String queryId) {
|
||||||
|
return Result.ok(queryService.cancel(queryId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package tech.easyflow.admin.controller.dataspace;
|
||||||
|
|
||||||
|
import static org.testng.Assert.assertEquals;
|
||||||
|
import static org.testng.Assert.assertNotNull;
|
||||||
|
import static org.testng.Assert.assertNull;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.lang.reflect.Parameter;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.testng.annotations.Test;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceDefinition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间管理接口请求绑定契约测试。
|
||||||
|
*/
|
||||||
|
public class DataspaceControllerContractTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证保存接口使用 Jackson 请求体绑定,避免嵌套定义残留为 JSONObject。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射或 JSON 转换失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldBindNestedDataspaceDefinitionWithJackson() throws Exception {
|
||||||
|
Method method = DataspaceController.class.getMethod(
|
||||||
|
"save", DataspaceDefinition.class);
|
||||||
|
Parameter parameter = method.getParameters()[0];
|
||||||
|
assertNotNull(parameter.getAnnotation(RequestBody.class));
|
||||||
|
assertNull(parameter.getAnnotation(JsonBody.class));
|
||||||
|
|
||||||
|
String request = """
|
||||||
|
{
|
||||||
|
"name": "网点经营分析",
|
||||||
|
"tables": [
|
||||||
|
{
|
||||||
|
"clientKey": "table:outlet",
|
||||||
|
"objectId": "1001",
|
||||||
|
"sourceAlias": "MYSQL_1",
|
||||||
|
"schemaAlias": "MAIN",
|
||||||
|
"tableAlias": "outlet",
|
||||||
|
"positionX": 80,
|
||||||
|
"positionY": 120
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"clientKey": "table:region",
|
||||||
|
"objectId": "1002",
|
||||||
|
"sourceAlias": "PG_1",
|
||||||
|
"schemaAlias": "PUBLIC",
|
||||||
|
"tableAlias": "outlet_region",
|
||||||
|
"positionX": 420,
|
||||||
|
"positionY": 120
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"relations": [
|
||||||
|
{
|
||||||
|
"leftClientKey": "table:outlet",
|
||||||
|
"rightClientKey": "table:region",
|
||||||
|
"joinType": "INNER",
|
||||||
|
"leftColumn": "institution_id",
|
||||||
|
"rightColumn": "institution_id"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
DataspaceDefinition definition = new ObjectMapper().readValue(
|
||||||
|
request, DataspaceDefinition.class);
|
||||||
|
|
||||||
|
assertEquals(2, definition.tables().size());
|
||||||
|
assertEquals("outlet", definition.tables().get(0).tableAlias());
|
||||||
|
assertEquals("outlet_region", definition.tables().get(1).tableAlias());
|
||||||
|
assertEquals(1, definition.relations().size());
|
||||||
|
assertEquals("institution_id", definition.relations().get(0).leftColumn());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ tech.easyflow.approval.config.ApprovalModuleConfig
|
|||||||
tech.easyflow.auth.config.AuthModuleConfig
|
tech.easyflow.auth.config.AuthModuleConfig
|
||||||
tech.easyflow.chatlog.config.ChatlogModuleConfig
|
tech.easyflow.chatlog.config.ChatlogModuleConfig
|
||||||
tech.easyflow.datacenter.config.DatacenterModuleConfig
|
tech.easyflow.datacenter.config.DatacenterModuleConfig
|
||||||
|
tech.easyflow.dataspace.config.DataspaceModuleConfig
|
||||||
tech.easyflow.job.config.JobModuleConfig
|
tech.easyflow.job.config.JobModuleConfig
|
||||||
tech.easyflow.log.config.LogModuleConfig
|
tech.easyflow.log.config.LogModuleConfig
|
||||||
tech.easyflow.skill.config.SkillModuleConfig
|
tech.easyflow.skill.config.SkillModuleConfig
|
||||||
|
|||||||
66
easyflow-modules/easyflow-module-dataspace/pom.xml
Normal file
66
easyflow-modules/easyflow-module-dataspace/pom.xml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<parent>
|
||||||
|
<groupId>tech.easyflow</groupId>
|
||||||
|
<artifactId>easyflow-modules</artifactId>
|
||||||
|
<version>${revision}</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>easyflow-module-dataspace</artifactId>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mybatis-flex</groupId>
|
||||||
|
<artifactId>mybatis-flex-spring-boot3-starter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.zaxxer</groupId>
|
||||||
|
<artifactId>HikariCP</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.easyagents</groupId>
|
||||||
|
<artifactId>easy-agents-federation-sql-adapter-jdbc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<version>42.7.3</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>tech.easyflow</groupId>
|
||||||
|
<artifactId>easyflow-common-base</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>tech.easyflow</groupId>
|
||||||
|
<artifactId>easyflow-common-cache</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>tech.easyflow</groupId>
|
||||||
|
<artifactId>easyflow-common-satoken</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>tech.easyflow</groupId>
|
||||||
|
<artifactId>easyflow-common-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>junit</groupId>
|
||||||
|
<artifactId>junit</artifactId>
|
||||||
|
<version>${junit.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mockito</groupId>
|
||||||
|
<artifactId>mockito-core</artifactId>
|
||||||
|
<version>5.12.0</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mysql</groupId>
|
||||||
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package tech.easyflow.dataspace.config;
|
||||||
|
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间模块自动配置。
|
||||||
|
*/
|
||||||
|
@MapperScan("tech.easyflow.dataspace.mapper")
|
||||||
|
@ComponentScan("tech.easyflow.dataspace")
|
||||||
|
@AutoConfiguration
|
||||||
|
public class DataspaceModuleConfig {
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package tech.easyflow.dataspace.entity;
|
||||||
|
|
||||||
|
import com.mybatisflex.annotation.Table;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 面向 Agent 和 SQL 工作台的虚拟数据空间。
|
||||||
|
*/
|
||||||
|
@Table("tb_dataspace")
|
||||||
|
public class Dataspace extends DataspaceRecordBase {
|
||||||
|
|
||||||
|
private String name;
|
||||||
|
private String description;
|
||||||
|
private Long currentRevision;
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/** @return 名称 */ public String getName() { return name; }
|
||||||
|
/** @param name 名称 */ public void setName(String name) { this.name = name; }
|
||||||
|
/** @return 说明 */ public String getDescription() { return description; }
|
||||||
|
/** @param description 说明 */ public void setDescription(String description) { this.description = description; }
|
||||||
|
/** @return 当前 revision */ public Long getCurrentRevision() { return currentRevision; }
|
||||||
|
/** @param currentRevision 当前 revision */ public void setCurrentRevision(Long currentRevision) { this.currentRevision = currentRevision; }
|
||||||
|
/** @return 状态 */ public String getStatus() { return status; }
|
||||||
|
/** @param status 状态 */ public void setStatus(String status) { this.status = status; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package tech.easyflow.dataspace.entity;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.mybatisflex.annotation.Column;
|
||||||
|
import com.mybatisflex.annotation.Table;
|
||||||
|
import com.mybatisflex.core.handler.FastjsonTypeHandler;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间物理数据库连接。
|
||||||
|
*/
|
||||||
|
@Table("tb_dataspace_connection")
|
||||||
|
public class DataspaceConnection extends DataspaceRecordBase {
|
||||||
|
|
||||||
|
private String name;
|
||||||
|
private String databaseType;
|
||||||
|
private String host;
|
||||||
|
private Integer port;
|
||||||
|
private String databaseName;
|
||||||
|
private String username;
|
||||||
|
private String credentialCipher;
|
||||||
|
private Integer sslEnabled;
|
||||||
|
private String driverClassName;
|
||||||
|
@Column(typeHandler = FastjsonTypeHandler.class)
|
||||||
|
private Map<String, Object> optionsJson = new LinkedHashMap<>();
|
||||||
|
private Long definitionRevision;
|
||||||
|
private String definitionChecksum;
|
||||||
|
private String status;
|
||||||
|
private String lastTestStatus;
|
||||||
|
private String lastTestMessage;
|
||||||
|
private String databaseProduct;
|
||||||
|
private String databaseVersion;
|
||||||
|
private String driverName;
|
||||||
|
private String driverVersion;
|
||||||
|
private Date lastTestedAt;
|
||||||
|
private Date metadataRefreshedAt;
|
||||||
|
|
||||||
|
/** @return 连接名称 */ public String getName() { return name; }
|
||||||
|
/** @param name 连接名称 */ public void setName(String name) { this.name = name; }
|
||||||
|
/** @return 数据库类型 */ public String getDatabaseType() { return databaseType; }
|
||||||
|
/** @param databaseType 数据库类型 */ public void setDatabaseType(String databaseType) { this.databaseType = databaseType; }
|
||||||
|
/** @return 主机 */ public String getHost() { return host; }
|
||||||
|
/** @param host 主机 */ public void setHost(String host) { this.host = host; }
|
||||||
|
/** @return 端口 */ public Integer getPort() { return port; }
|
||||||
|
/** @param port 端口 */ public void setPort(Integer port) { this.port = port; }
|
||||||
|
/** @return 数据库名 */ public String getDatabaseName() { return databaseName; }
|
||||||
|
/** @param databaseName 数据库名 */ public void setDatabaseName(String databaseName) { this.databaseName = databaseName; }
|
||||||
|
/** @return 用户名 */ public String getUsername() { return username; }
|
||||||
|
/** @param username 用户名 */ public void setUsername(String username) { this.username = username; }
|
||||||
|
/** @return 凭据密文 */ @JsonIgnore public String getCredentialCipher() { return credentialCipher; }
|
||||||
|
/** @param credentialCipher 凭据密文 */ public void setCredentialCipher(String credentialCipher) { this.credentialCipher = credentialCipher; }
|
||||||
|
/** @return 是否启用 SSL */ public Integer getSslEnabled() { return sslEnabled; }
|
||||||
|
/** @param sslEnabled 是否启用 SSL */ public void setSslEnabled(Integer sslEnabled) { this.sslEnabled = sslEnabled; }
|
||||||
|
/** @return Driver 类名 */ public String getDriverClassName() { return driverClassName; }
|
||||||
|
/** @param driverClassName Driver 类名 */ public void setDriverClassName(String driverClassName) { this.driverClassName = driverClassName; }
|
||||||
|
/** @return 扩展选项 */ @JsonIgnore public Map<String, Object> getOptionsJson() { return optionsJson; }
|
||||||
|
/** @param optionsJson 扩展选项 */ public void setOptionsJson(Map<String, Object> optionsJson) { this.optionsJson = optionsJson == null ? new LinkedHashMap<>() : optionsJson; }
|
||||||
|
/** @return Definition revision */ public Long getDefinitionRevision() { return definitionRevision; }
|
||||||
|
/** @param definitionRevision Definition revision */ public void setDefinitionRevision(Long definitionRevision) { this.definitionRevision = definitionRevision; }
|
||||||
|
/** @return Definition checksum */ public String getDefinitionChecksum() { return definitionChecksum; }
|
||||||
|
/** @param definitionChecksum Definition checksum */ public void setDefinitionChecksum(String definitionChecksum) { this.definitionChecksum = definitionChecksum; }
|
||||||
|
/** @return 连接状态 */ public String getStatus() { return status; }
|
||||||
|
/** @param status 连接状态 */ public void setStatus(String status) { this.status = status; }
|
||||||
|
/** @return 最近测试状态 */ public String getLastTestStatus() { return lastTestStatus; }
|
||||||
|
/** @param lastTestStatus 最近测试状态 */ public void setLastTestStatus(String lastTestStatus) { this.lastTestStatus = lastTestStatus; }
|
||||||
|
/** @return 最近测试信息 */ public String getLastTestMessage() { return lastTestMessage; }
|
||||||
|
/** @param lastTestMessage 最近测试信息 */ public void setLastTestMessage(String lastTestMessage) { this.lastTestMessage = lastTestMessage; }
|
||||||
|
/** @return 数据库产品 */ public String getDatabaseProduct() { return databaseProduct; }
|
||||||
|
/** @param databaseProduct 数据库产品 */ public void setDatabaseProduct(String databaseProduct) { this.databaseProduct = databaseProduct; }
|
||||||
|
/** @return 数据库版本 */ public String getDatabaseVersion() { return databaseVersion; }
|
||||||
|
/** @param databaseVersion 数据库版本 */ public void setDatabaseVersion(String databaseVersion) { this.databaseVersion = databaseVersion; }
|
||||||
|
/** @return 驱动名称 */ public String getDriverName() { return driverName; }
|
||||||
|
/** @param driverName 驱动名称 */ public void setDriverName(String driverName) { this.driverName = driverName; }
|
||||||
|
/** @return 驱动版本 */ public String getDriverVersion() { return driverVersion; }
|
||||||
|
/** @param driverVersion 驱动版本 */ public void setDriverVersion(String driverVersion) { this.driverVersion = driverVersion; }
|
||||||
|
/** @return 最近测试时间 */ public Date getLastTestedAt() { return lastTestedAt; }
|
||||||
|
/** @param lastTestedAt 最近测试时间 */ public void setLastTestedAt(Date lastTestedAt) { this.lastTestedAt = lastTestedAt; }
|
||||||
|
/** @return 元数据刷新时间 */ public Date getMetadataRefreshedAt() { return metadataRefreshedAt; }
|
||||||
|
/** @param metadataRefreshedAt 元数据刷新时间 */ public void setMetadataRefreshedAt(Date metadataRefreshedAt) { this.metadataRefreshedAt = metadataRefreshedAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package tech.easyflow.dataspace.entity;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
|
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||||
|
import com.mybatisflex.annotation.Column;
|
||||||
|
import com.mybatisflex.annotation.Table;
|
||||||
|
import com.mybatisflex.core.handler.FastjsonTypeHandler;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 物理连接同步得到的表或视图元数据。
|
||||||
|
*/
|
||||||
|
@Table("tb_dataspace_object")
|
||||||
|
public class DataspaceObject extends DataspaceRecordBase {
|
||||||
|
|
||||||
|
private BigInteger connectionId;
|
||||||
|
private String catalogName;
|
||||||
|
private String schemaName;
|
||||||
|
private String objectName;
|
||||||
|
private String objectType;
|
||||||
|
private String remarks;
|
||||||
|
@Column(typeHandler = FastjsonTypeHandler.class)
|
||||||
|
private List<Map<String, Object>> columnsJson = new ArrayList<>();
|
||||||
|
@Column(typeHandler = FastjsonTypeHandler.class)
|
||||||
|
private List<String> primaryKeysJson = new ArrayList<>();
|
||||||
|
private Long metadataRevision;
|
||||||
|
|
||||||
|
/** @return 连接 ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getConnectionId() { return connectionId; }
|
||||||
|
/** @param connectionId 连接 ID */ public void setConnectionId(BigInteger connectionId) { this.connectionId = connectionId; }
|
||||||
|
/** @return Catalog */ public String getCatalogName() { return catalogName; }
|
||||||
|
/** @param catalogName Catalog */ public void setCatalogName(String catalogName) { this.catalogName = catalogName; }
|
||||||
|
/** @return Schema */ public String getSchemaName() { return schemaName; }
|
||||||
|
/** @param schemaName Schema */ public void setSchemaName(String schemaName) { this.schemaName = schemaName; }
|
||||||
|
/** @return 对象名 */ public String getObjectName() { return objectName; }
|
||||||
|
/** @param objectName 对象名 */ public void setObjectName(String objectName) { this.objectName = objectName; }
|
||||||
|
/** @return 对象类型 */ public String getObjectType() { return objectType; }
|
||||||
|
/** @param objectType 对象类型 */ public void setObjectType(String objectType) { this.objectType = objectType; }
|
||||||
|
/** @return 说明 */ public String getRemarks() { return remarks; }
|
||||||
|
/** @param remarks 说明 */ public void setRemarks(String remarks) { this.remarks = remarks; }
|
||||||
|
/** @return 字段快照 */ public List<Map<String, Object>> getColumnsJson() { return columnsJson; }
|
||||||
|
/** @param columnsJson 字段快照 */ public void setColumnsJson(List<Map<String, Object>> columnsJson) { this.columnsJson = columnsJson == null ? new ArrayList<>() : columnsJson; }
|
||||||
|
/** @return 主键字段 */ public List<String> getPrimaryKeysJson() { return primaryKeysJson; }
|
||||||
|
/** @param primaryKeysJson 主键字段 */ public void setPrimaryKeysJson(List<String> primaryKeysJson) { this.primaryKeysJson = primaryKeysJson == null ? new ArrayList<>() : primaryKeysJson; }
|
||||||
|
/** @return 元数据 revision */ public Long getMetadataRevision() { return metadataRevision; }
|
||||||
|
/** @param metadataRevision 元数据 revision */ public void setMetadataRevision(Long metadataRevision) { this.metadataRevision = metadataRevision; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package tech.easyflow.dataspace.entity;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
|
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||||
|
import com.mybatisflex.annotation.Table;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间查询审计摘要。
|
||||||
|
*/
|
||||||
|
@Table("tb_dataspace_query_audit")
|
||||||
|
public class DataspaceQueryAudit extends DataspaceRecordBase {
|
||||||
|
|
||||||
|
private BigInteger dataspaceId;
|
||||||
|
private Long revisionNo;
|
||||||
|
private String queryId;
|
||||||
|
private String queryMode;
|
||||||
|
private String sqlDigest;
|
||||||
|
private String sqlText;
|
||||||
|
private String status;
|
||||||
|
private Long returnedRows;
|
||||||
|
private Long intermediateRows;
|
||||||
|
private Long durationMillis;
|
||||||
|
private String errorCode;
|
||||||
|
private String errorMessage;
|
||||||
|
|
||||||
|
/** @return 数据空间 ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getDataspaceId() { return dataspaceId; }
|
||||||
|
/** @param dataspaceId 数据空间 ID */ public void setDataspaceId(BigInteger dataspaceId) { this.dataspaceId = dataspaceId; }
|
||||||
|
/** @return revision */ public Long getRevisionNo() { return revisionNo; }
|
||||||
|
/** @param revisionNo revision */ public void setRevisionNo(Long revisionNo) { this.revisionNo = revisionNo; }
|
||||||
|
/** @return 查询 ID */ public String getQueryId() { return queryId; }
|
||||||
|
/** @param queryId 查询 ID */ public void setQueryId(String queryId) { this.queryId = queryId; }
|
||||||
|
/** @return 查询模式 */ public String getQueryMode() { return queryMode; }
|
||||||
|
/** @param queryMode 查询模式 */ public void setQueryMode(String queryMode) { this.queryMode = queryMode; }
|
||||||
|
/** @return SQL 摘要 */ public String getSqlDigest() { return sqlDigest; }
|
||||||
|
/** @param sqlDigest SQL 摘要 */ public void setSqlDigest(String sqlDigest) { this.sqlDigest = sqlDigest; }
|
||||||
|
/** @return SQL 文本 */ public String getSqlText() { return sqlText; }
|
||||||
|
/** @param sqlText SQL 文本 */ public void setSqlText(String sqlText) { this.sqlText = sqlText; }
|
||||||
|
/** @return 终态 */ public String getStatus() { return status; }
|
||||||
|
/** @param status 终态 */ public void setStatus(String status) { this.status = status; }
|
||||||
|
/** @return 返回行数 */ public Long getReturnedRows() { return returnedRows; }
|
||||||
|
/** @param returnedRows 返回行数 */ public void setReturnedRows(Long returnedRows) { this.returnedRows = returnedRows; }
|
||||||
|
/** @return 中间行数 */ public Long getIntermediateRows() { return intermediateRows; }
|
||||||
|
/** @param intermediateRows 中间行数 */ public void setIntermediateRows(Long intermediateRows) { this.intermediateRows = intermediateRows; }
|
||||||
|
/** @return 耗时毫秒 */ public Long getDurationMillis() { return durationMillis; }
|
||||||
|
/** @param durationMillis 耗时毫秒 */ public void setDurationMillis(Long durationMillis) { this.durationMillis = durationMillis; }
|
||||||
|
/** @return 错误码 */ public String getErrorCode() { return errorCode; }
|
||||||
|
/** @param errorCode 错误码 */ public void setErrorCode(String errorCode) { this.errorCode = errorCode; }
|
||||||
|
/** @return 错误信息 */ public String getErrorMessage() { return errorMessage; }
|
||||||
|
/** @param errorMessage 错误信息 */ public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package tech.easyflow.dataspace.entity;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
|
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||||
|
import com.mybatisflex.annotation.Column;
|
||||||
|
import com.mybatisflex.annotation.Id;
|
||||||
|
import com.mybatisflex.annotation.KeyType;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Date;
|
||||||
|
import tech.easyflow.common.entity.DateEntity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间持久化记录公共字段。
|
||||||
|
*/
|
||||||
|
public abstract class DataspaceRecordBase extends DateEntity implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
|
||||||
|
private BigInteger id;
|
||||||
|
@Column(tenantId = true)
|
||||||
|
private BigInteger tenantId;
|
||||||
|
private Date created;
|
||||||
|
private BigInteger createdBy;
|
||||||
|
private Date modified;
|
||||||
|
private BigInteger modifiedBy;
|
||||||
|
@Column(value = "is_deleted", isLogicDelete = true)
|
||||||
|
private Integer deleted;
|
||||||
|
|
||||||
|
/** @return 主键 */
|
||||||
|
@JsonSerialize(using = ToStringSerializer.class)
|
||||||
|
public BigInteger getId() { return id; }
|
||||||
|
/** @param id 主键 */
|
||||||
|
public void setId(BigInteger id) { this.id = id; }
|
||||||
|
/** @return 租户 ID */
|
||||||
|
@JsonSerialize(using = ToStringSerializer.class)
|
||||||
|
public BigInteger getTenantId() { return tenantId; }
|
||||||
|
/** @param tenantId 租户 ID */
|
||||||
|
public void setTenantId(BigInteger tenantId) { this.tenantId = tenantId; }
|
||||||
|
/** @return 创建时间 */
|
||||||
|
@Override public Date getCreated() { return created; }
|
||||||
|
/** @param created 创建时间 */
|
||||||
|
@Override public void setCreated(Date created) { this.created = created; }
|
||||||
|
/** @return 创建人 */
|
||||||
|
@JsonSerialize(using = ToStringSerializer.class)
|
||||||
|
public BigInteger getCreatedBy() { return createdBy; }
|
||||||
|
/** @param createdBy 创建人 */
|
||||||
|
public void setCreatedBy(BigInteger createdBy) { this.createdBy = createdBy; }
|
||||||
|
/** @return 修改时间 */
|
||||||
|
@Override public Date getModified() { return modified; }
|
||||||
|
/** @param modified 修改时间 */
|
||||||
|
@Override public void setModified(Date modified) { this.modified = modified; }
|
||||||
|
/** @return 修改人 */
|
||||||
|
@JsonSerialize(using = ToStringSerializer.class)
|
||||||
|
public BigInteger getModifiedBy() { return modifiedBy; }
|
||||||
|
/** @param modifiedBy 修改人 */
|
||||||
|
public void setModifiedBy(BigInteger modifiedBy) { this.modifiedBy = modifiedBy; }
|
||||||
|
/** @return 删除标记 */
|
||||||
|
public Integer getDeleted() { return deleted; }
|
||||||
|
/** @param deleted 删除标记 */
|
||||||
|
public void setDeleted(Integer deleted) { this.deleted = deleted; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package tech.easyflow.dataspace.entity;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
|
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||||
|
import com.mybatisflex.annotation.Table;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* revision 中两张表的字段关联配置。
|
||||||
|
*/
|
||||||
|
@Table("tb_dataspace_relation")
|
||||||
|
public class DataspaceRelation extends DataspaceRecordBase {
|
||||||
|
|
||||||
|
private BigInteger dataspaceId;
|
||||||
|
private BigInteger revisionId;
|
||||||
|
private BigInteger leftBindingId;
|
||||||
|
private BigInteger rightBindingId;
|
||||||
|
private String joinType;
|
||||||
|
private String leftColumn;
|
||||||
|
private String rightColumn;
|
||||||
|
|
||||||
|
/** @return 数据空间 ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getDataspaceId() { return dataspaceId; }
|
||||||
|
/** @param dataspaceId 数据空间 ID */ public void setDataspaceId(BigInteger dataspaceId) { this.dataspaceId = dataspaceId; }
|
||||||
|
/** @return revision ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getRevisionId() { return revisionId; }
|
||||||
|
/** @param revisionId revision ID */ public void setRevisionId(BigInteger revisionId) { this.revisionId = revisionId; }
|
||||||
|
/** @return 左表绑定 ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getLeftBindingId() { return leftBindingId; }
|
||||||
|
/** @param leftBindingId 左表绑定 ID */ public void setLeftBindingId(BigInteger leftBindingId) { this.leftBindingId = leftBindingId; }
|
||||||
|
/** @return 右表绑定 ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getRightBindingId() { return rightBindingId; }
|
||||||
|
/** @param rightBindingId 右表绑定 ID */ public void setRightBindingId(BigInteger rightBindingId) { this.rightBindingId = rightBindingId; }
|
||||||
|
/** @return Join 类型 */ public String getJoinType() { return joinType; }
|
||||||
|
/** @param joinType Join 类型 */ public void setJoinType(String joinType) { this.joinType = joinType; }
|
||||||
|
/** @return 左字段 */ public String getLeftColumn() { return leftColumn; }
|
||||||
|
/** @param leftColumn 左字段 */ public void setLeftColumn(String leftColumn) { this.leftColumn = leftColumn; }
|
||||||
|
/** @return 右字段 */ public String getRightColumn() { return rightColumn; }
|
||||||
|
/** @param rightColumn 右字段 */ public void setRightColumn(String rightColumn) { this.rightColumn = rightColumn; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package tech.easyflow.dataspace.entity;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
|
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||||
|
import com.mybatisflex.annotation.Table;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间不可变 revision 记录。
|
||||||
|
*/
|
||||||
|
@Table("tb_dataspace_revision")
|
||||||
|
public class DataspaceRevision extends DataspaceRecordBase {
|
||||||
|
|
||||||
|
private BigInteger dataspaceId;
|
||||||
|
private Long revisionNo;
|
||||||
|
private String snapshotChecksum;
|
||||||
|
|
||||||
|
/** @return 数据空间 ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getDataspaceId() { return dataspaceId; }
|
||||||
|
/** @param dataspaceId 数据空间 ID */ public void setDataspaceId(BigInteger dataspaceId) { this.dataspaceId = dataspaceId; }
|
||||||
|
/** @return revision */ public Long getRevisionNo() { return revisionNo; }
|
||||||
|
/** @param revisionNo revision */ public void setRevisionNo(Long revisionNo) { this.revisionNo = revisionNo; }
|
||||||
|
/** @return 快照校验和 */ public String getSnapshotChecksum() { return snapshotChecksum; }
|
||||||
|
/** @param snapshotChecksum 快照校验和 */ public void setSnapshotChecksum(String snapshotChecksum) { this.snapshotChecksum = snapshotChecksum; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package tech.easyflow.dataspace.entity;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||||
|
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||||
|
import com.mybatisflex.annotation.Table;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* revision 中一张可查询物理表的绑定。
|
||||||
|
*/
|
||||||
|
@Table("tb_dataspace_table_binding")
|
||||||
|
public class DataspaceTableBinding extends DataspaceRecordBase {
|
||||||
|
|
||||||
|
private BigInteger dataspaceId;
|
||||||
|
private BigInteger revisionId;
|
||||||
|
private BigInteger connectionId;
|
||||||
|
private Long sourceRevision;
|
||||||
|
private BigInteger objectId;
|
||||||
|
private String sourceAlias;
|
||||||
|
private String schemaAlias;
|
||||||
|
private String tableAlias;
|
||||||
|
private Integer positionX;
|
||||||
|
private Integer positionY;
|
||||||
|
|
||||||
|
/** @return 数据空间 ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getDataspaceId() { return dataspaceId; }
|
||||||
|
/** @param dataspaceId 数据空间 ID */ public void setDataspaceId(BigInteger dataspaceId) { this.dataspaceId = dataspaceId; }
|
||||||
|
/** @return revision ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getRevisionId() { return revisionId; }
|
||||||
|
/** @param revisionId revision ID */ public void setRevisionId(BigInteger revisionId) { this.revisionId = revisionId; }
|
||||||
|
/** @return 连接 ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getConnectionId() { return connectionId; }
|
||||||
|
/** @param connectionId 连接 ID */ public void setConnectionId(BigInteger connectionId) { this.connectionId = connectionId; }
|
||||||
|
/** @return 绑定时的物理源 revision */ public Long getSourceRevision() { return sourceRevision; }
|
||||||
|
/** @param sourceRevision 绑定时的物理源 revision */ public void setSourceRevision(Long sourceRevision) { this.sourceRevision = sourceRevision; }
|
||||||
|
/** @return 元数据对象 ID */ @JsonSerialize(using = ToStringSerializer.class) public BigInteger getObjectId() { return objectId; }
|
||||||
|
/** @param objectId 元数据对象 ID */ public void setObjectId(BigInteger objectId) { this.objectId = objectId; }
|
||||||
|
/** @return SQL 数据源别名 */ public String getSourceAlias() { return sourceAlias; }
|
||||||
|
/** @param sourceAlias SQL 数据源别名 */ public void setSourceAlias(String sourceAlias) { this.sourceAlias = sourceAlias; }
|
||||||
|
/** @return SQL Schema 别名 */ public String getSchemaAlias() { return schemaAlias; }
|
||||||
|
/** @param schemaAlias SQL Schema 别名 */ public void setSchemaAlias(String schemaAlias) { this.schemaAlias = schemaAlias; }
|
||||||
|
/** @return SQL 表名 */ public String getTableAlias() { return tableAlias; }
|
||||||
|
/** @param tableAlias SQL 表名 */ public void setTableAlias(String tableAlias) { this.tableAlias = tableAlias; }
|
||||||
|
/** @return 画布横坐标 */ public Integer getPositionX() { return positionX; }
|
||||||
|
/** @param positionX 画布横坐标 */ public void setPositionX(Integer positionX) { this.positionX = positionX; }
|
||||||
|
/** @return 画布纵坐标 */ public Integer getPositionY() { return positionY; }
|
||||||
|
/** @param positionY 画布纵坐标 */ public void setPositionY(Integer positionY) { this.positionY = positionY; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package tech.easyflow.dataspace.federation;
|
||||||
|
|
||||||
|
import com.zaxxer.hikari.HikariConfig;
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceConnection;
|
||||||
|
import tech.easyflow.dataspace.provider.DataspaceConnectionConfig;
|
||||||
|
import tech.easyflow.dataspace.provider.DataspaceDatabaseProvider;
|
||||||
|
import tech.easyflow.dataspace.provider.DataspaceDatabaseProviderRegistry;
|
||||||
|
import tech.easyflow.dataspace.provider.DataspaceDatabaseType;
|
||||||
|
import tech.easyflow.dataspace.security.DataspaceCredentialCipher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建数据空间测试池和 Federation 独占只读池。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class DataspaceDataSourceFactory {
|
||||||
|
|
||||||
|
private final DataspaceDatabaseProviderRegistry providerRegistry;
|
||||||
|
private final DataspaceCredentialCipher credentialCipher;
|
||||||
|
private final int maximumPoolSize;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建连接池工厂。
|
||||||
|
*
|
||||||
|
* @param providerRegistry 数据库 Provider 注册表
|
||||||
|
* @param credentialCipher 凭据解密器
|
||||||
|
* @param maximumPoolSize 单物理源最大池大小
|
||||||
|
*/
|
||||||
|
public DataspaceDataSourceFactory(
|
||||||
|
DataspaceDatabaseProviderRegistry providerRegistry,
|
||||||
|
DataspaceCredentialCipher credentialCipher,
|
||||||
|
@Value("${easyflow.dataspace.connection-pool.maximum-size:10}")
|
||||||
|
int maximumPoolSize) {
|
||||||
|
if (maximumPoolSize <= 0) {
|
||||||
|
throw new IllegalArgumentException("数据空间连接池大小必须大于零");
|
||||||
|
}
|
||||||
|
this.providerRegistry = providerRegistry;
|
||||||
|
this.credentialCipher = credentialCipher;
|
||||||
|
this.maximumPoolSize = maximumPoolSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为临时测试或元数据探测创建单连接池。
|
||||||
|
*
|
||||||
|
* @param config 连接配置
|
||||||
|
* @return 调用方负责关闭的连接池
|
||||||
|
*/
|
||||||
|
public HikariDataSource temporary(DataspaceConnectionConfig config) {
|
||||||
|
return create("dataspace-probe", config, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为已持久化连接创建 Federation 独占池。
|
||||||
|
*
|
||||||
|
* @param source 连接快照
|
||||||
|
* @return Federation Runtime 负责关闭的连接池
|
||||||
|
*/
|
||||||
|
public HikariDataSource runtime(DataspaceConnection source) {
|
||||||
|
return create("dataspace-" + source.getId() + "-r" + source.getDefinitionRevision(),
|
||||||
|
config(source), maximumPoolSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将持久化连接转换为节点内短生命周期明文配置。
|
||||||
|
*
|
||||||
|
* @param source 连接快照
|
||||||
|
* @return Provider 连接配置
|
||||||
|
*/
|
||||||
|
DataspaceConnectionConfig config(DataspaceConnection source) {
|
||||||
|
return new DataspaceConnectionConfig(
|
||||||
|
DataspaceDatabaseType.parse(source.getDatabaseType()),
|
||||||
|
source.getHost(), source.getPort(), source.getDatabaseName(), source.getUsername(),
|
||||||
|
credentialCipher.decrypt(source.getCredentialCipher()),
|
||||||
|
Integer.valueOf(1).equals(source.getSslEnabled()), source.getOptionsJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建带安全边界的只读 HikariCP 连接池。
|
||||||
|
*
|
||||||
|
* @param poolName 连接池名称
|
||||||
|
* @param config 数据库连接参数
|
||||||
|
* @param poolSize 最大连接数
|
||||||
|
* @return 初始化完成的连接池
|
||||||
|
*/
|
||||||
|
private HikariDataSource create(String poolName, DataspaceConnectionConfig config, int poolSize) {
|
||||||
|
DataspaceDatabaseProvider provider = providerRegistry.require(config.type());
|
||||||
|
HikariConfig hikari = new HikariConfig();
|
||||||
|
hikari.setPoolName(poolName);
|
||||||
|
hikari.setJdbcUrl(provider.jdbcUrl(config));
|
||||||
|
hikari.setDriverClassName(config.type().driverClassName());
|
||||||
|
hikari.setUsername(config.username());
|
||||||
|
hikari.setPassword(config.password());
|
||||||
|
hikari.setReadOnly(true);
|
||||||
|
hikari.setAutoCommit(true);
|
||||||
|
hikari.setMaximumPoolSize(poolSize);
|
||||||
|
hikari.setMinimumIdle(0);
|
||||||
|
hikari.setConnectionTimeout(5_000L);
|
||||||
|
hikari.setValidationTimeout(3_000L);
|
||||||
|
hikari.setIdleTimeout(600_000L);
|
||||||
|
hikari.setMaxLifetime(1_800_000L);
|
||||||
|
hikari.setInitializationFailTimeout(5_000L);
|
||||||
|
return new HikariDataSource(hikari);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package tech.easyflow.dataspace.federation;
|
||||||
|
|
||||||
|
import com.easyagents.federation.sql.source.FederationDataSourceHandle;
|
||||||
|
import com.easyagents.federation.sql.source.FederationDataSourceHandles;
|
||||||
|
import com.easyagents.federation.sql.source.FederationDataSourceResolver;
|
||||||
|
import com.easyagents.federation.sql.source.FederationSourceDefinition;
|
||||||
|
import com.easyagents.federation.sql.source.RuntimeFingerprint;
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DatabaseMetaData;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceConnection;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceConnectionMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 EasyFlow 权威连接记录解析节点本地 Federation DataSource。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class DataspaceDataSourceResolver implements FederationDataSourceResolver {
|
||||||
|
|
||||||
|
private final DataspaceConnectionMapper connectionMapper;
|
||||||
|
private final DataspaceDefinitionFactory definitionFactory;
|
||||||
|
private final DataspaceDataSourceFactory dataSourceFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 DataSource Resolver。
|
||||||
|
*
|
||||||
|
* @param connectionMapper 连接 Mapper
|
||||||
|
* @param definitionFactory Definition 工厂
|
||||||
|
* @param dataSourceFactory 连接池工厂
|
||||||
|
*/
|
||||||
|
public DataspaceDataSourceResolver(
|
||||||
|
DataspaceConnectionMapper connectionMapper,
|
||||||
|
DataspaceDefinitionFactory definitionFactory,
|
||||||
|
DataspaceDataSourceFactory dataSourceFactory) {
|
||||||
|
this.connectionMapper = connectionMapper;
|
||||||
|
this.definitionFactory = definitionFactory;
|
||||||
|
this.dataSourceFactory = dataSourceFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 revision 独占的只读连接池句柄。
|
||||||
|
*
|
||||||
|
* @param definition 无凭据 Definition
|
||||||
|
* @return 独占句柄
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public FederationDataSourceHandle resolve(FederationSourceDefinition definition) {
|
||||||
|
DataspaceConnection source = connectionMapper.selectOneById(
|
||||||
|
definitionFactory.connectionId(definition));
|
||||||
|
if (source == null || !"ENABLED".equals(source.getStatus())) {
|
||||||
|
throw new IllegalStateException("数据空间连接不存在或已禁用");
|
||||||
|
}
|
||||||
|
if (!definitionFactory.sourceId(source).equals(definition.sourceId())
|
||||||
|
|| source.getDefinitionRevision() == null
|
||||||
|
|| source.getDefinitionRevision() != definition.revision()) {
|
||||||
|
throw new IllegalStateException("数据空间连接 revision 已变化");
|
||||||
|
}
|
||||||
|
FederationSourceDefinition current = definitionFactory.create(source);
|
||||||
|
if (!current.checksum().equals(definition.checksum())
|
||||||
|
|| !current.checksum().equals(source.getDefinitionChecksum())) {
|
||||||
|
throw new IllegalStateException("数据空间连接 Definition 校验失败");
|
||||||
|
}
|
||||||
|
HikariDataSource pool = dataSourceFactory.runtime(source);
|
||||||
|
try (Connection connection = pool.getConnection()) {
|
||||||
|
DatabaseMetaData metadata = connection.getMetaData();
|
||||||
|
RuntimeFingerprint fingerprint = new RuntimeFingerprint(
|
||||||
|
metadata.getDatabaseProductName(), metadata.getDatabaseProductVersion(),
|
||||||
|
metadata.getDriverName(), metadata.getDriverVersion(), "easyflow-xl16");
|
||||||
|
return FederationDataSourceHandles.owned(pool, fingerprint, pool::close);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
pool.close();
|
||||||
|
throw new IllegalStateException("初始化数据空间连接池失败", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package tech.easyflow.dataspace.federation;
|
||||||
|
|
||||||
|
import com.easyagents.federation.sql.adapter.jdbc.JdbcFederationSqlAdapterProvider;
|
||||||
|
import com.easyagents.federation.sql.adapter.jdbc.JdbcSchemaDefinition;
|
||||||
|
import com.easyagents.federation.sql.source.FederationSourceDefinition;
|
||||||
|
import com.easyagents.federation.sql.source.SourceId;
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceConnection;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceObject;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceObjectMapper;
|
||||||
|
import tech.easyflow.dataspace.provider.DataspaceDatabaseProvider;
|
||||||
|
import tech.easyflow.dataspace.provider.DataspaceDatabaseProviderRegistry;
|
||||||
|
import tech.easyflow.dataspace.provider.DataspaceDatabaseType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将权威连接和元数据快照转换为无凭据 Federation Definition。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class DataspaceDefinitionFactory {
|
||||||
|
|
||||||
|
/** Definition 中的业务连接主键选项。 */
|
||||||
|
public static final String CONNECTION_ID_OPTION = "dataspaceConnectionId";
|
||||||
|
|
||||||
|
private final DataspaceObjectMapper objectMapper;
|
||||||
|
private final DataspaceDatabaseProviderRegistry providerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Definition 工厂。
|
||||||
|
*
|
||||||
|
* @param objectMapper 元数据对象 Mapper
|
||||||
|
* @param providerRegistry 数据库 Provider 注册表
|
||||||
|
*/
|
||||||
|
public DataspaceDefinitionFactory(
|
||||||
|
DataspaceObjectMapper objectMapper,
|
||||||
|
DataspaceDatabaseProviderRegistry providerRegistry) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.providerRegistry = providerRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从数据库加载当前元数据并创建 Definition。
|
||||||
|
*
|
||||||
|
* @param source 连接记录
|
||||||
|
* @return Federation Definition
|
||||||
|
*/
|
||||||
|
public FederationSourceDefinition create(DataspaceConnection source) {
|
||||||
|
List<DataspaceObject> objects = objectMapper.selectListByQuery(QueryWrapper.create()
|
||||||
|
.eq(DataspaceObject::getConnectionId, source.getId())
|
||||||
|
.eq(DataspaceObject::getMetadataRevision, source.getDefinitionRevision())
|
||||||
|
.orderBy("schema_name asc, object_name asc"));
|
||||||
|
return create(source, objects);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用给定元数据快照创建 Definition。
|
||||||
|
*
|
||||||
|
* @param source 连接记录
|
||||||
|
* @param objects 元数据对象
|
||||||
|
* @return Federation Definition
|
||||||
|
*/
|
||||||
|
public FederationSourceDefinition create(
|
||||||
|
DataspaceConnection source,
|
||||||
|
List<DataspaceObject> objects) {
|
||||||
|
DataspaceDatabaseType type = DataspaceDatabaseType.parse(source.getDatabaseType());
|
||||||
|
DataspaceDatabaseProvider provider = providerRegistry.require(type);
|
||||||
|
Set<String> seen = new LinkedHashSet<>();
|
||||||
|
List<JdbcSchemaDefinition> schemas = new ArrayList<>();
|
||||||
|
for (DataspaceObject object : objects == null ? List.<DataspaceObject>of() : objects) {
|
||||||
|
String logical = provider.logicalSchema(object.getSchemaName());
|
||||||
|
if (!seen.add(logical)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
schemas.add(type == DataspaceDatabaseType.MYSQL
|
||||||
|
? new JdbcSchemaDefinition(logical, source.getDatabaseName(), null)
|
||||||
|
: new JdbcSchemaDefinition(logical, null, object.getSchemaName()));
|
||||||
|
}
|
||||||
|
if (schemas.isEmpty()) {
|
||||||
|
schemas.add(type == DataspaceDatabaseType.MYSQL
|
||||||
|
? new JdbcSchemaDefinition("MAIN", source.getDatabaseName(), null)
|
||||||
|
: new JdbcSchemaDefinition("PUBLIC", null, "public"));
|
||||||
|
}
|
||||||
|
Map<String, String> options = new LinkedHashMap<>();
|
||||||
|
options.put(CONNECTION_ID_OPTION, source.getId().toString());
|
||||||
|
return new FederationSourceDefinition(
|
||||||
|
sourceId(source),
|
||||||
|
Math.max(1L, source.getDefinitionRevision() == null
|
||||||
|
? 1L : source.getDefinitionRevision()),
|
||||||
|
JdbcFederationSqlAdapterProvider.ADAPTER_ID,
|
||||||
|
new ArrayList<>(schemas),
|
||||||
|
options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回租户隔离且稳定的 Federation SourceId。
|
||||||
|
*
|
||||||
|
* @param source 连接记录
|
||||||
|
* @return SourceId
|
||||||
|
*/
|
||||||
|
public SourceId sourceId(DataspaceConnection source) {
|
||||||
|
BigInteger tenant = source.getTenantId() == null ? BigInteger.ZERO : source.getTenantId();
|
||||||
|
return new SourceId("tenant-" + tenant + "-dataspace-source-" + source.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Definition 提取业务连接 ID。
|
||||||
|
*
|
||||||
|
* @param definition Federation Definition
|
||||||
|
* @return 连接 ID
|
||||||
|
*/
|
||||||
|
public BigInteger connectionId(FederationSourceDefinition definition) {
|
||||||
|
String value = definition.adapterOptions().get(CONNECTION_ID_OPTION);
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Federation Definition 缺少连接 ID");
|
||||||
|
}
|
||||||
|
return new BigInteger(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package tech.easyflow.dataspace.federation;
|
||||||
|
|
||||||
|
import com.easyagents.federation.sql.adapter.jdbc.JdbcFederationSqlAdapterProvider;
|
||||||
|
import com.easyagents.federation.sql.api.FederationSqlEngine;
|
||||||
|
import com.easyagents.federation.sql.api.FederationSqlEngines;
|
||||||
|
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||||
|
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||||
|
import com.easyagents.federation.sql.source.FederationSourceDefinition;
|
||||||
|
import com.easyagents.federation.sql.source.FederationSourceView;
|
||||||
|
import com.easyagents.federation.sql.source.SourceApplyOptions;
|
||||||
|
import com.easyagents.federation.sql.source.SourceId;
|
||||||
|
import com.easyagents.federation.sql.source.SourceRuntimeStatus;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ConcurrentMap;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceConnection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间共享 Federation SQL Engine 生命周期。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class DataspaceFederationRuntime {
|
||||||
|
|
||||||
|
private final DataspaceDefinitionFactory definitionFactory;
|
||||||
|
private final FederationSqlEngine engine;
|
||||||
|
private final ConcurrentMap<SourceId, FederationSourceDefinition> definitions =
|
||||||
|
new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建数据空间 Federation Runtime。
|
||||||
|
*
|
||||||
|
* @param resolver DataSource Resolver
|
||||||
|
* @param definitionFactory Definition 工厂
|
||||||
|
* @param sqlPolicy 数据空间表白名单策略
|
||||||
|
*/
|
||||||
|
public DataspaceFederationRuntime(
|
||||||
|
DataspaceDataSourceResolver resolver,
|
||||||
|
DataspaceDefinitionFactory definitionFactory,
|
||||||
|
DataspaceSqlPolicy sqlPolicy) {
|
||||||
|
this.definitionFactory = definitionFactory;
|
||||||
|
this.engine = FederationSqlEngines.builder()
|
||||||
|
.dataSourceResolver(resolver)
|
||||||
|
.adapter(new JdbcFederationSqlAdapterProvider())
|
||||||
|
.policy(sqlPolicy)
|
||||||
|
.maximumPlanCacheEntries(1024)
|
||||||
|
.crossSourceEnabled(true)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确保连接当前 revision 已以懒加载方式应用到本节点。
|
||||||
|
*
|
||||||
|
* @param connection 连接快照
|
||||||
|
* @return Federation Definition
|
||||||
|
*/
|
||||||
|
public FederationSourceDefinition ensureSource(DataspaceConnection connection) {
|
||||||
|
SourceId sourceId = definitionFactory.sourceId(connection);
|
||||||
|
FederationSourceDefinition definition = definitions.compute(sourceId,
|
||||||
|
(ignored, current) -> reusable(current, connection)
|
||||||
|
? current : loadDefinition(connection));
|
||||||
|
FederationSourceView view = engine.sources().view(sourceId).orElse(null);
|
||||||
|
if (!applied(view, definition)) {
|
||||||
|
engine.sources().apply(definition, SourceApplyOptions.lazy());
|
||||||
|
}
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断缓存 Definition 是否仍对应连接的权威 revision 和 checksum。
|
||||||
|
*
|
||||||
|
* @param definition 缓存 Definition
|
||||||
|
* @param connection 连接快照
|
||||||
|
* @return 是否可复用
|
||||||
|
*/
|
||||||
|
private boolean reusable(
|
||||||
|
FederationSourceDefinition definition,
|
||||||
|
DataspaceConnection connection) {
|
||||||
|
return definition != null
|
||||||
|
&& connection.getDefinitionRevision() != null
|
||||||
|
&& definition.revision() == connection.getDefinitionRevision()
|
||||||
|
&& Objects.equals(definition.checksum(), connection.getDefinitionChecksum());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载并校验连接的完整物理 Schema Definition。
|
||||||
|
*
|
||||||
|
* @param connection 连接快照
|
||||||
|
* @return 完整 Federation Definition
|
||||||
|
* @throws FederationSqlException 权威 checksum 与元数据不一致时抛出
|
||||||
|
*/
|
||||||
|
private FederationSourceDefinition loadDefinition(DataspaceConnection connection) {
|
||||||
|
FederationSourceDefinition definition = definitionFactory.create(connection);
|
||||||
|
if (!Objects.equals(definition.checksum(), connection.getDefinitionChecksum())) {
|
||||||
|
throw new FederationSqlException(
|
||||||
|
FederationSqlErrorCode.SOURCE_DEFINITION_CONFLICT,
|
||||||
|
"dataspace source definition checksum does not match authoritative metadata");
|
||||||
|
}
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断 Definition 是否已经登记到本节点 Source Manager。
|
||||||
|
*
|
||||||
|
* @param view 节点本地 Source 视图
|
||||||
|
* @param definition 目标 Definition
|
||||||
|
* @return 是否已经应用
|
||||||
|
*/
|
||||||
|
private boolean applied(
|
||||||
|
FederationSourceView view,
|
||||||
|
FederationSourceDefinition definition) {
|
||||||
|
return view != null
|
||||||
|
&& view.status() != SourceRuntimeStatus.REMOVED
|
||||||
|
&& view.desiredRevision() == definition.revision()
|
||||||
|
&& Objects.equals(view.checksum(), definition.checksum());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回统一查询引擎。
|
||||||
|
*
|
||||||
|
* @return Federation SQL Engine
|
||||||
|
*/
|
||||||
|
public FederationSqlEngine engine() {
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用停止时关闭计划缓存与连接池。
|
||||||
|
*/
|
||||||
|
@PreDestroy
|
||||||
|
public void close() {
|
||||||
|
definitions.clear();
|
||||||
|
engine.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
package tech.easyflow.dataspace.federation;
|
||||||
|
|
||||||
|
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||||
|
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||||
|
import com.easyagents.federation.sql.compile.FederationSqlPolicy;
|
||||||
|
import com.easyagents.federation.sql.compile.SqlPolicyContext;
|
||||||
|
import com.easyagents.federation.sql.source.SourceId;
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import org.apache.calcite.rel.RelNode;
|
||||||
|
import org.apache.calcite.rel.RelVisitor;
|
||||||
|
import org.apache.calcite.rel.core.TableScan;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.dataspace.entity.Dataspace;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceConnection;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceObject;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceRevision;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceTableBinding;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceConnectionMapper;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceMapper;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceObjectMapper;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceRevisionMapper;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceTableBindingMapper;
|
||||||
|
import tech.easyflow.dataspace.service.DataspaceMetadataResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Calcite 已解析表限制在当前数据空间不可变 revision 内。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class DataspaceSqlPolicy implements FederationSqlPolicy {
|
||||||
|
|
||||||
|
private final DataspaceMapper dataspaceMapper;
|
||||||
|
private final DataspaceRevisionMapper revisionMapper;
|
||||||
|
private final DataspaceTableBindingMapper bindingMapper;
|
||||||
|
private final DataspaceObjectMapper objectMapper;
|
||||||
|
private final DataspaceConnectionMapper connectionMapper;
|
||||||
|
private final DataspaceDefinitionFactory definitionFactory;
|
||||||
|
private final DataspaceMetadataResolver metadataResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建数据空间 SQL Policy。
|
||||||
|
*
|
||||||
|
* @param dataspaceMapper 数据空间 Mapper
|
||||||
|
* @param revisionMapper revision Mapper
|
||||||
|
* @param bindingMapper 表绑定 Mapper
|
||||||
|
* @param objectMapper 元数据对象 Mapper
|
||||||
|
* @param connectionMapper 连接 Mapper
|
||||||
|
* @param definitionFactory Definition 工厂
|
||||||
|
* @param metadataResolver 元数据兼容解析器
|
||||||
|
*/
|
||||||
|
public DataspaceSqlPolicy(
|
||||||
|
DataspaceMapper dataspaceMapper,
|
||||||
|
DataspaceRevisionMapper revisionMapper,
|
||||||
|
DataspaceTableBindingMapper bindingMapper,
|
||||||
|
DataspaceObjectMapper objectMapper,
|
||||||
|
DataspaceConnectionMapper connectionMapper,
|
||||||
|
DataspaceDefinitionFactory definitionFactory,
|
||||||
|
DataspaceMetadataResolver metadataResolver) {
|
||||||
|
this.dataspaceMapper = dataspaceMapper;
|
||||||
|
this.revisionMapper = revisionMapper;
|
||||||
|
this.bindingMapper = bindingMapper;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.connectionMapper = connectionMapper;
|
||||||
|
this.definitionFactory = definitionFactory;
|
||||||
|
this.metadataResolver = metadataResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回策略版本。
|
||||||
|
*
|
||||||
|
* @return 策略版本
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String version() {
|
||||||
|
return "xl16-v1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验数据空间 revision、物理源集合和表白名单。
|
||||||
|
*
|
||||||
|
* @param context Calcite 策略上下文
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void validate(SqlPolicyContext context) {
|
||||||
|
ScopeIdentity identity = ScopeIdentity.parse(context.request().queryScope().definitionId());
|
||||||
|
Dataspace dataspace = dataspaceMapper.selectOneById(identity.dataspaceId());
|
||||||
|
if (dataspace == null) {
|
||||||
|
throw rejected("数据空间不存在");
|
||||||
|
}
|
||||||
|
// 查询开始时已经固定不可变 revision;后续保存新 revision 不应使在途查询失效。
|
||||||
|
DataspaceRevision revision = revisionMapper.selectOneByQuery(QueryWrapper.create()
|
||||||
|
.eq(DataspaceRevision::getDataspaceId, identity.dataspaceId())
|
||||||
|
.eq(DataspaceRevision::getRevisionNo, identity.revision()));
|
||||||
|
if (revision == null) {
|
||||||
|
throw rejected("数据空间 revision 不存在");
|
||||||
|
}
|
||||||
|
List<DataspaceTableBinding> bindings = bindingMapper.selectListByQuery(QueryWrapper.create()
|
||||||
|
.eq(DataspaceTableBinding::getRevisionId, revision.getId()));
|
||||||
|
PolicySnapshot snapshot = snapshot(bindings);
|
||||||
|
if (!snapshot.sources().containsAll(context.referencedSources())) {
|
||||||
|
throw rejected("SQL 引用了数据空间外的物理数据源");
|
||||||
|
}
|
||||||
|
new RelVisitor() {
|
||||||
|
/**
|
||||||
|
* 校验每个 Calcite 表扫描节点是否位于当前 revision 白名单。
|
||||||
|
*
|
||||||
|
* @param node 当前关系节点
|
||||||
|
* @param ordinal 输入序号
|
||||||
|
* @param parent 父节点
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void visit(RelNode node, int ordinal, RelNode parent) {
|
||||||
|
if (node instanceof TableScan scan) {
|
||||||
|
List<String> path = normalizeParts(scan.getTable().getQualifiedName());
|
||||||
|
if (!isAllowed(path, snapshot)) {
|
||||||
|
throw rejected("SQL 引用了数据空间外的表: " + String.join(".", path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
super.visit(node, ordinal, parent);
|
||||||
|
}
|
||||||
|
}.go(context.relRoot().rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从当前 revision 表绑定构建精确路径和物理源白名单。
|
||||||
|
*
|
||||||
|
* @param bindings 当前 revision 表绑定
|
||||||
|
* @return 策略快照
|
||||||
|
*/
|
||||||
|
private PolicySnapshot snapshot(List<DataspaceTableBinding> bindings) {
|
||||||
|
if (bindings.isEmpty()) {
|
||||||
|
throw rejected("数据空间尚未配置可查询表");
|
||||||
|
}
|
||||||
|
Set<BigInteger> objectIds = new HashSet<>();
|
||||||
|
Set<BigInteger> connectionIds = new HashSet<>();
|
||||||
|
for (DataspaceTableBinding binding : bindings) {
|
||||||
|
objectIds.add(binding.getObjectId());
|
||||||
|
connectionIds.add(binding.getConnectionId());
|
||||||
|
}
|
||||||
|
Map<BigInteger, DataspaceObject> objects = new HashMap<>();
|
||||||
|
objectMapper.selectListByQuery(QueryWrapper.create().in(DataspaceObject::getId, objectIds))
|
||||||
|
.forEach(item -> objects.put(item.getId(), item));
|
||||||
|
Map<BigInteger, DataspaceConnection> connections = new HashMap<>();
|
||||||
|
connectionMapper.selectListByQuery(QueryWrapper.create().in(DataspaceConnection::getId, connectionIds))
|
||||||
|
.forEach(item -> connections.put(item.getId(), item));
|
||||||
|
Map<BigInteger, DataspaceMetadataResolver.ObjectResolution> resolutions =
|
||||||
|
metadataResolver.resolve(objects, connections);
|
||||||
|
List<DataspaceTableBinding> activeBindings = bindings.stream()
|
||||||
|
.filter(binding -> {
|
||||||
|
DataspaceMetadataResolver.ObjectResolution resolution =
|
||||||
|
resolutions.get(binding.getObjectId());
|
||||||
|
return resolution != null && resolution.active();
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
if (activeBindings.isEmpty()) {
|
||||||
|
throw rejected("数据空间尚未配置可查询表");
|
||||||
|
}
|
||||||
|
Set<String> fullPaths = new HashSet<>();
|
||||||
|
Set<String> schemaPaths = new HashSet<>();
|
||||||
|
Map<String, Long> tableNameCounts = activeBindings.stream()
|
||||||
|
.map(DataspaceTableBinding::getObjectId)
|
||||||
|
.map(resolutions::get)
|
||||||
|
.map(DataspaceMetadataResolver.ObjectResolution::current)
|
||||||
|
.map(DataspaceObject::getObjectName)
|
||||||
|
.map(this::normalizePart)
|
||||||
|
.collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
|
||||||
|
Set<String> uniqueTableNames = tableNameCounts.entrySet().stream()
|
||||||
|
.filter(entry -> entry.getValue() == 1L)
|
||||||
|
.map(Map.Entry::getKey)
|
||||||
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
|
Set<SourceId> sources = new HashSet<>();
|
||||||
|
for (DataspaceTableBinding binding : activeBindings) {
|
||||||
|
DataspaceObject object = resolutions.get(binding.getObjectId()).current();
|
||||||
|
DataspaceConnection connection = connections.get(binding.getConnectionId());
|
||||||
|
if (object == null || connection == null) {
|
||||||
|
throw rejected("数据空间包含不可用的表绑定");
|
||||||
|
}
|
||||||
|
// 策略阶段再次按稳定物理身份解析,仅将当前可用表加入白名单。
|
||||||
|
fullPaths.add(normalize(List.of(
|
||||||
|
binding.getSourceAlias(), binding.getSchemaAlias(), object.getObjectName())));
|
||||||
|
schemaPaths.add(normalize(List.of(binding.getSchemaAlias(), object.getObjectName())));
|
||||||
|
sources.add(definitionFactory.sourceId(connection));
|
||||||
|
}
|
||||||
|
return new PolicySnapshot(
|
||||||
|
Set.copyOf(fullPaths), Set.copyOf(schemaPaths), uniqueTableNames, Set.copyOf(sources));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 Calcite 解析后的限定名层级校验表,避免同名表通过后缀匹配越过物理源边界。
|
||||||
|
*
|
||||||
|
* @param path Calcite 表限定名
|
||||||
|
* @param snapshot 当前 revision 白名单快照
|
||||||
|
* @return 是否允许访问
|
||||||
|
*/
|
||||||
|
private boolean isAllowed(List<String> path, PolicySnapshot snapshot) {
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (path.size() >= 3) {
|
||||||
|
String fullPath = String.join(".", path.subList(path.size() - 3, path.size()));
|
||||||
|
return snapshot.fullPaths().contains(fullPath);
|
||||||
|
}
|
||||||
|
if (path.size() == 2) {
|
||||||
|
return snapshot.schemaPaths().contains(String.join(".", path));
|
||||||
|
}
|
||||||
|
return snapshot.uniqueTableNames().contains(path.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将限定名片段规范化后拼接为精确路径。
|
||||||
|
*
|
||||||
|
* @param names 限定名片段
|
||||||
|
* @return 规范化路径
|
||||||
|
*/
|
||||||
|
private String normalize(List<String> names) {
|
||||||
|
return String.join(".", normalizeParts(names));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化限定名各段,便于大小写不敏感地进行精确比较。
|
||||||
|
*
|
||||||
|
* @param names 原始限定名
|
||||||
|
* @return 规范化后的限定名片段
|
||||||
|
*/
|
||||||
|
private List<String> normalizeParts(List<String> names) {
|
||||||
|
return names.stream()
|
||||||
|
.filter(value -> value != null && !value.isBlank())
|
||||||
|
.map(this::normalizePart)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化单个限定名片段。
|
||||||
|
*
|
||||||
|
* @param value 原始片段
|
||||||
|
* @return 规范化片段
|
||||||
|
*/
|
||||||
|
private String normalizePart(String value) {
|
||||||
|
return value.toUpperCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建统一的 SQL 校验异常。
|
||||||
|
*
|
||||||
|
* @param message 校验失败信息
|
||||||
|
* @return Federation SQL 异常
|
||||||
|
*/
|
||||||
|
private FederationSqlException rejected(String message) {
|
||||||
|
return new FederationSqlException(FederationSqlErrorCode.SQL_VALIDATION_FAILED, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前 revision 的表路径与物理源白名单。
|
||||||
|
*
|
||||||
|
* @param fullPaths source.schema.table 路径
|
||||||
|
* @param schemaPaths schema.table 路径
|
||||||
|
* @param uniqueTableNames 在当前空间唯一的非限定表名
|
||||||
|
* @param sources 物理源集合
|
||||||
|
*/
|
||||||
|
private record PolicySnapshot(
|
||||||
|
Set<String> fullPaths,
|
||||||
|
Set<String> schemaPaths,
|
||||||
|
Set<String> uniqueTableNames,
|
||||||
|
Set<SourceId> sources) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从查询作用域标识解析出的数据空间 revision。
|
||||||
|
*
|
||||||
|
* @param dataspaceId 数据空间 ID
|
||||||
|
* @param revision revision 号
|
||||||
|
*/
|
||||||
|
private record ScopeIdentity(BigInteger dataspaceId, long revision) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 dataspace:{id}:{revision} 格式的作用域标识。
|
||||||
|
*
|
||||||
|
* @param definitionId 作用域标识
|
||||||
|
* @return 作用域身份
|
||||||
|
* @throws FederationSqlException 格式非法时抛出
|
||||||
|
*/
|
||||||
|
private static ScopeIdentity parse(String definitionId) {
|
||||||
|
if (definitionId == null || !definitionId.startsWith("dataspace:")) {
|
||||||
|
throw new FederationSqlException(
|
||||||
|
FederationSqlErrorCode.SQL_VALIDATION_FAILED, "数据空间范围标识无效");
|
||||||
|
}
|
||||||
|
String[] parts = definitionId.split(":");
|
||||||
|
if (parts.length != 3) {
|
||||||
|
throw new FederationSqlException(
|
||||||
|
FederationSqlErrorCode.SQL_VALIDATION_FAILED, "数据空间范围标识无效");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new ScopeIdentity(new BigInteger(parts[1]), Long.parseLong(parts[2]));
|
||||||
|
} catch (NumberFormatException exception) {
|
||||||
|
throw new FederationSqlException(
|
||||||
|
FederationSqlErrorCode.SQL_VALIDATION_FAILED, "数据空间范围标识无效", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package tech.easyflow.dataspace.mapper;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.BaseMapper;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceConnection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间连接 Mapper。
|
||||||
|
*/
|
||||||
|
public interface DataspaceConnectionMapper extends BaseMapper<DataspaceConnection> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以 expected revision 原子推进连接 Definition。
|
||||||
|
*
|
||||||
|
* @param id 连接 ID
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param expectedRevision 期望 revision
|
||||||
|
* @param nextRevision 新 revision
|
||||||
|
* @param checksum 新校验和
|
||||||
|
* @return 更新行数
|
||||||
|
*/
|
||||||
|
@Update("UPDATE tb_dataspace_connection SET definition_revision = #{nextRevision}, "
|
||||||
|
+ "definition_checksum = #{checksum}, modified = CURRENT_TIMESTAMP "
|
||||||
|
+ "WHERE id = #{id} AND tenant_id = #{tenantId} AND is_deleted = 0 "
|
||||||
|
+ "AND definition_revision = #{expectedRevision}")
|
||||||
|
int compareAndSetRevision(
|
||||||
|
@Param("id") BigInteger id,
|
||||||
|
@Param("tenantId") BigInteger tenantId,
|
||||||
|
@Param("expectedRevision") long expectedRevision,
|
||||||
|
@Param("nextRevision") long nextRevision,
|
||||||
|
@Param("checksum") String checksum);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package tech.easyflow.dataspace.mapper;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.BaseMapper;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
|
import tech.easyflow.dataspace.entity.Dataspace;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间 Mapper。
|
||||||
|
*/
|
||||||
|
public interface DataspaceMapper extends BaseMapper<Dataspace> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子切换数据空间当前 revision。
|
||||||
|
*
|
||||||
|
* @param id 数据空间 ID
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param expectedRevision 期望 revision
|
||||||
|
* @param nextRevision 新 revision
|
||||||
|
* @return 更新行数
|
||||||
|
*/
|
||||||
|
@Update("UPDATE tb_dataspace SET current_revision = #{nextRevision}, "
|
||||||
|
+ "modified = CURRENT_TIMESTAMP WHERE id = #{id} AND tenant_id = #{tenantId} "
|
||||||
|
+ "AND is_deleted = 0 AND current_revision = #{expectedRevision}")
|
||||||
|
int compareAndSetCurrentRevision(
|
||||||
|
@Param("id") BigInteger id,
|
||||||
|
@Param("tenantId") BigInteger tenantId,
|
||||||
|
@Param("expectedRevision") long expectedRevision,
|
||||||
|
@Param("nextRevision") long nextRevision);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package tech.easyflow.dataspace.mapper;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.BaseMapper;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import org.apache.ibatis.annotations.Delete;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间元数据对象 Mapper。
|
||||||
|
*/
|
||||||
|
public interface DataspaceObjectMapper extends BaseMapper<DataspaceObject> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 物理删除指定连接的旧元数据快照。
|
||||||
|
*
|
||||||
|
* @param connectionId 连接 ID
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @return 删除行数
|
||||||
|
*/
|
||||||
|
@Delete("DELETE FROM tb_dataspace_object WHERE connection_id = #{connectionId} "
|
||||||
|
+ "AND tenant_id = #{tenantId}")
|
||||||
|
int deleteSnapshot(
|
||||||
|
@Param("connectionId") BigInteger connectionId,
|
||||||
|
@Param("tenantId") BigInteger tenantId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package tech.easyflow.dataspace.mapper;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.BaseMapper;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceQueryAudit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间查询审计 Mapper。
|
||||||
|
*/
|
||||||
|
public interface DataspaceQueryAuditMapper extends BaseMapper<DataspaceQueryAudit> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package tech.easyflow.dataspace.mapper;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.BaseMapper;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceRelation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间关联 Mapper。
|
||||||
|
*/
|
||||||
|
public interface DataspaceRelationMapper extends BaseMapper<DataspaceRelation> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package tech.easyflow.dataspace.mapper;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.BaseMapper;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceRevision;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间 revision Mapper。
|
||||||
|
*/
|
||||||
|
public interface DataspaceRevisionMapper extends BaseMapper<DataspaceRevision> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package tech.easyflow.dataspace.mapper;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.BaseMapper;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceTableBinding;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间表绑定 Mapper。
|
||||||
|
*/
|
||||||
|
public interface DataspaceTableBindingMapper extends BaseMapper<DataspaceTableBinding> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package tech.easyflow.dataspace.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 物理数据库连接创建、更新或临时测试请求。
|
||||||
|
*
|
||||||
|
* @param id 更新时的连接 ID
|
||||||
|
* @param expectedRevision 更新时的期望 revision
|
||||||
|
* @param name 连接名称
|
||||||
|
* @param databaseType 数据库类型
|
||||||
|
* @param host 主机
|
||||||
|
* @param port 端口
|
||||||
|
* @param databaseName 数据库名
|
||||||
|
* @param username 用户名
|
||||||
|
* @param password 密码;更新时为空表示保留原密码
|
||||||
|
* @param sslEnabled 是否启用 SSL
|
||||||
|
* @param options 扩展选项
|
||||||
|
*/
|
||||||
|
public record ConnectionDefinition(
|
||||||
|
BigInteger id,
|
||||||
|
Long expectedRevision,
|
||||||
|
String name,
|
||||||
|
String databaseType,
|
||||||
|
String host,
|
||||||
|
Integer port,
|
||||||
|
String databaseName,
|
||||||
|
String username,
|
||||||
|
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String password,
|
||||||
|
Boolean sslEnabled,
|
||||||
|
Map<String, Object> options) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防御性复制扩展选项。
|
||||||
|
*/
|
||||||
|
public ConnectionDefinition {
|
||||||
|
options = Map.copyOf(options == null ? Map.of() : options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package tech.easyflow.dataspace.model;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Date;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceConnection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 不含凭据的连接视图。
|
||||||
|
*
|
||||||
|
* @param id 连接 ID
|
||||||
|
* @param name 名称
|
||||||
|
* @param databaseType 数据库类型
|
||||||
|
* @param host 主机
|
||||||
|
* @param port 端口
|
||||||
|
* @param databaseName 数据库名
|
||||||
|
* @param username 用户名
|
||||||
|
* @param sslEnabled 是否启用 SSL
|
||||||
|
* @param definitionRevision Definition revision
|
||||||
|
* @param status 状态
|
||||||
|
* @param lastTestStatus 最近测试状态
|
||||||
|
* @param lastTestMessage 最近测试信息
|
||||||
|
* @param databaseProduct 数据库产品
|
||||||
|
* @param databaseVersion 数据库版本
|
||||||
|
* @param driverName 驱动名称
|
||||||
|
* @param driverVersion 驱动版本
|
||||||
|
* @param lastTestedAt 最近测试时间
|
||||||
|
* @param metadataRefreshedAt 元数据刷新时间
|
||||||
|
*/
|
||||||
|
public record ConnectionView(
|
||||||
|
BigInteger id,
|
||||||
|
String name,
|
||||||
|
String databaseType,
|
||||||
|
String host,
|
||||||
|
Integer port,
|
||||||
|
String databaseName,
|
||||||
|
String username,
|
||||||
|
boolean sslEnabled,
|
||||||
|
Long definitionRevision,
|
||||||
|
String status,
|
||||||
|
String lastTestStatus,
|
||||||
|
String lastTestMessage,
|
||||||
|
String databaseProduct,
|
||||||
|
String databaseVersion,
|
||||||
|
String driverName,
|
||||||
|
String driverVersion,
|
||||||
|
Date lastTestedAt,
|
||||||
|
Date metadataRefreshedAt) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从持久化记录创建安全视图。
|
||||||
|
*
|
||||||
|
* @param source 连接记录
|
||||||
|
* @return 安全视图
|
||||||
|
*/
|
||||||
|
public static ConnectionView from(DataspaceConnection source) {
|
||||||
|
return new ConnectionView(
|
||||||
|
source.getId(), source.getName(), source.getDatabaseType(), source.getHost(),
|
||||||
|
source.getPort(), source.getDatabaseName(), source.getUsername(),
|
||||||
|
Integer.valueOf(1).equals(source.getSslEnabled()), source.getDefinitionRevision(),
|
||||||
|
source.getStatus(), source.getLastTestStatus(), source.getLastTestMessage(),
|
||||||
|
source.getDatabaseProduct(), source.getDatabaseVersion(), source.getDriverName(),
|
||||||
|
source.getDriverVersion(), source.getLastTestedAt(), source.getMetadataRefreshedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package tech.easyflow.dataspace.model;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson.JSONObject;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间保存请求;每次保存都会生成新的不可变 revision。
|
||||||
|
*
|
||||||
|
* @param id 更新时的数据空间 ID
|
||||||
|
* @param expectedRevision 更新时的期望 revision
|
||||||
|
* @param name 名称
|
||||||
|
* @param description 说明
|
||||||
|
* @param tables 纳管表
|
||||||
|
* @param relations 表关系
|
||||||
|
*/
|
||||||
|
public record DataspaceDefinition(
|
||||||
|
BigInteger id,
|
||||||
|
Long expectedRevision,
|
||||||
|
String name,
|
||||||
|
String description,
|
||||||
|
List<TableDefinition> tables,
|
||||||
|
List<RelationDefinition> relations) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防御性复制保存请求。
|
||||||
|
*/
|
||||||
|
public DataspaceDefinition {
|
||||||
|
tables = typedCopy(tables, TableDefinition.class, "tables");
|
||||||
|
relations = typedCopy(relations, RelationDefinition.class, "relations");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将请求转换器保留的 JSON 对象恢复成强类型定义,并创建不可变副本。
|
||||||
|
*
|
||||||
|
* @param values 原始列表
|
||||||
|
* @param elementType 元素类型
|
||||||
|
* @param fieldName 请求字段名
|
||||||
|
* @param <T> 元素类型
|
||||||
|
* @return 强类型不可变列表
|
||||||
|
* @throws IllegalArgumentException 元素无法转换时抛出
|
||||||
|
*/
|
||||||
|
private static <T> List<T> typedCopy(
|
||||||
|
List<?> values,
|
||||||
|
Class<T> elementType,
|
||||||
|
String fieldName) {
|
||||||
|
if (values == null || values.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return values.stream()
|
||||||
|
.map(value -> convertElement(value, elementType, fieldName))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换一个嵌套定义元素。
|
||||||
|
*
|
||||||
|
* @param value 原始元素
|
||||||
|
* @param elementType 目标类型
|
||||||
|
* @param fieldName 请求字段名
|
||||||
|
* @param <T> 元素类型
|
||||||
|
* @return 强类型元素
|
||||||
|
* @throws IllegalArgumentException 元素为空或无法转换时抛出
|
||||||
|
*/
|
||||||
|
private static <T> T convertElement(
|
||||||
|
Object value,
|
||||||
|
Class<T> elementType,
|
||||||
|
String fieldName) {
|
||||||
|
if (elementType.isInstance(value)) {
|
||||||
|
return elementType.cast(value);
|
||||||
|
}
|
||||||
|
if (value instanceof JSONObject jsonObject) {
|
||||||
|
return jsonObject.toJavaObject(elementType);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException(fieldName + " 包含无效元素");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一张纳管表的画布与 SQL 名称定义。
|
||||||
|
*
|
||||||
|
* @param clientKey 前端稳定临时键,供关系引用
|
||||||
|
* @param objectId 元数据对象 ID
|
||||||
|
* @param sourceAlias SQL 第一段数据源别名
|
||||||
|
* @param schemaAlias SQL 第二段 Schema 别名
|
||||||
|
* @param tableAlias 数据空间内全局唯一的逻辑表名
|
||||||
|
* @param positionX 画布横坐标
|
||||||
|
* @param positionY 画布纵坐标
|
||||||
|
*/
|
||||||
|
public record TableDefinition(
|
||||||
|
String clientKey,
|
||||||
|
BigInteger objectId,
|
||||||
|
String sourceAlias,
|
||||||
|
String schemaAlias,
|
||||||
|
String tableAlias,
|
||||||
|
Integer positionX,
|
||||||
|
Integer positionY) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 两张纳管表之间的字段关系。
|
||||||
|
*
|
||||||
|
* @param leftClientKey 左表临时键
|
||||||
|
* @param rightClientKey 右表临时键
|
||||||
|
* @param joinType Join 类型
|
||||||
|
* @param leftColumn 左字段
|
||||||
|
* @param rightColumn 右字段
|
||||||
|
*/
|
||||||
|
public record RelationDefinition(
|
||||||
|
String leftClientKey,
|
||||||
|
String rightClientKey,
|
||||||
|
String joinType,
|
||||||
|
String leftColumn,
|
||||||
|
String rightColumn) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package tech.easyflow.dataspace.model;
|
||||||
|
|
||||||
|
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||||
|
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.dataspace.security.DataspaceCredentialUnavailableException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间稳定业务错误码及 HTTP 语义。
|
||||||
|
*/
|
||||||
|
public enum DataspaceErrorCode {
|
||||||
|
|
||||||
|
/** 聚合 revision 冲突。 */
|
||||||
|
REVISION_CONFLICT(409, 40901, "数据已被其他操作更新,请刷新后重试"),
|
||||||
|
/** 连接仍被数据空间引用。 */
|
||||||
|
CONNECTION_IN_USE(409, 40902, "连接已被数据空间引用,无法删除"),
|
||||||
|
/** 资源名称重复。 */
|
||||||
|
NAME_CONFLICT(409, 40903, "名称已存在"),
|
||||||
|
/** 查询 ID 正在使用。 */
|
||||||
|
QUERY_ID_CONFLICT(409, 40904, "查询 ID 已在执行中"),
|
||||||
|
/** 当前账号无权操作目标查询或资源。 */
|
||||||
|
ACCESS_DENIED(403, 40361, "无权执行该操作"),
|
||||||
|
/** 物理连接不存在。 */
|
||||||
|
CONNECTION_NOT_FOUND(404, 40461, "数据连接不存在"),
|
||||||
|
/** 数据空间不存在。 */
|
||||||
|
DATASPACE_NOT_FOUND(404, 40462, "数据空间不存在"),
|
||||||
|
/** 数据空间已禁用。 */
|
||||||
|
DATASPACE_DISABLED(409, 40961, "数据空间已禁用,请先启用后再查询"),
|
||||||
|
/** 物理连接已禁用。 */
|
||||||
|
CONNECTION_DISABLED(409, 40962, "数据连接已禁用,请先启用并测试连接"),
|
||||||
|
/** 已绑定物理对象在最新元数据中不存在。 */
|
||||||
|
OBJECT_MISSING(409, 40963, "已绑定的数据库对象不存在,请刷新元数据或移除该表"),
|
||||||
|
/** 关系字段在最新元数据中不存在。 */
|
||||||
|
RELATION_FIELD_MISSING(409, 40964, "关联字段已不存在,请重新连线或删除该关系"),
|
||||||
|
/** 关系字段类型不再可比较。 */
|
||||||
|
RELATION_TYPE_INCOMPATIBLE(409, 40965, "关联字段类型不兼容,请重新选择字段"),
|
||||||
|
/** 查询被主动取消。 */
|
||||||
|
QUERY_CANCELLED(409, 40966, "查询已取消"),
|
||||||
|
/** SQL 解析失败。 */
|
||||||
|
SQL_PARSE_FAILED(400, 40061, "SQL 语法解析失败,请检查语句"),
|
||||||
|
/** SQL 校验失败。 */
|
||||||
|
SQL_VALIDATION_FAILED(400, 40062, "SQL 校验失败,请检查表名和字段名"),
|
||||||
|
/** 查询不满足只读限制。 */
|
||||||
|
READ_ONLY_REQUIRED(400, 40063, "仅允许执行单条只读查询"),
|
||||||
|
/** SQL 引用了数据空间外的对象。 */
|
||||||
|
OBJECT_OUT_OF_SCOPE(400, 40064, "SQL 引用了当前数据空间之外的对象"),
|
||||||
|
/** 数据空间建模定义不完整。 */
|
||||||
|
DEFINITION_INVALID(400, 40065, "数据空间配置不完整,请检查后重试"),
|
||||||
|
/** 查询请求参数不合法。 */
|
||||||
|
QUERY_REQUEST_INVALID(400, 40066, "查询参数不正确"),
|
||||||
|
/** 数据连接定义不合法。 */
|
||||||
|
CONNECTION_DEFINITION_INVALID(400, 40067, "数据连接配置不完整,请检查后重试"),
|
||||||
|
/** 查询准入超时。 */
|
||||||
|
QUERY_ADMISSION_TIMEOUT(408, 40861, "查询排队超时,请稍后重试"),
|
||||||
|
/** 查询执行超时。 */
|
||||||
|
QUERY_TIMEOUT(408, 40862, "查询执行超时,请缩小查询范围后重试"),
|
||||||
|
/** SQL 编译超时。 */
|
||||||
|
SQL_COMPILE_TIMEOUT(408, 40863, "SQL 编译超时,请简化语句后重试"),
|
||||||
|
/** 节点查询内存准入超时。 */
|
||||||
|
NODE_MEMORY_ADMISSION_TIMEOUT(429, 42961, "节点查询资源繁忙,请稍后重试"),
|
||||||
|
/** 联邦资源超过平台上限。 */
|
||||||
|
QUERY_LIMIT_EXCEEDED(422, 42261, "查询结果或中间数据超过平台限制,请缩小查询范围"),
|
||||||
|
/** 当前查询能力暂不支持。 */
|
||||||
|
QUERY_UNSUPPORTED(422, 42262, "当前查询暂不支持,请简化 SQL 后重试"),
|
||||||
|
/** 连接测试未通过。 */
|
||||||
|
CONNECTION_TEST_FAILED(422, 42263, "连接测试未通过,请检查连接信息"),
|
||||||
|
/** 查询依赖的数据源不可用。 */
|
||||||
|
SOURCE_UNAVAILABLE(503, 50361, "查询使用的数据连接当前不可用,请检查连接状态"),
|
||||||
|
/** 底层数据库或联邦执行失败。 */
|
||||||
|
FEDERATION_EXECUTION_FAILED(503, 50362, "数据库执行失败,请检查 SQL 和数据源状态"),
|
||||||
|
/** Explain 执行失败。 */
|
||||||
|
EXPLAIN_FAILED(503, 50363, "执行计划获取失败,请检查数据源状态后重试"),
|
||||||
|
/** SQL 补全暂时不可用。 */
|
||||||
|
SQL_COMPLETION_FAILED(503, 50364, "SQL 补全暂不可用,请稍后重试"),
|
||||||
|
/** 数据库连接池等待超时。 */
|
||||||
|
CONNECTION_ACQUISITION_TIMEOUT(503, 50365, "数据库连接池繁忙,请稍后重试"),
|
||||||
|
/** 无法获取数据库连接。 */
|
||||||
|
CONNECTION_ACQUISITION_FAILED(503, 50366, "无法获取数据库连接,请检查连接状态");
|
||||||
|
|
||||||
|
private final int httpStatus;
|
||||||
|
private final int code;
|
||||||
|
private final String defaultMessage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建错误码定义。
|
||||||
|
*
|
||||||
|
* @param httpStatus HTTP 状态码
|
||||||
|
* @param code 稳定业务错误码
|
||||||
|
* @param defaultMessage 默认用户提示
|
||||||
|
*/
|
||||||
|
DataspaceErrorCode(int httpStatus, int code, String defaultMessage) {
|
||||||
|
this.httpStatus = httpStatus;
|
||||||
|
this.code = code;
|
||||||
|
this.defaultMessage = defaultMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建使用默认提示的业务异常。
|
||||||
|
*
|
||||||
|
* @return 业务异常
|
||||||
|
*/
|
||||||
|
public BusinessException exception() {
|
||||||
|
return new BusinessException(httpStatus, code, defaultMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建使用指定安全提示的业务异常。
|
||||||
|
*
|
||||||
|
* @param message 可安全展示的提示
|
||||||
|
* @return 业务异常
|
||||||
|
*/
|
||||||
|
public BusinessException exception(String message) {
|
||||||
|
return new BusinessException(httpStatus, code, normalizeMessage(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建保留原始根因的业务异常。
|
||||||
|
*
|
||||||
|
* @param message 可安全展示的提示
|
||||||
|
* @param cause 原始异常
|
||||||
|
* @return 业务异常
|
||||||
|
*/
|
||||||
|
public BusinessException exception(String message, Throwable cause) {
|
||||||
|
return new BusinessException(httpStatus, code, normalizeMessage(message), cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Federation SQL 错误转换为数据空间错误契约。
|
||||||
|
*
|
||||||
|
* @param exception Federation SQL 异常
|
||||||
|
* @param explain 是否来自 Explain API
|
||||||
|
* @return 携带稳定 HTTP 状态和业务码的异常
|
||||||
|
*/
|
||||||
|
public static BusinessException fromFederation(
|
||||||
|
FederationSqlException exception,
|
||||||
|
boolean explain) {
|
||||||
|
Throwable cause = exception.getCause();
|
||||||
|
while (cause != null) {
|
||||||
|
// 数据源初始化会包装调用方异常,凭据失效需要保留可恢复的 40905 提示。
|
||||||
|
if (cause instanceof DataspaceCredentialUnavailableException credentialException) {
|
||||||
|
return credentialException;
|
||||||
|
}
|
||||||
|
cause = cause.getCause();
|
||||||
|
}
|
||||||
|
FederationSqlErrorCode source = exception.errorCode();
|
||||||
|
DataspaceErrorCode target = switch (source) {
|
||||||
|
case SQL_PARSE_FAILED -> SQL_PARSE_FAILED;
|
||||||
|
case SQL_VALIDATION_FAILED, INVALID_QUERY_SCOPE -> SQL_VALIDATION_FAILED;
|
||||||
|
case SQL_NOT_READ_ONLY -> READ_ONLY_REQUIRED;
|
||||||
|
case QUERY_ADMISSION_TIMEOUT -> QUERY_ADMISSION_TIMEOUT;
|
||||||
|
case QUERY_TIMEOUT -> QUERY_TIMEOUT;
|
||||||
|
case SQL_COMPILE_TIMEOUT -> SQL_COMPILE_TIMEOUT;
|
||||||
|
case NODE_MEMORY_ADMISSION_TIMEOUT -> NODE_MEMORY_ADMISSION_TIMEOUT;
|
||||||
|
case CONNECTION_ACQUISITION_TIMEOUT -> CONNECTION_ACQUISITION_TIMEOUT;
|
||||||
|
case CONNECTION_ACQUISITION_FAILED -> CONNECTION_ACQUISITION_FAILED;
|
||||||
|
case QUERY_CANCELLED -> QUERY_CANCELLED;
|
||||||
|
case FEDERATION_RESOURCE_LIMIT_EXCEEDED -> QUERY_LIMIT_EXCEEDED;
|
||||||
|
case SOURCE_NOT_FOUND, SOURCE_REMOVED, SOURCE_REVISION_NOT_READY,
|
||||||
|
SOURCE_DEFINITION_CONFLICT, SOURCE_INITIALIZATION_FAILED,
|
||||||
|
ADAPTER_NOT_FOUND, ADAPTER_UNSUPPORTED, PLAN_STALE, ENGINE_CLOSED ->
|
||||||
|
SOURCE_UNAVAILABLE;
|
||||||
|
case EXPLAIN_FAILED -> EXPLAIN_FAILED;
|
||||||
|
case SQL_COMPLETION_FAILED -> SQL_COMPLETION_FAILED;
|
||||||
|
case CROSS_SOURCE_DISABLED, CROSS_SOURCE_EXECUTION_UNSUPPORTED,
|
||||||
|
FEDERATION_OPERATOR_UNSUPPORTED, SQL_NOT_FULLY_PUSHDOWN -> QUERY_UNSUPPORTED;
|
||||||
|
case EXECUTION_FAILED, RESOURCE_CLOSE_FAILED -> explain
|
||||||
|
? EXPLAIN_FAILED : FEDERATION_EXECUTION_FAILED;
|
||||||
|
case INVALID_ARGUMENT, SQL_COMPILE_FAILED, PARAMETER_COUNT_MISMATCH ->
|
||||||
|
SQL_VALIDATION_FAILED;
|
||||||
|
};
|
||||||
|
String message = target == SQL_PARSE_FAILED || target == SQL_VALIDATION_FAILED
|
||||||
|
? appendSafeDetail(target.defaultMessage, localizeSqlDetail(exception.getMessage()))
|
||||||
|
: target.defaultMessage;
|
||||||
|
return target.exception(message, exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将底层逻辑表定位信息转换为用户可直接处理的提示。
|
||||||
|
*
|
||||||
|
* @param detail Federation SQL 原始详情
|
||||||
|
* @return 本地化后的安全详情
|
||||||
|
*/
|
||||||
|
private static String localizeSqlDetail(String detail) {
|
||||||
|
if (detail == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String prefix = "logical table is not declared in the query scope: ";
|
||||||
|
if (detail.startsWith(prefix)) {
|
||||||
|
String table = detail.substring(prefix.length()).trim();
|
||||||
|
return "逻辑表 “" + table + "” 不在当前数据空间中,请检查逻辑表名";
|
||||||
|
}
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取稳定数值错误码。
|
||||||
|
*
|
||||||
|
* @return 数值错误码
|
||||||
|
*/
|
||||||
|
public int code() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取默认用户提示。
|
||||||
|
*
|
||||||
|
* @return 默认提示
|
||||||
|
*/
|
||||||
|
public String defaultMessage() {
|
||||||
|
return defaultMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化自定义提示。
|
||||||
|
*
|
||||||
|
* @param message 原始提示
|
||||||
|
* @return 非空提示
|
||||||
|
*/
|
||||||
|
private String normalizeMessage(String message) {
|
||||||
|
return message == null || message.isBlank() ? defaultMessage : message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为 SQL 解析和校验错误保留有界的底层定位信息。
|
||||||
|
*
|
||||||
|
* @param prefix 用户提示前缀
|
||||||
|
* @param detail 底层安全详情
|
||||||
|
* @return 有界提示
|
||||||
|
*/
|
||||||
|
private static String appendSafeDetail(String prefix, String detail) {
|
||||||
|
if (detail == null || detail.isBlank()) {
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
String normalized = detail.replaceAll("[\\r\\n\\t]+", " ").trim();
|
||||||
|
if (normalized.length() > 180) {
|
||||||
|
normalized = normalized.substring(0, 180) + "…";
|
||||||
|
}
|
||||||
|
return prefix + ":" + normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package tech.easyflow.dataspace.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间 SQL Explain 视图。
|
||||||
|
*
|
||||||
|
* @param queryMode 查询模式
|
||||||
|
* @param normalizedSql 统一规范化 SQL
|
||||||
|
* @param executionPlan 实际执行计划
|
||||||
|
* @param executable 是否可执行
|
||||||
|
* @param diagnostic 诊断信息
|
||||||
|
* @param statisticsStatus 统计完整性与时效状态
|
||||||
|
* @param estimateAvailable 聚合成本数值是否为有效估算
|
||||||
|
* @param estimatedTransferBytes 预计跨源搬运字节数
|
||||||
|
* @param estimatedLocalMemoryBytes 预计本地计算内存字节数
|
||||||
|
* @param joins Join 优化选择
|
||||||
|
* @param fragments 数据库分片计划
|
||||||
|
*/
|
||||||
|
public record DataspaceExplainResult(
|
||||||
|
String queryMode,
|
||||||
|
String normalizedSql,
|
||||||
|
String executionPlan,
|
||||||
|
boolean executable,
|
||||||
|
String diagnostic,
|
||||||
|
String statisticsStatus,
|
||||||
|
boolean estimateAvailable,
|
||||||
|
double estimatedTransferBytes,
|
||||||
|
long estimatedLocalMemoryBytes,
|
||||||
|
List<JoinView> joins,
|
||||||
|
List<FragmentView> fragments) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防御性复制分片列表。
|
||||||
|
*/
|
||||||
|
public DataspaceExplainResult {
|
||||||
|
joins = List.copyOf(joins == null ? List.of() : joins);
|
||||||
|
fragments = List.copyOf(fragments == null ? List.of() : fragments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个跨源 Join 的执行选择。
|
||||||
|
*
|
||||||
|
* @param stageIndex 执行阶段序号
|
||||||
|
* @param leftSources 左输入包含的数据源
|
||||||
|
* @param rightSources 右输入包含的数据源
|
||||||
|
* @param leftSource 左输入数据源
|
||||||
|
* @param rightSource 右输入数据源
|
||||||
|
* @param buildSource 哈希表构建侧数据源
|
||||||
|
* @param algorithm Join 算法
|
||||||
|
* @param reason 选择原因
|
||||||
|
* @param estimatedBuildBytes 预计构建侧字节数
|
||||||
|
*/
|
||||||
|
public record JoinView(
|
||||||
|
int stageIndex,
|
||||||
|
List<String> leftSources,
|
||||||
|
List<String> rightSources,
|
||||||
|
String leftSource,
|
||||||
|
String rightSource,
|
||||||
|
String buildSource,
|
||||||
|
String algorithm,
|
||||||
|
String reason,
|
||||||
|
double estimatedBuildBytes) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防御性复制阶段输入集合。
|
||||||
|
*/
|
||||||
|
public JoinView {
|
||||||
|
leftSources = List.copyOf(leftSources == null ? List.of() : leftSources);
|
||||||
|
rightSources = List.copyOf(rightSources == null ? List.of() : rightSources);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个物理数据库分片的 Explain 信息。
|
||||||
|
*
|
||||||
|
* @param sourceAlias 数据源别名
|
||||||
|
* @param executableSql 目标方言 SQL
|
||||||
|
* @param scanType 扫描方式
|
||||||
|
* @param candidateIndexes 候选索引
|
||||||
|
* @param chosenIndex 选中索引
|
||||||
|
* @param estimatedRows 估算行数
|
||||||
|
* @param estimateAvailable 分片成本数值是否为有效估算
|
||||||
|
* @param estimatedOutputRows 分片预计返回行数
|
||||||
|
* @param estimatedRowWidthBytes 预计平均行宽
|
||||||
|
* @param estimatedTransferBytes 预计搬运字节数
|
||||||
|
* @param statisticsStatus 统计状态
|
||||||
|
* @param statisticsSource 统计来源
|
||||||
|
* @param statisticsCollectedAt 统计采集时间
|
||||||
|
* @param pushedDownOperators 已下推算子
|
||||||
|
* @param nativePlan 数据库原生计划
|
||||||
|
* @param diagnostic 诊断信息
|
||||||
|
*/
|
||||||
|
public record FragmentView(
|
||||||
|
String sourceAlias,
|
||||||
|
String executableSql,
|
||||||
|
String scanType,
|
||||||
|
List<String> candidateIndexes,
|
||||||
|
String chosenIndex,
|
||||||
|
Long estimatedRows,
|
||||||
|
boolean estimateAvailable,
|
||||||
|
double estimatedOutputRows,
|
||||||
|
long estimatedRowWidthBytes,
|
||||||
|
double estimatedTransferBytes,
|
||||||
|
String statisticsStatus,
|
||||||
|
String statisticsSource,
|
||||||
|
String statisticsCollectedAt,
|
||||||
|
List<String> pushedDownOperators,
|
||||||
|
String nativePlan,
|
||||||
|
String diagnostic) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防御性复制候选索引。
|
||||||
|
*/
|
||||||
|
public FragmentView {
|
||||||
|
candidateIndexes = List.copyOf(candidateIndexes == null ? List.of() : candidateIndexes);
|
||||||
|
pushedDownOperators = List.copyOf(
|
||||||
|
pushedDownOperators == null ? List.of() : pushedDownOperators);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package tech.easyflow.dataspace.model;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间只读 SQL 请求。
|
||||||
|
*
|
||||||
|
* @param dataspaceId 数据空间 ID
|
||||||
|
* @param queryId 调用方预生成的查询 ID,可为空
|
||||||
|
* @param sql Calcite SQL
|
||||||
|
* @param maxRows 最大返回行数
|
||||||
|
* @param timeoutSeconds 超时秒数
|
||||||
|
*/
|
||||||
|
public record DataspaceQueryRequest(
|
||||||
|
BigInteger dataspaceId,
|
||||||
|
String queryId,
|
||||||
|
String sql,
|
||||||
|
Integer maxRows,
|
||||||
|
Integer timeoutSeconds) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package tech.easyflow.dataspace.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间 SQL 查询结果。
|
||||||
|
*
|
||||||
|
* @param queryId 查询 ID
|
||||||
|
* @param columns 结果列
|
||||||
|
* @param rows 结果行
|
||||||
|
* @param metrics 查询指标
|
||||||
|
*/
|
||||||
|
public record DataspaceQueryResult(
|
||||||
|
String queryId,
|
||||||
|
List<ColumnView> columns,
|
||||||
|
List<List<Object>> rows,
|
||||||
|
QueryMetricsView metrics) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防御性复制查询结果。
|
||||||
|
*/
|
||||||
|
public DataspaceQueryResult {
|
||||||
|
columns = List.copyOf(columns == null ? List.of() : columns);
|
||||||
|
rows = List.copyOf(rows == null ? List.of() : rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询结果列。
|
||||||
|
*
|
||||||
|
* @param name 列名
|
||||||
|
* @param jdbcType JDBC 类型
|
||||||
|
* @param typeName 类型名
|
||||||
|
* @param nullable 是否可空
|
||||||
|
*/
|
||||||
|
public record ColumnView(String name, int jdbcType, String typeName, boolean nullable) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 常用查询消耗指标。
|
||||||
|
*
|
||||||
|
* @param queryMode 单源或联邦模式
|
||||||
|
* @param planCacheHit 是否命中计划缓存
|
||||||
|
* @param planningMillis 编译耗时
|
||||||
|
* @param databaseMillis 数据库执行累计耗时
|
||||||
|
* @param localMillis 本地算子耗时
|
||||||
|
* @param totalMillis 总耗时
|
||||||
|
* @param firstRowMillis 首行耗时
|
||||||
|
* @param returnedRows 返回行数
|
||||||
|
* @param intermediateRows 中间结果行数
|
||||||
|
* @param truncated 是否截断
|
||||||
|
*/
|
||||||
|
public record QueryMetricsView(
|
||||||
|
String queryMode,
|
||||||
|
boolean planCacheHit,
|
||||||
|
long planningMillis,
|
||||||
|
long databaseMillis,
|
||||||
|
long localMillis,
|
||||||
|
long totalMillis,
|
||||||
|
long firstRowMillis,
|
||||||
|
long returnedRows,
|
||||||
|
long intermediateRows,
|
||||||
|
boolean truncated) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package tech.easyflow.dataspace.model;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间 SQL 补全请求。
|
||||||
|
*
|
||||||
|
* @param dataspaceId 数据空间 ID
|
||||||
|
* @param sql 允许不完整的 SQL 文本
|
||||||
|
* @param cursorOffset 光标 UTF-16 字符偏移
|
||||||
|
*/
|
||||||
|
public record DataspaceSqlCompletionRequest(
|
||||||
|
BigInteger dataspaceId,
|
||||||
|
String sql,
|
||||||
|
Integer cursorOffset) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package tech.easyflow.dataspace.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间 SQL 补全结果。
|
||||||
|
*
|
||||||
|
* @param replaceStart 建议替换区间起点,使用 UTF-16 字符偏移
|
||||||
|
* @param replaceEnd 建议替换区间终点,使用 UTF-16 字符偏移
|
||||||
|
* @param items 补全候选
|
||||||
|
*/
|
||||||
|
public record DataspaceSqlCompletionResult(
|
||||||
|
int replaceStart,
|
||||||
|
int replaceEnd,
|
||||||
|
List<ItemView> items) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防御性复制补全候选。
|
||||||
|
*/
|
||||||
|
public DataspaceSqlCompletionResult {
|
||||||
|
items = List.copyOf(items == null ? List.of() : items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个 SQL 补全候选。
|
||||||
|
*
|
||||||
|
* @param label 展示名称
|
||||||
|
* @param insertText 插入文本
|
||||||
|
* @param kind 候选类型
|
||||||
|
* @param qualifiedName 完整限定名称
|
||||||
|
*/
|
||||||
|
public record ItemView(
|
||||||
|
String label,
|
||||||
|
String insertText,
|
||||||
|
String kind,
|
||||||
|
List<String> qualifiedName) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防御性复制限定名称。
|
||||||
|
*/
|
||||||
|
public ItemView {
|
||||||
|
qualifiedName = List.copyOf(
|
||||||
|
qualifiedName == null ? List.of() : qualifiedName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package tech.easyflow.dataspace.model;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间列表摘要。
|
||||||
|
*
|
||||||
|
* @param id 数据空间 ID
|
||||||
|
* @param name 名称
|
||||||
|
* @param description 说明
|
||||||
|
* @param currentRevision 当前 revision
|
||||||
|
* @param status 状态
|
||||||
|
* @param tableCount 表数量
|
||||||
|
* @param sourceCount 物理源数量
|
||||||
|
* @param modified 最近修改时间
|
||||||
|
*/
|
||||||
|
public record DataspaceSummary(
|
||||||
|
BigInteger id,
|
||||||
|
String name,
|
||||||
|
String description,
|
||||||
|
long currentRevision,
|
||||||
|
String status,
|
||||||
|
long tableCount,
|
||||||
|
long sourceCount,
|
||||||
|
Date modified) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package tech.easyflow.dataspace.model;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间当前 revision 视图。
|
||||||
|
*
|
||||||
|
* @param id 数据空间 ID
|
||||||
|
* @param name 名称
|
||||||
|
* @param description 说明
|
||||||
|
* @param currentRevision 当前 revision
|
||||||
|
* @param status 状态
|
||||||
|
* @param tables 表绑定
|
||||||
|
* @param relations 表关系
|
||||||
|
*/
|
||||||
|
public record DataspaceView(
|
||||||
|
BigInteger id,
|
||||||
|
String name,
|
||||||
|
String description,
|
||||||
|
long currentRevision,
|
||||||
|
String status,
|
||||||
|
List<TableView> tables,
|
||||||
|
List<RelationView> relations) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防御性复制详情集合。
|
||||||
|
*/
|
||||||
|
public DataspaceView {
|
||||||
|
tables = List.copyOf(tables == null ? List.of() : tables);
|
||||||
|
relations = List.copyOf(relations == null ? List.of() : relations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间表绑定视图。
|
||||||
|
*
|
||||||
|
* @param id 绑定 ID
|
||||||
|
* @param connectionId 连接 ID
|
||||||
|
* @param connectionName 连接名称
|
||||||
|
* @param databaseType 数据库类型
|
||||||
|
* @param objectId 元数据对象 ID
|
||||||
|
* @param catalogName Catalog
|
||||||
|
* @param schemaName 物理 Schema
|
||||||
|
* @param objectName 物理表名
|
||||||
|
* @param sourceAlias SQL 数据源别名
|
||||||
|
* @param schemaAlias SQL Schema 别名
|
||||||
|
* @param tableAlias SQL 表名
|
||||||
|
* @param positionX 横坐标
|
||||||
|
* @param positionY 纵坐标
|
||||||
|
* @param columns 字段快照
|
||||||
|
* @param status ACTIVE、MISSING 或 CONNECTION_DISABLED
|
||||||
|
* @param issueCode 稳定问题码
|
||||||
|
* @param issueMessage 用户可读问题提示
|
||||||
|
*/
|
||||||
|
public record TableView(
|
||||||
|
BigInteger id,
|
||||||
|
BigInteger connectionId,
|
||||||
|
String connectionName,
|
||||||
|
String databaseType,
|
||||||
|
BigInteger objectId,
|
||||||
|
String catalogName,
|
||||||
|
String schemaName,
|
||||||
|
String objectName,
|
||||||
|
String sourceAlias,
|
||||||
|
String schemaAlias,
|
||||||
|
String tableAlias,
|
||||||
|
Integer positionX,
|
||||||
|
Integer positionY,
|
||||||
|
List<java.util.Map<String, Object>> columns,
|
||||||
|
String status,
|
||||||
|
Integer issueCode,
|
||||||
|
String issueMessage) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防御性复制字段快照。
|
||||||
|
*/
|
||||||
|
public TableView {
|
||||||
|
columns = List.copyOf(columns == null ? List.of() : columns);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间表关联视图。
|
||||||
|
*
|
||||||
|
* @param id 关系 ID
|
||||||
|
* @param leftBindingId 左表绑定 ID
|
||||||
|
* @param rightBindingId 右表绑定 ID
|
||||||
|
* @param joinType Join 类型
|
||||||
|
* @param leftColumn 左字段
|
||||||
|
* @param rightColumn 右字段
|
||||||
|
* @param status ACTIVE 或 INVALID
|
||||||
|
* @param issueCode 稳定问题码
|
||||||
|
* @param issueMessage 用户可读问题提示
|
||||||
|
*/
|
||||||
|
public record RelationView(
|
||||||
|
BigInteger id,
|
||||||
|
BigInteger leftBindingId,
|
||||||
|
BigInteger rightBindingId,
|
||||||
|
String joinType,
|
||||||
|
String leftColumn,
|
||||||
|
String rightColumn,
|
||||||
|
String status,
|
||||||
|
Integer issueCode,
|
||||||
|
String issueMessage) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package tech.easyflow.dataspace.model;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceObject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据库对象与字段视图。
|
||||||
|
*
|
||||||
|
* @param id 对象 ID
|
||||||
|
* @param connectionId 连接 ID
|
||||||
|
* @param catalogName Catalog
|
||||||
|
* @param schemaName Schema
|
||||||
|
* @param objectName 表或视图名
|
||||||
|
* @param objectType 类型
|
||||||
|
* @param remarks 说明
|
||||||
|
* @param columns 字段
|
||||||
|
* @param primaryKeys 主键字段
|
||||||
|
*/
|
||||||
|
public record ObjectView(
|
||||||
|
BigInteger id,
|
||||||
|
BigInteger connectionId,
|
||||||
|
String catalogName,
|
||||||
|
String schemaName,
|
||||||
|
String objectName,
|
||||||
|
String objectType,
|
||||||
|
String remarks,
|
||||||
|
List<Map<String, Object>> columns,
|
||||||
|
List<String> primaryKeys) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防御性复制对象视图。
|
||||||
|
*/
|
||||||
|
public ObjectView {
|
||||||
|
columns = List.copyOf(columns == null ? List.of() : columns);
|
||||||
|
primaryKeys = List.copyOf(primaryKeys == null ? List.of() : primaryKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从元数据记录创建视图。
|
||||||
|
*
|
||||||
|
* @param source 元数据记录
|
||||||
|
* @return 对象视图
|
||||||
|
*/
|
||||||
|
public static ObjectView from(DataspaceObject source) {
|
||||||
|
return new ObjectView(
|
||||||
|
source.getId(), source.getConnectionId(), source.getCatalogName(),
|
||||||
|
source.getSchemaName(), source.getObjectName(), source.getObjectType(),
|
||||||
|
source.getRemarks(), source.getColumnsJson(), source.getPrimaryKeysJson());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package tech.easyflow.dataspace.provider;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DatabaseMetaData;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于 JDBC DatabaseMetaData 的 Provider 公共实现。
|
||||||
|
*/
|
||||||
|
abstract class AbstractJdbcDatabaseProvider implements DataspaceDatabaseProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回需要扫描的物理 Schema。
|
||||||
|
*
|
||||||
|
* @param connection JDBC 连接
|
||||||
|
* @param config 连接配置
|
||||||
|
* @return Schema 名称;MySQL 使用单个空 Schema
|
||||||
|
* @throws SQLException 读取失败
|
||||||
|
*/
|
||||||
|
protected abstract List<String> schemas(Connection connection, DataspaceConnectionConfig config)
|
||||||
|
throws SQLException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回元数据查询使用的 Catalog。
|
||||||
|
*
|
||||||
|
* @param connection JDBC 连接
|
||||||
|
* @param config 连接配置
|
||||||
|
* @return Catalog
|
||||||
|
* @throws SQLException 读取失败
|
||||||
|
*/
|
||||||
|
protected String catalog(Connection connection, DataspaceConnectionConfig config) throws SQLException {
|
||||||
|
return connection.getCatalog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断 Schema 是否属于系统空间。
|
||||||
|
*
|
||||||
|
* @param schema Schema 名称
|
||||||
|
* @return 系统 Schema 时为 true
|
||||||
|
*/
|
||||||
|
protected boolean systemSchema(String schema) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取当前连接的表、视图与字段元数据。
|
||||||
|
*
|
||||||
|
* @param connection JDBC 连接
|
||||||
|
* @param config 连接配置
|
||||||
|
* @return 稳定排序的对象快照
|
||||||
|
* @throws SQLException 读取失败
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<DataspaceObjectMetadata> inspect(
|
||||||
|
Connection connection,
|
||||||
|
DataspaceConnectionConfig config) throws SQLException {
|
||||||
|
DatabaseMetaData metadata = connection.getMetaData();
|
||||||
|
String catalog = catalog(connection, config);
|
||||||
|
List<DataspaceObjectMetadata> objects = new ArrayList<>();
|
||||||
|
for (String schema : schemas(connection, config)) {
|
||||||
|
if (systemSchema(schema)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<TableDescriptor> descriptors = new ArrayList<>();
|
||||||
|
try (ResultSet tables = metadata.getTables(
|
||||||
|
catalog,
|
||||||
|
schema,
|
||||||
|
"%",
|
||||||
|
new String[]{"TABLE", "VIEW"})) {
|
||||||
|
while (tables.next()) {
|
||||||
|
String tableCatalog = tables.getString("TABLE_CAT");
|
||||||
|
String tableSchema = tables.getString("TABLE_SCHEM");
|
||||||
|
String tableName = tables.getString("TABLE_NAME");
|
||||||
|
descriptors.add(new TableDescriptor(
|
||||||
|
tableCatalog,
|
||||||
|
tableSchema,
|
||||||
|
tableName,
|
||||||
|
tables.getString("TABLE_TYPE"),
|
||||||
|
tables.getString("REMARKS")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (descriptors.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Map<String, Set<String>> primaryKeys = primaryKeys(
|
||||||
|
connection, catalog, schema,
|
||||||
|
descriptors.stream().map(TableDescriptor::name).toList());
|
||||||
|
Map<String, List<DataspaceColumnMetadata>> columns = columns(
|
||||||
|
metadata, catalog, schema, primaryKeys);
|
||||||
|
for (TableDescriptor descriptor : descriptors) {
|
||||||
|
objects.add(new DataspaceObjectMetadata(
|
||||||
|
descriptor.catalog(), descriptor.schema(), descriptor.name(), descriptor.type(),
|
||||||
|
descriptor.remarks(), columns.getOrDefault(tableKey(descriptor.name()), List.of())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
objects.sort(Comparator
|
||||||
|
.comparing((DataspaceObjectMetadata item) -> safe(item.schema()))
|
||||||
|
.thenComparing(DataspaceObjectMetadata::name, String.CASE_INSENSITIVE_ORDER));
|
||||||
|
return List.copyOf(objects);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量读取当前 Schema 的主键字段;未知数据库默认逐表调用 JDBC 元数据。
|
||||||
|
*
|
||||||
|
* @param connection JDBC 连接
|
||||||
|
* @param catalog Catalog
|
||||||
|
* @param schema Schema
|
||||||
|
* @param tables 表名列表
|
||||||
|
* @return 按规范化表名归组的大写主键字段
|
||||||
|
* @throws SQLException 读取失败时抛出
|
||||||
|
*/
|
||||||
|
protected Map<String, Set<String>> primaryKeys(
|
||||||
|
Connection connection,
|
||||||
|
String catalog,
|
||||||
|
String schema,
|
||||||
|
List<String> tables) throws SQLException {
|
||||||
|
Map<String, Set<String>> keysByTable = new HashMap<>();
|
||||||
|
DatabaseMetaData metadata = connection.getMetaData();
|
||||||
|
for (String table : tables) {
|
||||||
|
Set<String> keys = new HashSet<>();
|
||||||
|
try (ResultSet result = metadata.getPrimaryKeys(catalog, schema, table)) {
|
||||||
|
while (result.next()) {
|
||||||
|
keys.add(result.getString("COLUMN_NAME").toUpperCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keysByTable.put(tableKey(table), Set.copyOf(keys));
|
||||||
|
}
|
||||||
|
return Map.copyOf(keysByTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单次读取当前 Schema 的全部有序字段元数据。
|
||||||
|
*
|
||||||
|
* @param metadata JDBC 元数据
|
||||||
|
* @param catalog Catalog
|
||||||
|
* @param schema Schema
|
||||||
|
* @param primaryKeys 按表归组的大写主键字段
|
||||||
|
* @return 按规范化表名归组的字段元数据
|
||||||
|
* @throws SQLException 读取失败时抛出
|
||||||
|
*/
|
||||||
|
private Map<String, List<DataspaceColumnMetadata>> columns(
|
||||||
|
DatabaseMetaData metadata,
|
||||||
|
String catalog,
|
||||||
|
String schema,
|
||||||
|
Map<String, Set<String>> primaryKeys) throws SQLException {
|
||||||
|
Map<String, List<DataspaceColumnMetadata>> columnsByTable = new LinkedHashMap<>();
|
||||||
|
try (ResultSet result = metadata.getColumns(catalog, schema, "%", "%")) {
|
||||||
|
while (result.next()) {
|
||||||
|
String table = result.getString("TABLE_NAME");
|
||||||
|
String name = result.getString("COLUMN_NAME");
|
||||||
|
Set<String> tableKeys = primaryKeys.getOrDefault(tableKey(table), Set.of());
|
||||||
|
columnsByTable.computeIfAbsent(tableKey(table), ignored -> new ArrayList<>()).add(
|
||||||
|
new DataspaceColumnMetadata(
|
||||||
|
name,
|
||||||
|
result.getInt("DATA_TYPE"),
|
||||||
|
result.getString("TYPE_NAME"),
|
||||||
|
result.getInt("NULLABLE") != DatabaseMetaData.columnNoNulls,
|
||||||
|
result.getInt("ORDINAL_POSITION"),
|
||||||
|
tableKeys.contains(name.toUpperCase(Locale.ROOT)),
|
||||||
|
result.getString("REMARKS")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
columnsByTable.replaceAll((ignored, columns) -> columns.stream()
|
||||||
|
.sorted(Comparator.comparingInt(DataspaceColumnMetadata::ordinalPosition))
|
||||||
|
.toList());
|
||||||
|
return Map.copyOf(columnsByTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取数据库暴露的业务 Schema。
|
||||||
|
*
|
||||||
|
* @param connection JDBC 连接
|
||||||
|
* @return Schema 名称
|
||||||
|
* @throws SQLException 读取失败
|
||||||
|
*/
|
||||||
|
protected List<String> listSchemas(Connection connection) throws SQLException {
|
||||||
|
Set<String> schemas = new LinkedHashSet<>();
|
||||||
|
try (ResultSet result = connection.getMetaData().getSchemas()) {
|
||||||
|
while (result.next()) {
|
||||||
|
String schema = result.getString("TABLE_SCHEM");
|
||||||
|
if (schema != null && !schema.isBlank() && !systemSchema(schema)) {
|
||||||
|
schemas.add(schema);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.copyOf(schemas);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将可空字符串转换为空串,保证排序稳定。
|
||||||
|
*
|
||||||
|
* @param value 原始值
|
||||||
|
* @return 非空字符串
|
||||||
|
*/
|
||||||
|
private String safe(String value) {
|
||||||
|
return value == null ? "" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化单个 Schema 内的表名索引键。
|
||||||
|
*
|
||||||
|
* @param table 表名
|
||||||
|
* @return 大写表名键
|
||||||
|
*/
|
||||||
|
protected String tableKey(String table) {
|
||||||
|
return table.toUpperCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JDBC 表枚举阶段的轻量描述。
|
||||||
|
*
|
||||||
|
* @param catalog Catalog
|
||||||
|
* @param schema Schema
|
||||||
|
* @param name 表名
|
||||||
|
* @param type 对象类型
|
||||||
|
* @param remarks 备注
|
||||||
|
*/
|
||||||
|
private record TableDescriptor(
|
||||||
|
String catalog,
|
||||||
|
String schema,
|
||||||
|
String name,
|
||||||
|
String type,
|
||||||
|
String remarks) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package tech.easyflow.dataspace.provider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间字段元数据。
|
||||||
|
*
|
||||||
|
* @param name 字段名
|
||||||
|
* @param jdbcType JDBC 类型
|
||||||
|
* @param typeName 数据库类型名
|
||||||
|
* @param nullable 是否可空
|
||||||
|
* @param ordinalPosition 字段顺序
|
||||||
|
* @param primaryKey 是否主键
|
||||||
|
* @param remarks 字段说明
|
||||||
|
*/
|
||||||
|
public record DataspaceColumnMetadata(
|
||||||
|
String name,
|
||||||
|
int jdbcType,
|
||||||
|
String typeName,
|
||||||
|
boolean nullable,
|
||||||
|
int ordinalPosition,
|
||||||
|
boolean primaryKey,
|
||||||
|
String remarks) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package tech.easyflow.dataspace.provider;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据库 Provider 使用的只读连接配置。
|
||||||
|
*
|
||||||
|
* @param type 数据库类型
|
||||||
|
* @param host 主机
|
||||||
|
* @param port 端口
|
||||||
|
* @param database 数据库名
|
||||||
|
* @param username 用户名
|
||||||
|
* @param password 明文密码,仅在节点内短暂使用
|
||||||
|
* @param sslEnabled 是否启用 SSL
|
||||||
|
* @param options 扩展参数
|
||||||
|
*/
|
||||||
|
public record DataspaceConnectionConfig(
|
||||||
|
DataspaceDatabaseType type,
|
||||||
|
String host,
|
||||||
|
int port,
|
||||||
|
String database,
|
||||||
|
String username,
|
||||||
|
String password,
|
||||||
|
boolean sslEnabled,
|
||||||
|
Map<String, Object> options) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验并复制连接配置。
|
||||||
|
*/
|
||||||
|
public DataspaceConnectionConfig {
|
||||||
|
if (type == null || host == null || host.isBlank() || port <= 0 || port > 65535
|
||||||
|
|| database == null || database.isBlank() || username == null || username.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("数据库连接参数不完整");
|
||||||
|
}
|
||||||
|
options = Map.copyOf(options == null ? Map.of() : options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package tech.easyflow.dataspace.provider;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间数据库差异适配入口。
|
||||||
|
*/
|
||||||
|
public interface DataspaceDatabaseProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回数据库类型。
|
||||||
|
*
|
||||||
|
* @return 数据库类型
|
||||||
|
*/
|
||||||
|
DataspaceDatabaseType type();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造 JDBC URL。
|
||||||
|
*
|
||||||
|
* @param config 连接配置
|
||||||
|
* @return JDBC URL
|
||||||
|
*/
|
||||||
|
String jdbcUrl(DataspaceConnectionConfig config);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取当前连接中的业务表和视图。
|
||||||
|
*
|
||||||
|
* @param connection JDBC 连接
|
||||||
|
* @param config 连接配置
|
||||||
|
* @return 元数据快照
|
||||||
|
* @throws SQLException JDBC 元数据读取失败
|
||||||
|
*/
|
||||||
|
List<DataspaceObjectMetadata> inspect(Connection connection, DataspaceConnectionConfig config)
|
||||||
|
throws SQLException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回 Calcite Definition 中的默认逻辑 Schema。
|
||||||
|
*
|
||||||
|
* @param physicalSchema 物理 Schema
|
||||||
|
* @return SQL 使用的逻辑 Schema
|
||||||
|
*/
|
||||||
|
String logicalSchema(String physicalSchema);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package tech.easyflow.dataspace.provider;
|
||||||
|
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间数据库 Provider 注册表。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class DataspaceDatabaseProviderRegistry {
|
||||||
|
|
||||||
|
private final Map<DataspaceDatabaseType, DataspaceDatabaseProvider> providers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Provider 注册表。
|
||||||
|
*
|
||||||
|
* @param providers Spring 发现的 Provider
|
||||||
|
*/
|
||||||
|
public DataspaceDatabaseProviderRegistry(List<DataspaceDatabaseProvider> providers) {
|
||||||
|
EnumMap<DataspaceDatabaseType, DataspaceDatabaseProvider> indexed =
|
||||||
|
new EnumMap<>(DataspaceDatabaseType.class);
|
||||||
|
for (DataspaceDatabaseProvider provider : providers) {
|
||||||
|
if (indexed.put(provider.type(), provider) != null) {
|
||||||
|
throw new IllegalStateException("重复的数据空间数据库 Provider: " + provider.type());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.providers = Map.copyOf(indexed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定类型的 Provider。
|
||||||
|
*
|
||||||
|
* @param type 数据库类型
|
||||||
|
* @return Provider
|
||||||
|
* @throws IllegalArgumentException 未注册时抛出
|
||||||
|
*/
|
||||||
|
public DataspaceDatabaseProvider require(DataspaceDatabaseType type) {
|
||||||
|
DataspaceDatabaseProvider provider = providers.get(type);
|
||||||
|
if (provider == null) {
|
||||||
|
throw new IllegalArgumentException("数据库类型尚未接入: " + type);
|
||||||
|
}
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package tech.easyflow.dataspace.provider;
|
||||||
|
|
||||||
|
import com.easyagents.federation.sql.source.KnownJdbcDriver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前产品开放的数据空间数据库类型。
|
||||||
|
*/
|
||||||
|
public enum DataspaceDatabaseType {
|
||||||
|
|
||||||
|
/** MySQL 数据库。 */
|
||||||
|
MYSQL(3306, KnownJdbcDriver.MYSQL.driverClassName()),
|
||||||
|
/** PostgreSQL 数据库。 */
|
||||||
|
POSTGRESQL(5432, KnownJdbcDriver.POSTGRESQL.driverClassName());
|
||||||
|
|
||||||
|
private final int defaultPort;
|
||||||
|
private final String driverClassName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建数据库类型。
|
||||||
|
*
|
||||||
|
* @param defaultPort 默认端口
|
||||||
|
* @param driverClassName JDBC Driver 类名
|
||||||
|
*/
|
||||||
|
DataspaceDatabaseType(int defaultPort, String driverClassName) {
|
||||||
|
this.defaultPort = defaultPort;
|
||||||
|
this.driverClassName = driverClassName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回默认端口。
|
||||||
|
*
|
||||||
|
* @return 默认端口
|
||||||
|
*/
|
||||||
|
public int defaultPort() {
|
||||||
|
return defaultPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回 JDBC Driver 类名。
|
||||||
|
*
|
||||||
|
* @return Driver 类名
|
||||||
|
*/
|
||||||
|
public String driverClassName() {
|
||||||
|
return driverClassName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析数据库类型。
|
||||||
|
*
|
||||||
|
* @param value 类型文本
|
||||||
|
* @return 数据库类型
|
||||||
|
* @throws IllegalArgumentException 类型不受支持时抛出
|
||||||
|
*/
|
||||||
|
public static DataspaceDatabaseType parse(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("数据库类型不能为空");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return valueOf(value.trim().toUpperCase());
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
throw new IllegalArgumentException("暂不支持该数据库类型: " + value, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package tech.easyflow.dataspace.provider;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间表或视图元数据。
|
||||||
|
*
|
||||||
|
* @param catalog 物理 Catalog
|
||||||
|
* @param schema 物理 Schema
|
||||||
|
* @param name 对象名
|
||||||
|
* @param type 对象类型
|
||||||
|
* @param remarks 对象说明
|
||||||
|
* @param columns 字段列表
|
||||||
|
*/
|
||||||
|
public record DataspaceObjectMetadata(
|
||||||
|
String catalog,
|
||||||
|
String schema,
|
||||||
|
String name,
|
||||||
|
String type,
|
||||||
|
String remarks,
|
||||||
|
List<DataspaceColumnMetadata> columns) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防御性复制字段列表。
|
||||||
|
*/
|
||||||
|
public DataspaceObjectMetadata {
|
||||||
|
columns = List.copyOf(columns == null ? List.of() : columns);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package tech.easyflow.dataspace.provider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据库连接测试结果。
|
||||||
|
*
|
||||||
|
* @param success 是否成功
|
||||||
|
* @param latencyMillis 响应耗时
|
||||||
|
* @param databaseProduct 数据库产品
|
||||||
|
* @param databaseVersion 数据库版本
|
||||||
|
* @param driverName 驱动名称
|
||||||
|
* @param driverVersion 驱动版本
|
||||||
|
* @param errorCode 失败分类,成功时为空
|
||||||
|
* @param message 诊断信息
|
||||||
|
*/
|
||||||
|
public record DataspaceProbe(
|
||||||
|
boolean success,
|
||||||
|
long latencyMillis,
|
||||||
|
String databaseProduct,
|
||||||
|
String databaseVersion,
|
||||||
|
String driverName,
|
||||||
|
String driverVersion,
|
||||||
|
String errorCode,
|
||||||
|
String message) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package tech.easyflow.dataspace.provider;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MySQL 数据空间 Provider。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class MySqlProvider extends AbstractJdbcDatabaseProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回 MySQL 类型。
|
||||||
|
*
|
||||||
|
* @return MySQL
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public DataspaceDatabaseType type() {
|
||||||
|
return DataspaceDatabaseType.MYSQL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造 MySQL JDBC URL。
|
||||||
|
*
|
||||||
|
* @param config 连接配置
|
||||||
|
* @return JDBC URL
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String jdbcUrl(DataspaceConnectionConfig config) {
|
||||||
|
return "jdbc:mysql://" + config.host() + ":" + config.port() + "/" + config.database()
|
||||||
|
+ "?useUnicode=true&characterEncoding=utf8&useSSL=" + config.sslEnabled()
|
||||||
|
+ "&serverTimezone=UTC&allowMultiQueries=false";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MySQL 将数据库建模为 Catalog,Schema 参数为空。
|
||||||
|
*
|
||||||
|
* @param connection JDBC 连接
|
||||||
|
* @param config 连接配置
|
||||||
|
* @return 单个空 Schema
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected List<String> schemas(Connection connection, DataspaceConnectionConfig config) {
|
||||||
|
return java.util.Collections.singletonList(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次查询当前 Catalog 的全部主键,避免逐表元数据往返。
|
||||||
|
*
|
||||||
|
* @param connection JDBC 连接
|
||||||
|
* @param catalog Catalog
|
||||||
|
* @param schema Schema
|
||||||
|
* @param tables 表名列表
|
||||||
|
* @return 按表归组的主键字段
|
||||||
|
* @throws SQLException 查询失败
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected Map<String, Set<String>> primaryKeys(
|
||||||
|
Connection connection,
|
||||||
|
String catalog,
|
||||||
|
String schema,
|
||||||
|
List<String> tables) throws SQLException {
|
||||||
|
Map<String, Set<String>> keys = new HashMap<>();
|
||||||
|
String sql = "SELECT TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE "
|
||||||
|
+ "WHERE TABLE_SCHEMA = ? AND CONSTRAINT_NAME = 'PRIMARY'";
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
|
statement.setString(1, catalog);
|
||||||
|
try (ResultSet result = statement.executeQuery()) {
|
||||||
|
while (result.next()) {
|
||||||
|
keys.computeIfAbsent(tableKey(result.getString("TABLE_NAME")), ignored -> new HashSet<>())
|
||||||
|
.add(result.getString("COLUMN_NAME").toUpperCase(java.util.Locale.ROOT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys.replaceAll((ignored, value) -> Set.copyOf(value));
|
||||||
|
return Map.copyOf(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MySQL Definition 使用稳定的 MAIN 逻辑 Schema。
|
||||||
|
*
|
||||||
|
* @param physicalSchema 物理 Schema
|
||||||
|
* @return MAIN
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String logicalSchema(String physicalSchema) {
|
||||||
|
return "MAIN";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package tech.easyflow.dataspace.provider;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostgreSQL 数据空间 Provider。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class PostgresqlProvider extends AbstractJdbcDatabaseProvider {
|
||||||
|
|
||||||
|
private static final Set<String> SYSTEM_SCHEMAS = Set.of(
|
||||||
|
"information_schema", "pg_catalog", "pg_toast");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回 PostgreSQL 类型。
|
||||||
|
*
|
||||||
|
* @return PostgreSQL
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public DataspaceDatabaseType type() {
|
||||||
|
return DataspaceDatabaseType.POSTGRESQL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造 PostgreSQL JDBC URL。
|
||||||
|
*
|
||||||
|
* @param config 连接配置
|
||||||
|
* @return JDBC URL
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String jdbcUrl(DataspaceConnectionConfig config) {
|
||||||
|
return "jdbc:postgresql://" + config.host() + ":" + config.port() + "/" + config.database()
|
||||||
|
+ "?sslmode=" + (config.sslEnabled() ? "require" : "disable")
|
||||||
|
+ "&ApplicationName=easyflow-dataspace";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取 PostgreSQL 业务 Schema。
|
||||||
|
*
|
||||||
|
* @param connection JDBC 连接
|
||||||
|
* @param config 连接配置
|
||||||
|
* @return Schema 列表
|
||||||
|
* @throws SQLException 读取失败
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected List<String> schemas(Connection connection, DataspaceConnectionConfig config)
|
||||||
|
throws SQLException {
|
||||||
|
return listSchemas(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostgreSQL 元数据查询不限定 Catalog,避免驱动版本差异。
|
||||||
|
*
|
||||||
|
* @param connection JDBC 连接
|
||||||
|
* @param config 连接配置
|
||||||
|
* @return null
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected String catalog(Connection connection, DataspaceConnectionConfig config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次查询当前 Schema 的全部主键,避免逐表元数据往返。
|
||||||
|
*
|
||||||
|
* @param connection JDBC 连接
|
||||||
|
* @param catalog Catalog
|
||||||
|
* @param schema Schema
|
||||||
|
* @param tables 表名列表
|
||||||
|
* @return 按表归组的主键字段
|
||||||
|
* @throws SQLException 查询失败
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected Map<String, Set<String>> primaryKeys(
|
||||||
|
Connection connection,
|
||||||
|
String catalog,
|
||||||
|
String schema,
|
||||||
|
List<String> tables) throws SQLException {
|
||||||
|
Map<String, Set<String>> keys = new HashMap<>();
|
||||||
|
String sql = "SELECT c.relname AS table_name, a.attname AS column_name "
|
||||||
|
+ "FROM pg_catalog.pg_index i "
|
||||||
|
+ "JOIN pg_catalog.pg_class c ON c.oid = i.indrelid "
|
||||||
|
+ "JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace "
|
||||||
|
+ "JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(i.indkey) "
|
||||||
|
+ "WHERE i.indisprimary AND n.nspname = ?";
|
||||||
|
try (PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||||
|
statement.setString(1, schema);
|
||||||
|
try (ResultSet result = statement.executeQuery()) {
|
||||||
|
while (result.next()) {
|
||||||
|
keys.computeIfAbsent(tableKey(result.getString("table_name")), ignored -> new HashSet<>())
|
||||||
|
.add(result.getString("column_name").toUpperCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys.replaceAll((ignored, value) -> Set.copyOf(value));
|
||||||
|
return Map.copyOf(keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过滤 PostgreSQL 系统 Schema。
|
||||||
|
*
|
||||||
|
* @param schema Schema 名称
|
||||||
|
* @return 系统 Schema 时为 true
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected boolean systemSchema(String schema) {
|
||||||
|
if (schema == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String normalized = schema.toLowerCase(Locale.ROOT);
|
||||||
|
return SYSTEM_SCHEMAS.contains(normalized) || normalized.startsWith("pg_temp_");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostgreSQL 直接使用大写物理 Schema 作为 Calcite 逻辑 Schema。
|
||||||
|
*
|
||||||
|
* @param physicalSchema 物理 Schema
|
||||||
|
* @return 逻辑 Schema
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String logicalSchema(String physicalSchema) {
|
||||||
|
return physicalSchema == null || physicalSchema.isBlank()
|
||||||
|
? "PUBLIC" : physicalSchema.toUpperCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package tech.easyflow.dataspace.security;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceErrorCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间当前登录主体解析工具。
|
||||||
|
*/
|
||||||
|
public final class DataspaceActor {
|
||||||
|
|
||||||
|
private DataspaceActor() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回当前登录账号。
|
||||||
|
*
|
||||||
|
* @return 登录账号
|
||||||
|
* @throws BusinessException 登录上下文缺失时抛出
|
||||||
|
*/
|
||||||
|
public static LoginAccount current() {
|
||||||
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
|
if (account == null || account.getId() == null || account.getTenantId() == null) {
|
||||||
|
throw DataspaceErrorCode.ACCESS_DENIED.exception("当前登录信息无效");
|
||||||
|
}
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回当前租户 ID。
|
||||||
|
*
|
||||||
|
* @return 租户 ID
|
||||||
|
*/
|
||||||
|
public static BigInteger tenantId() {
|
||||||
|
return current().getTenantId();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package tech.easyflow.dataspace.security;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.GeneralSecurityException;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Base64;
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.spec.GCMParameterSpec;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用部署主密钥认证加密数据空间连接密码。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class DataspaceCredentialCipher {
|
||||||
|
|
||||||
|
private static final String VERSION = "v1:";
|
||||||
|
private static final String KEY_DOMAIN = "easyflow-dataspace-credential-v1\u0000";
|
||||||
|
private static final int IV_BYTES = 12;
|
||||||
|
private static final int TAG_BITS = 128;
|
||||||
|
|
||||||
|
private final SecretKeySpec key;
|
||||||
|
private final SecureRandom secureRandom = new SecureRandom();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建凭据加密器。
|
||||||
|
*
|
||||||
|
* @param masterKey 部署主密钥;兼容复用现有数据中心独立密钥
|
||||||
|
* @throws IllegalStateException 密钥长度不足时抛出
|
||||||
|
*/
|
||||||
|
public DataspaceCredentialCipher(
|
||||||
|
@Value("${easyflow.dataspace.credential-key:${easyflow.datacenter.credential-key:}}")
|
||||||
|
String masterKey) {
|
||||||
|
if (masterKey == null || masterKey.length() < 32) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"easyflow.dataspace.credential-key must contain at least 32 characters");
|
||||||
|
}
|
||||||
|
this.key = new SecretKeySpec(sha256(KEY_DOMAIN + masterKey), "AES");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加密密码。
|
||||||
|
*
|
||||||
|
* @param plainText 明文
|
||||||
|
* @return 版本化密文;空值返回 null
|
||||||
|
*/
|
||||||
|
public String encrypt(String plainText) {
|
||||||
|
if (plainText == null || plainText.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
byte[] iv = new byte[IV_BYTES];
|
||||||
|
secureRandom.nextBytes(iv);
|
||||||
|
try {
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
|
||||||
|
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return VERSION + Base64.getEncoder().encodeToString(
|
||||||
|
ByteBuffer.allocate(iv.length + encrypted.length)
|
||||||
|
.put(iv).put(encrypted).array());
|
||||||
|
} catch (GeneralSecurityException exception) {
|
||||||
|
throw new IllegalStateException("数据空间凭据加密失败", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密密码。
|
||||||
|
*
|
||||||
|
* @param cipherText 版本化密文
|
||||||
|
* @return 明文;空值返回 null
|
||||||
|
* @throws DataspaceCredentialUnavailableException 密钥不匹配、密文损坏或版本不兼容时抛出
|
||||||
|
*/
|
||||||
|
public String decrypt(String cipherText) {
|
||||||
|
if (cipherText == null || cipherText.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!cipherText.startsWith(VERSION)) {
|
||||||
|
throw new DataspaceCredentialUnavailableException(
|
||||||
|
new IllegalArgumentException("不支持的数据空间凭据格式"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
byte[] payload = Base64.getDecoder().decode(cipherText.substring(VERSION.length()));
|
||||||
|
if (payload.length <= IV_BYTES) {
|
||||||
|
throw new DataspaceCredentialUnavailableException(
|
||||||
|
new IllegalArgumentException("数据空间凭据内容无效"));
|
||||||
|
}
|
||||||
|
byte[] iv = java.util.Arrays.copyOfRange(payload, 0, IV_BYTES);
|
||||||
|
byte[] encrypted = java.util.Arrays.copyOfRange(payload, IV_BYTES, payload.length);
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
|
||||||
|
return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8);
|
||||||
|
} catch (GeneralSecurityException | IllegalArgumentException exception) {
|
||||||
|
throw new DataspaceCredentialUnavailableException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将任意长度部署密钥派生为固定长度 AES 密钥材料。
|
||||||
|
*
|
||||||
|
* @param value 部署密钥
|
||||||
|
* @return SHA-256 摘要
|
||||||
|
*/
|
||||||
|
private byte[] sha256(String value) {
|
||||||
|
try {
|
||||||
|
return MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||||
|
} catch (NoSuchAlgorithmException exception) {
|
||||||
|
throw new IllegalStateException("SHA-256 不可用", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package tech.easyflow.dataspace.security;
|
||||||
|
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表示已保存的数据空间连接凭据无法使用。
|
||||||
|
*
|
||||||
|
* <p>部署密钥变更、密文损坏或密文版本不兼容时,用户需要重新输入数据库密码。
|
||||||
|
* 该异常使用冲突状态表达当前连接定义与部署密钥不匹配。</p>
|
||||||
|
*/
|
||||||
|
public final class DataspaceCredentialUnavailableException extends BusinessException {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 凭据失效的稳定业务错误码。 */
|
||||||
|
public static final int ERROR_CODE = 40905;
|
||||||
|
|
||||||
|
/** 可安全返回给用户的恢复提示。 */
|
||||||
|
public static final String USER_MESSAGE =
|
||||||
|
"连接凭据已失效,请编辑连接并重新输入数据库密码";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建凭据不可用异常。
|
||||||
|
*
|
||||||
|
* @param cause 原始解密失败原因
|
||||||
|
*/
|
||||||
|
public DataspaceCredentialUnavailableException(Throwable cause) {
|
||||||
|
super(409, ERROR_CODE, USER_MESSAGE, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,694 @@
|
|||||||
|
package tech.easyflow.dataspace.service;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.net.ConnectException;
|
||||||
|
import java.net.NoRouteToHostException;
|
||||||
|
import java.net.SocketTimeoutException;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DatabaseMetaData;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.util.SearchKeywordUtil;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceConnection;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceObject;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceTableBinding;
|
||||||
|
import tech.easyflow.dataspace.federation.DataspaceDataSourceFactory;
|
||||||
|
import tech.easyflow.dataspace.federation.DataspaceDefinitionFactory;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceConnectionMapper;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceObjectMapper;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceTableBindingMapper;
|
||||||
|
import tech.easyflow.dataspace.model.ConnectionDefinition;
|
||||||
|
import tech.easyflow.dataspace.model.ConnectionView;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceErrorCode;
|
||||||
|
import tech.easyflow.dataspace.model.ObjectView;
|
||||||
|
import tech.easyflow.dataspace.provider.DataspaceColumnMetadata;
|
||||||
|
import tech.easyflow.dataspace.provider.DataspaceConnectionConfig;
|
||||||
|
import tech.easyflow.dataspace.provider.DataspaceDatabaseProvider;
|
||||||
|
import tech.easyflow.dataspace.provider.DataspaceDatabaseProviderRegistry;
|
||||||
|
import tech.easyflow.dataspace.provider.DataspaceDatabaseType;
|
||||||
|
import tech.easyflow.dataspace.provider.DataspaceObjectMetadata;
|
||||||
|
import tech.easyflow.dataspace.provider.DataspaceProbe;
|
||||||
|
import tech.easyflow.dataspace.security.DataspaceActor;
|
||||||
|
import tech.easyflow.dataspace.security.DataspaceCredentialCipher;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 物理数据库连接、测试和版本化元数据快照服务。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class DataspaceConnectionService {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(DataspaceConnectionService.class);
|
||||||
|
private static final Duration LOCK_WAIT = Duration.ofSeconds(3);
|
||||||
|
private static final Duration LOCK_LEASE = Duration.ofSeconds(30);
|
||||||
|
private final DataspaceConnectionMapper connectionMapper;
|
||||||
|
private final DataspaceObjectMapper objectMapper;
|
||||||
|
private final DataspaceTableBindingMapper bindingMapper;
|
||||||
|
private final DataspaceDatabaseProviderRegistry providerRegistry;
|
||||||
|
private final DataspaceDataSourceFactory dataSourceFactory;
|
||||||
|
private final DataspaceDefinitionFactory definitionFactory;
|
||||||
|
private final DataspaceCredentialCipher credentialCipher;
|
||||||
|
private final RedisLockExecutor lockExecutor;
|
||||||
|
private final TransactionTemplate transactionTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建连接服务。
|
||||||
|
*
|
||||||
|
* @param connectionMapper 连接 Mapper
|
||||||
|
* @param objectMapper 元数据对象 Mapper
|
||||||
|
* @param bindingMapper 表绑定 Mapper
|
||||||
|
* @param providerRegistry Provider 注册表
|
||||||
|
* @param dataSourceFactory 连接池工厂
|
||||||
|
* @param definitionFactory Federation Definition 工厂
|
||||||
|
* @param credentialCipher 凭据加密器
|
||||||
|
* @param lockExecutor 分布式锁执行器
|
||||||
|
* @param transactionTemplate 事务模板
|
||||||
|
*/
|
||||||
|
public DataspaceConnectionService(
|
||||||
|
DataspaceConnectionMapper connectionMapper,
|
||||||
|
DataspaceObjectMapper objectMapper,
|
||||||
|
DataspaceTableBindingMapper bindingMapper,
|
||||||
|
DataspaceDatabaseProviderRegistry providerRegistry,
|
||||||
|
DataspaceDataSourceFactory dataSourceFactory,
|
||||||
|
DataspaceDefinitionFactory definitionFactory,
|
||||||
|
DataspaceCredentialCipher credentialCipher,
|
||||||
|
RedisLockExecutor lockExecutor,
|
||||||
|
TransactionTemplate transactionTemplate) {
|
||||||
|
this.connectionMapper = connectionMapper;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.bindingMapper = bindingMapper;
|
||||||
|
this.providerRegistry = providerRegistry;
|
||||||
|
this.dataSourceFactory = dataSourceFactory;
|
||||||
|
this.definitionFactory = definitionFactory;
|
||||||
|
this.credentialCipher = credentialCipher;
|
||||||
|
this.lockExecutor = lockExecutor;
|
||||||
|
this.transactionTemplate = transactionTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前租户连接。
|
||||||
|
*
|
||||||
|
* @param keyword 名称、主机或数据库关键词
|
||||||
|
* @return 安全连接视图
|
||||||
|
*/
|
||||||
|
public List<ConnectionView> list(String keyword) {
|
||||||
|
QueryWrapper query = QueryWrapper.create().orderBy("modified desc, id desc");
|
||||||
|
if (keyword != null && !keyword.isBlank()) {
|
||||||
|
String pattern = SearchKeywordUtil.literalContainsPattern(keyword.trim());
|
||||||
|
query.and("(name LIKE ? ESCAPE '\\\\' OR host LIKE ? ESCAPE '\\\\' "
|
||||||
|
+ "OR database_name LIKE ? ESCAPE '\\\\')", pattern, pattern, pattern);
|
||||||
|
}
|
||||||
|
return connectionMapper.selectListByQuery(query).stream().map(ConnectionView::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取连接详情。
|
||||||
|
*
|
||||||
|
* @param id 连接 ID
|
||||||
|
* @return 安全连接视图
|
||||||
|
*/
|
||||||
|
public ConnectionView detail(BigInteger id) {
|
||||||
|
return ConnectionView.from(requireConnection(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试候选连接;已保存连接会记录最近测试结果。
|
||||||
|
*
|
||||||
|
* @param definition 候选定义
|
||||||
|
* @return 测试结果
|
||||||
|
*/
|
||||||
|
public DataspaceProbe test(ConnectionDefinition definition) {
|
||||||
|
CandidateInspection inspection = inspect(definition, false);
|
||||||
|
if (definition != null && definition.id() != null) {
|
||||||
|
LoginAccount account = DataspaceActor.current();
|
||||||
|
String lockKey = connectionLockKey(account, definition.id().toString());
|
||||||
|
lockExecutor.executeWithLock(lockKey, LOCK_WAIT, LOCK_LEASE, () ->
|
||||||
|
transactionTemplate.executeWithoutResult(status -> {
|
||||||
|
DataspaceConnection current = requireConnection(definition.id());
|
||||||
|
applyProbe(current, inspection.probe(), account);
|
||||||
|
connectionMapper.update(current);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return inspection.probe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建或更新连接,并在同一业务操作中保存元数据快照。
|
||||||
|
*
|
||||||
|
* @param definition 连接定义
|
||||||
|
* @return 保存后的安全视图
|
||||||
|
*/
|
||||||
|
public ConnectionView save(ConnectionDefinition definition) {
|
||||||
|
if (definition == null) {
|
||||||
|
throw DataspaceErrorCode.CONNECTION_DEFINITION_INVALID.exception("连接参数不能为空");
|
||||||
|
}
|
||||||
|
String connectionName = requiredText(definition.name(), "连接名称");
|
||||||
|
CandidateInspection inspection = inspect(definition, true);
|
||||||
|
if (!inspection.probe().success()) {
|
||||||
|
throw DataspaceErrorCode.CONNECTION_TEST_FAILED.exception(
|
||||||
|
inspection.probe().message());
|
||||||
|
}
|
||||||
|
LoginAccount account = DataspaceActor.current();
|
||||||
|
String identity = definition.id() == null
|
||||||
|
? "name:" + connectionName.toLowerCase(Locale.ROOT)
|
||||||
|
: definition.id().toString();
|
||||||
|
String lockKey = connectionLockKey(account, identity);
|
||||||
|
DataspaceConnection saved = lockExecutor.executeWithLock(lockKey, LOCK_WAIT, LOCK_LEASE,
|
||||||
|
() -> transactionTemplate.execute(status -> definition.id() == null
|
||||||
|
? insert(definition, inspection, account)
|
||||||
|
: update(definition, inspection, account)));
|
||||||
|
return ConnectionView.from(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用或禁用连接。状态切换与连接定义更新共用同一把分布式锁,避免并发覆盖。
|
||||||
|
*
|
||||||
|
* @param id 连接 ID
|
||||||
|
* @param enabled 是否启用
|
||||||
|
* @return 变更后的安全连接视图
|
||||||
|
*/
|
||||||
|
public ConnectionView setEnabled(BigInteger id, boolean enabled) {
|
||||||
|
LoginAccount account = DataspaceActor.current();
|
||||||
|
String lockKey = connectionLockKey(account, id == null ? "missing" : id.toString());
|
||||||
|
DataspaceConnection saved = lockExecutor.executeWithLock(lockKey, LOCK_WAIT, LOCK_LEASE,
|
||||||
|
() -> transactionTemplate.execute(status -> {
|
||||||
|
DataspaceConnection current = requireConnection(id);
|
||||||
|
String nextStatus = enabled ? "ENABLED" : "DISABLED";
|
||||||
|
if (nextStatus.equals(current.getStatus())) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
current.setStatus(nextStatus);
|
||||||
|
current.setModified(new Date());
|
||||||
|
current.setModifiedBy(account.getId());
|
||||||
|
connectionMapper.update(current);
|
||||||
|
return current;
|
||||||
|
}));
|
||||||
|
return ConnectionView.from(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新已保存连接的元数据并推进 Definition revision。
|
||||||
|
*
|
||||||
|
* @param id 连接 ID
|
||||||
|
* @param expectedRevision 期望 revision
|
||||||
|
* @return 新 revision 的对象列表
|
||||||
|
*/
|
||||||
|
public List<ObjectView> refreshMetadata(BigInteger id, long expectedRevision) {
|
||||||
|
DataspaceConnection current = requireConnection(id);
|
||||||
|
ConnectionDefinition definition = new ConnectionDefinition(
|
||||||
|
id, expectedRevision, current.getName(), current.getDatabaseType(), current.getHost(),
|
||||||
|
current.getPort(), current.getDatabaseName(), current.getUsername(), null,
|
||||||
|
Integer.valueOf(1).equals(current.getSslEnabled()), current.getOptionsJson());
|
||||||
|
CandidateInspection inspection = inspect(definition, true);
|
||||||
|
if (!inspection.probe().success()) {
|
||||||
|
throw DataspaceErrorCode.CONNECTION_TEST_FAILED.exception(
|
||||||
|
inspection.probe().message());
|
||||||
|
}
|
||||||
|
LoginAccount account = DataspaceActor.current();
|
||||||
|
String lockKey = connectionLockKey(account, id.toString());
|
||||||
|
DataspaceConnection saved = lockExecutor.executeWithLock(lockKey, LOCK_WAIT, LOCK_LEASE,
|
||||||
|
() -> transactionTemplate.execute(status -> update(definition, inspection, account)));
|
||||||
|
return objects(id, saved.getDefinitionRevision(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询连接当前 revision 的数据库对象,关键词同时匹配 Schema 和表名。
|
||||||
|
*
|
||||||
|
* @param connectionId 连接 ID
|
||||||
|
* @param keyword 搜索关键词
|
||||||
|
* @return 对象列表
|
||||||
|
*/
|
||||||
|
public List<ObjectView> objects(BigInteger connectionId, String keyword) {
|
||||||
|
DataspaceConnection connection = requireConnection(connectionId);
|
||||||
|
return objects(connectionId, connection.getDefinitionRevision(), keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除未被任何数据空间 revision 引用的连接。
|
||||||
|
*
|
||||||
|
* @param id 连接 ID
|
||||||
|
*/
|
||||||
|
public void remove(BigInteger id) {
|
||||||
|
LoginAccount account = DataspaceActor.current();
|
||||||
|
String lockKey = connectionLockKey(account, id == null ? "missing" : id.toString());
|
||||||
|
lockExecutor.executeWithLock(
|
||||||
|
lockKey, LOCK_WAIT, LOCK_LEASE, () ->
|
||||||
|
transactionTemplate.execute(status -> {
|
||||||
|
DataspaceConnection connection = requireConnection(id);
|
||||||
|
long references = bindingMapper.selectCountByQuery(QueryWrapper.create()
|
||||||
|
.eq(DataspaceTableBinding::getConnectionId, id));
|
||||||
|
if (references > 0) {
|
||||||
|
throw DataspaceErrorCode.CONNECTION_IN_USE.exception();
|
||||||
|
}
|
||||||
|
connectionMapper.deleteById(connection.getId());
|
||||||
|
return connection;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增连接及其首个不可变元数据快照。
|
||||||
|
*
|
||||||
|
* @param definition 连接定义
|
||||||
|
* @param inspection 探测与元数据结果
|
||||||
|
* @param account 当前登录账号
|
||||||
|
* @return 新增连接
|
||||||
|
*/
|
||||||
|
private DataspaceConnection insert(
|
||||||
|
ConnectionDefinition definition,
|
||||||
|
CandidateInspection inspection,
|
||||||
|
LoginAccount account) {
|
||||||
|
ensureNameUnique(definition.name(), null);
|
||||||
|
DataspaceConnection source = toEntity(definition, null, account);
|
||||||
|
source.setDefinitionRevision(1L);
|
||||||
|
source.setStatus("ENABLED");
|
||||||
|
applyProbe(source, inspection.probe(), account);
|
||||||
|
source.setMetadataRefreshedAt(new Date());
|
||||||
|
connectionMapper.insert(source);
|
||||||
|
List<DataspaceObject> objects = toObjects(source, 1L, inspection.objects(), account);
|
||||||
|
if (!objects.isEmpty()) {
|
||||||
|
objectMapper.insertBatch(objects);
|
||||||
|
}
|
||||||
|
source.setDefinitionChecksum(definitionFactory.create(source, objects).checksum());
|
||||||
|
connectionMapper.update(source);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 revision CAS 更新连接并写入新的元数据快照。
|
||||||
|
*
|
||||||
|
* @param definition 连接定义
|
||||||
|
* @param inspection 探测与元数据结果
|
||||||
|
* @param account 当前登录账号
|
||||||
|
* @return 更新后的连接
|
||||||
|
* @throws BusinessException revision 冲突时抛出
|
||||||
|
*/
|
||||||
|
private DataspaceConnection update(
|
||||||
|
ConnectionDefinition definition,
|
||||||
|
CandidateInspection inspection,
|
||||||
|
LoginAccount account) {
|
||||||
|
DataspaceConnection current = requireConnection(definition.id());
|
||||||
|
long expected = definition.expectedRevision() == null
|
||||||
|
? -1L : definition.expectedRevision();
|
||||||
|
if (current.getDefinitionRevision() == null || current.getDefinitionRevision() != expected) {
|
||||||
|
throw DataspaceErrorCode.REVISION_CONFLICT.exception(
|
||||||
|
"连接已被其他节点更新,请刷新后重试");
|
||||||
|
}
|
||||||
|
ensureNameUnique(definition.name(), current.getId());
|
||||||
|
long nextRevision = expected + 1L;
|
||||||
|
DataspaceConnection candidate = toEntity(definition, current, account);
|
||||||
|
candidate.setDefinitionRevision(nextRevision);
|
||||||
|
candidate.setStatus(current.getStatus());
|
||||||
|
applyProbe(candidate, inspection.probe(), account);
|
||||||
|
candidate.setMetadataRefreshedAt(new Date());
|
||||||
|
List<DataspaceObject> objects = toObjects(candidate, nextRevision, inspection.objects(), account);
|
||||||
|
String checksum = definitionFactory.create(candidate, objects).checksum();
|
||||||
|
int changed = connectionMapper.compareAndSetRevision(
|
||||||
|
candidate.getId(), account.getTenantId(), expected, nextRevision, checksum);
|
||||||
|
if (changed != 1) {
|
||||||
|
throw DataspaceErrorCode.REVISION_CONFLICT.exception(
|
||||||
|
"连接已被其他节点更新,请刷新后重试");
|
||||||
|
}
|
||||||
|
candidate.setDefinitionChecksum(checksum);
|
||||||
|
connectionMapper.update(candidate);
|
||||||
|
if (!objects.isEmpty()) {
|
||||||
|
objectMapper.insertBatch(objects);
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用短生命周期连接池测试连接并按需读取元数据。
|
||||||
|
*
|
||||||
|
* @param definition 候选连接定义
|
||||||
|
* @param includeMetadata 是否读取对象元数据
|
||||||
|
* @return 探测结果
|
||||||
|
*/
|
||||||
|
private CandidateInspection inspect(ConnectionDefinition definition, boolean includeMetadata) {
|
||||||
|
if (definition == null) {
|
||||||
|
throw DataspaceErrorCode.CONNECTION_DEFINITION_INVALID.exception("连接参数不能为空");
|
||||||
|
}
|
||||||
|
DataspaceConnection existing = definition.id() == null ? null : requireConnection(definition.id());
|
||||||
|
DataspaceConnectionConfig config = connectionConfig(definition, existing);
|
||||||
|
DataspaceDatabaseProvider provider = providerRegistry.require(config.type());
|
||||||
|
long started = System.nanoTime();
|
||||||
|
try (HikariDataSource pool = dataSourceFactory.temporary(config);
|
||||||
|
Connection connection = pool.getConnection()) {
|
||||||
|
connection.setReadOnly(true);
|
||||||
|
DatabaseMetaData metadata = connection.getMetaData();
|
||||||
|
List<DataspaceObjectMetadata> objects = includeMetadata
|
||||||
|
? provider.inspect(connection, config) : List.of();
|
||||||
|
DataspaceProbe probe = new DataspaceProbe(
|
||||||
|
true, Duration.ofNanos(System.nanoTime() - started).toMillis(),
|
||||||
|
metadata.getDatabaseProductName(), metadata.getDatabaseProductVersion(),
|
||||||
|
metadata.getDriverName(), metadata.getDriverVersion(), null, "连接成功");
|
||||||
|
return new CandidateInspection(probe, objects);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
LOG.error("数据空间连接测试失败,type={}, host={}, database={}",
|
||||||
|
config.type(), config.host(), config.database(), exception);
|
||||||
|
ConnectionFailure failure = classifyFailure(exception);
|
||||||
|
return new CandidateInspection(new DataspaceProbe(
|
||||||
|
false, Duration.ofNanos(System.nanoTime() - started).toMillis(),
|
||||||
|
null, null, null, null, failure.code(), failure.message()),
|
||||||
|
List.of());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并候选定义和已有密文凭据,构造 Provider 连接参数。
|
||||||
|
*
|
||||||
|
* @param definition 候选连接定义
|
||||||
|
* @param existing 已有连接
|
||||||
|
* @return Provider 连接参数
|
||||||
|
* @throws BusinessException 必填参数或地址非法时抛出
|
||||||
|
*/
|
||||||
|
private DataspaceConnectionConfig connectionConfig(
|
||||||
|
ConnectionDefinition definition,
|
||||||
|
DataspaceConnection existing) {
|
||||||
|
DataspaceDatabaseType type = DataspaceDatabaseType.parse(definition.databaseType());
|
||||||
|
String password = definition.password();
|
||||||
|
if ((password == null || password.isBlank()) && existing != null) {
|
||||||
|
password = credentialCipher.decrypt(existing.getCredentialCipher());
|
||||||
|
}
|
||||||
|
if (password == null) {
|
||||||
|
throw DataspaceErrorCode.CONNECTION_DEFINITION_INVALID.exception(
|
||||||
|
"数据库密码不能为空");
|
||||||
|
}
|
||||||
|
String host = requiredText(definition.host(), "主机地址");
|
||||||
|
String database = requiredText(definition.databaseName(), "数据库");
|
||||||
|
if (host.matches(".*[/?#&;].*") || database.matches(".*[/?#&;].*")) {
|
||||||
|
throw DataspaceErrorCode.CONNECTION_DEFINITION_INVALID.exception(
|
||||||
|
"主机地址或数据库名称包含非法字符");
|
||||||
|
}
|
||||||
|
int port = definition.port() == null ? type.defaultPort() : definition.port();
|
||||||
|
return new DataspaceConnectionConfig(
|
||||||
|
type, host, port, database, requiredText(definition.username(), "用户名"), password,
|
||||||
|
Boolean.TRUE.equals(definition.sslEnabled()), definition.options());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 API 连接定义转换为持久化记录并加密密码。
|
||||||
|
*
|
||||||
|
* @param definition 连接定义
|
||||||
|
* @param existing 已有记录
|
||||||
|
* @param account 当前登录账号
|
||||||
|
* @return 待持久化记录
|
||||||
|
*/
|
||||||
|
private DataspaceConnection toEntity(
|
||||||
|
ConnectionDefinition definition,
|
||||||
|
DataspaceConnection existing,
|
||||||
|
LoginAccount account) {
|
||||||
|
DataspaceDatabaseType type = DataspaceDatabaseType.parse(definition.databaseType());
|
||||||
|
DataspaceConnection target = new DataspaceConnection();
|
||||||
|
Date now = new Date();
|
||||||
|
target.setId(existing == null ? null : existing.getId());
|
||||||
|
target.setTenantId(account.getTenantId());
|
||||||
|
target.setName(requiredText(definition.name(), "连接名称"));
|
||||||
|
target.setDatabaseType(type.name());
|
||||||
|
target.setHost(requiredText(definition.host(), "主机地址"));
|
||||||
|
target.setPort(definition.port() == null ? type.defaultPort() : definition.port());
|
||||||
|
target.setDatabaseName(requiredText(definition.databaseName(), "数据库"));
|
||||||
|
target.setUsername(requiredText(definition.username(), "用户名"));
|
||||||
|
target.setCredentialCipher(definition.password() == null || definition.password().isBlank()
|
||||||
|
? existing == null ? null : existing.getCredentialCipher()
|
||||||
|
: credentialCipher.encrypt(definition.password()));
|
||||||
|
target.setSslEnabled(Boolean.TRUE.equals(definition.sslEnabled()) ? 1 : 0);
|
||||||
|
target.setDriverClassName(type.driverClassName());
|
||||||
|
target.setOptionsJson(new LinkedHashMap<>(definition.options()));
|
||||||
|
target.setCreated(existing == null ? now : existing.getCreated());
|
||||||
|
target.setCreatedBy(existing == null ? account.getId() : existing.getCreatedBy());
|
||||||
|
target.setModified(now);
|
||||||
|
target.setModifiedBy(account.getId());
|
||||||
|
target.setDeleted(0);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Provider 元数据转换为指定 revision 的持久化对象。
|
||||||
|
*
|
||||||
|
* @param connection 物理连接
|
||||||
|
* @param metadataRevision 元数据 revision
|
||||||
|
* @param metadata Provider 元数据
|
||||||
|
* @param account 当前登录账号
|
||||||
|
* @return 元数据对象列表
|
||||||
|
*/
|
||||||
|
private List<DataspaceObject> toObjects(
|
||||||
|
DataspaceConnection connection,
|
||||||
|
long metadataRevision,
|
||||||
|
List<DataspaceObjectMetadata> metadata,
|
||||||
|
LoginAccount account) {
|
||||||
|
List<DataspaceObject> objects = new ArrayList<>(metadata.size());
|
||||||
|
Date now = new Date();
|
||||||
|
for (DataspaceObjectMetadata item : metadata) {
|
||||||
|
DataspaceObject object = new DataspaceObject();
|
||||||
|
object.setTenantId(account.getTenantId());
|
||||||
|
object.setConnectionId(connection.getId());
|
||||||
|
object.setCatalogName(item.catalog());
|
||||||
|
object.setSchemaName(item.schema());
|
||||||
|
object.setObjectName(item.name());
|
||||||
|
object.setObjectType(item.type());
|
||||||
|
object.setRemarks(item.remarks());
|
||||||
|
List<Map<String, Object>> columns = new ArrayList<>();
|
||||||
|
List<String> primaryKeys = new ArrayList<>();
|
||||||
|
for (DataspaceColumnMetadata column : item.columns()) {
|
||||||
|
Map<String, Object> value = new LinkedHashMap<>();
|
||||||
|
value.put("name", column.name());
|
||||||
|
value.put("jdbcType", column.jdbcType());
|
||||||
|
value.put("typeName", column.typeName());
|
||||||
|
value.put("nullable", column.nullable());
|
||||||
|
value.put("ordinalPosition", column.ordinalPosition());
|
||||||
|
value.put("primaryKey", column.primaryKey());
|
||||||
|
value.put("remarks", column.remarks());
|
||||||
|
columns.add(value);
|
||||||
|
if (column.primaryKey()) {
|
||||||
|
primaryKeys.add(column.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
object.setColumnsJson(columns);
|
||||||
|
object.setPrimaryKeysJson(primaryKeys);
|
||||||
|
object.setMetadataRevision(metadataRevision);
|
||||||
|
object.setCreated(now);
|
||||||
|
object.setCreatedBy(account.getId());
|
||||||
|
object.setModified(now);
|
||||||
|
object.setModifiedBy(account.getId());
|
||||||
|
object.setDeleted(0);
|
||||||
|
objects.add(object);
|
||||||
|
}
|
||||||
|
return objects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将连接探测信息写入连接记录。
|
||||||
|
*
|
||||||
|
* @param target 目标连接
|
||||||
|
* @param probe 探测结果
|
||||||
|
* @param account 当前登录账号
|
||||||
|
*/
|
||||||
|
private void applyProbe(
|
||||||
|
DataspaceConnection target,
|
||||||
|
DataspaceProbe probe,
|
||||||
|
LoginAccount account) {
|
||||||
|
target.setLastTestStatus(probe.success() ? "SUCCESS" : "FAILED");
|
||||||
|
target.setLastTestMessage(probe.message());
|
||||||
|
target.setDatabaseProduct(probe.databaseProduct());
|
||||||
|
target.setDatabaseVersion(probe.databaseVersion());
|
||||||
|
target.setDriverName(probe.driverName());
|
||||||
|
target.setDriverVersion(probe.driverVersion());
|
||||||
|
target.setLastTestedAt(new Date());
|
||||||
|
target.setModified(new Date());
|
||||||
|
target.setModifiedBy(account.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询指定元数据 revision 的对象,并同时支持 Schema 与表名模糊搜索。
|
||||||
|
*
|
||||||
|
* @param connectionId 连接 ID
|
||||||
|
* @param metadataRevision 元数据 revision
|
||||||
|
* @param keyword 搜索关键词
|
||||||
|
* @return 对象视图列表
|
||||||
|
*/
|
||||||
|
private List<ObjectView> objects(
|
||||||
|
BigInteger connectionId,
|
||||||
|
long metadataRevision,
|
||||||
|
String keyword) {
|
||||||
|
QueryWrapper query = QueryWrapper.create()
|
||||||
|
.eq(DataspaceObject::getConnectionId, connectionId)
|
||||||
|
.eq(DataspaceObject::getMetadataRevision, metadataRevision)
|
||||||
|
.orderBy("schema_name asc, object_name asc");
|
||||||
|
if (keyword != null && !keyword.isBlank()) {
|
||||||
|
String pattern = SearchKeywordUtil.literalContainsPattern(keyword.trim());
|
||||||
|
query.and("(schema_name LIKE ? ESCAPE '\\\\' OR object_name LIKE ? ESCAPE '\\\\')",
|
||||||
|
pattern, pattern);
|
||||||
|
}
|
||||||
|
return objectMapper.selectListByQuery(query).stream().map(ObjectView::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验当前租户内连接名称唯一性。
|
||||||
|
*
|
||||||
|
* @param name 连接名称
|
||||||
|
* @param excludedId 更新时排除的连接 ID
|
||||||
|
* @throws BusinessException 名称重复时抛出
|
||||||
|
*/
|
||||||
|
private void ensureNameUnique(String name, BigInteger excludedId) {
|
||||||
|
QueryWrapper query = QueryWrapper.create().eq(DataspaceConnection::getName, requiredText(name, "连接名称"));
|
||||||
|
if (excludedId != null) {
|
||||||
|
query.ne(DataspaceConnection::getId, excludedId);
|
||||||
|
}
|
||||||
|
if (connectionMapper.selectCountByQuery(query) > 0) {
|
||||||
|
throw DataspaceErrorCode.NAME_CONFLICT.exception("连接名称已存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前租户可见的连接。
|
||||||
|
*
|
||||||
|
* @param id 连接 ID
|
||||||
|
* @return 连接记录
|
||||||
|
* @throws BusinessException ID 为空或记录不存在时抛出
|
||||||
|
*/
|
||||||
|
private DataspaceConnection requireConnection(BigInteger id) {
|
||||||
|
if (id == null) {
|
||||||
|
throw DataspaceErrorCode.CONNECTION_DEFINITION_INVALID.exception("连接 ID 不能为空");
|
||||||
|
}
|
||||||
|
DataspaceConnection source = connectionMapper.selectOneById(id);
|
||||||
|
if (source == null) {
|
||||||
|
throw DataspaceErrorCode.CONNECTION_NOT_FOUND.exception();
|
||||||
|
}
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取必填文本并去除首尾空白。
|
||||||
|
*
|
||||||
|
* @param value 原始值
|
||||||
|
* @param label 字段标签
|
||||||
|
* @return 规范化文本
|
||||||
|
* @throws BusinessException 文本为空时抛出
|
||||||
|
*/
|
||||||
|
private String requiredText(String value, String label) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw DataspaceErrorCode.CONNECTION_DEFINITION_INVALID.exception(
|
||||||
|
label + "不能为空");
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造同一租户内统一的连接聚合锁键,确保测试、保存、刷新和删除互斥。
|
||||||
|
*
|
||||||
|
* @param account 当前登录账号
|
||||||
|
* @param identity 连接 ID 或创建时的规范化名称
|
||||||
|
* @return 分布式锁键
|
||||||
|
*/
|
||||||
|
private String connectionLockKey(LoginAccount account, String identity) {
|
||||||
|
return "easyflow:lock:dataspace:connection:"
|
||||||
|
+ account.getTenantId() + ":" + identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将底层连接异常转换为有长度上限的用户可读信息。
|
||||||
|
*
|
||||||
|
* @param exception 连接异常
|
||||||
|
* @return 安全错误信息
|
||||||
|
*/
|
||||||
|
private String safeMessage(Throwable exception) {
|
||||||
|
String message = exception.getMessage();
|
||||||
|
if (message == null || message.isBlank()) {
|
||||||
|
return "连接失败,请检查地址、账密和网络";
|
||||||
|
}
|
||||||
|
return message.length() > 300 ? message.substring(0, 300) : message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 JDBC、驱动与网络异常归一化为稳定的连接失败分类。
|
||||||
|
*
|
||||||
|
* @param exception 连接失败异常
|
||||||
|
* @return 安全失败分类
|
||||||
|
*/
|
||||||
|
private ConnectionFailure classifyFailure(Exception exception) {
|
||||||
|
Throwable cause = exception;
|
||||||
|
while (cause != null) {
|
||||||
|
if (cause instanceof SQLException sqlException) {
|
||||||
|
String state = sqlException.getSQLState();
|
||||||
|
if (state != null && state.startsWith("28")) {
|
||||||
|
return new ConnectionFailure("AUTH_FAILED", "数据库用户名或密码不正确");
|
||||||
|
}
|
||||||
|
if ("3D000".equals(state) || sqlException.getErrorCode() == 1049) {
|
||||||
|
return new ConnectionFailure("DATABASE_NOT_FOUND", "目标数据库不存在");
|
||||||
|
}
|
||||||
|
if ("42501".equals(state)) {
|
||||||
|
return new ConnectionFailure("PERMISSION_DENIED", "当前账号没有访问权限");
|
||||||
|
}
|
||||||
|
if (state != null && state.startsWith("08")) {
|
||||||
|
return new ConnectionFailure("NETWORK_UNREACHABLE", "无法连接数据库服务");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cause instanceof UnknownHostException
|
||||||
|
|| cause instanceof ConnectException
|
||||||
|
|| cause instanceof NoRouteToHostException
|
||||||
|
|| cause instanceof SocketTimeoutException) {
|
||||||
|
return new ConnectionFailure("NETWORK_UNREACHABLE", "无法连接数据库服务");
|
||||||
|
}
|
||||||
|
cause = cause.getCause();
|
||||||
|
}
|
||||||
|
String message = safeMessage(exception);
|
||||||
|
String normalized = message.toLowerCase(Locale.ROOT);
|
||||||
|
if (normalized.contains("driver") && normalized.contains("not found")) {
|
||||||
|
return new ConnectionFailure("DRIVER_NOT_FOUND", "数据库驱动未安装");
|
||||||
|
}
|
||||||
|
if (normalized.contains("access denied")
|
||||||
|
|| normalized.contains("password authentication failed")) {
|
||||||
|
return new ConnectionFailure("AUTH_FAILED", "数据库用户名或密码不正确");
|
||||||
|
}
|
||||||
|
if (normalized.contains("unknown database")
|
||||||
|
|| normalized.contains("database") && normalized.contains("does not exist")) {
|
||||||
|
return new ConnectionFailure("DATABASE_NOT_FOUND", "目标数据库不存在");
|
||||||
|
}
|
||||||
|
if (normalized.contains("permission denied") || normalized.contains("not authorized")) {
|
||||||
|
return new ConnectionFailure("PERMISSION_DENIED", "当前账号没有访问权限");
|
||||||
|
}
|
||||||
|
return new ConnectionFailure("CONNECTION_FAILED", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次候选连接探测及其可选元数据结果。
|
||||||
|
*
|
||||||
|
* @param probe 探测结果
|
||||||
|
* @param objects 元数据对象
|
||||||
|
*/
|
||||||
|
private record CandidateInspection(
|
||||||
|
DataspaceProbe probe,
|
||||||
|
List<DataspaceObjectMetadata> objects) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防御性复制元数据列表。
|
||||||
|
*/
|
||||||
|
private CandidateInspection {
|
||||||
|
objects = List.copyOf(objects == null ? List.of() : objects);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 稳定的连接失败分类。
|
||||||
|
*
|
||||||
|
* @param code 失败代码
|
||||||
|
* @param message 用户可读信息
|
||||||
|
*/
|
||||||
|
private record ConnectionFailure(String code, String message) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
package tech.easyflow.dataspace.service;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceConnection;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceObject;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceObjectMapper;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceErrorCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将历史表绑定按稳定物理身份解析到连接的最新元数据快照。
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class DataspaceMetadataResolver {
|
||||||
|
|
||||||
|
private final DataspaceObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建元数据解析器。
|
||||||
|
*
|
||||||
|
* @param objectMapper 元数据对象 Mapper
|
||||||
|
*/
|
||||||
|
public DataspaceMetadataResolver(DataspaceObjectMapper objectMapper) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量解析历史对象,避免按表逐条查询当前元数据。
|
||||||
|
*
|
||||||
|
* @param historicalObjects 绑定时的历史对象,键为历史对象 ID
|
||||||
|
* @param connections 当前物理连接
|
||||||
|
* @return 历史对象 ID 到解析结果的映射
|
||||||
|
*/
|
||||||
|
public Map<BigInteger, ObjectResolution> resolve(
|
||||||
|
Map<BigInteger, DataspaceObject> historicalObjects,
|
||||||
|
Map<BigInteger, DataspaceConnection> connections) {
|
||||||
|
Map<BigInteger, Map<PhysicalIdentity, DataspaceObject>> currentByConnection =
|
||||||
|
new LinkedHashMap<>();
|
||||||
|
for (DataspaceConnection connection : connections.values()) {
|
||||||
|
if (!"ENABLED".equals(connection.getStatus())) {
|
||||||
|
currentByConnection.put(connection.getId(), Map.of());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
long revision = currentMetadataRevision(connection);
|
||||||
|
Map<PhysicalIdentity, DataspaceObject> current = new LinkedHashMap<>();
|
||||||
|
if (revision > 0) {
|
||||||
|
List<DataspaceObject> objects = objectMapper.selectListByQuery(QueryWrapper.create()
|
||||||
|
.eq(DataspaceObject::getConnectionId, connection.getId())
|
||||||
|
.eq(DataspaceObject::getMetadataRevision, revision));
|
||||||
|
for (DataspaceObject object : objects) {
|
||||||
|
current.put(identity(object, connection), object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentByConnection.put(connection.getId(), current);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<BigInteger, ObjectResolution> result = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<BigInteger, DataspaceObject> entry : historicalObjects.entrySet()) {
|
||||||
|
DataspaceObject historical = entry.getValue();
|
||||||
|
DataspaceConnection connection = connections.get(historical.getConnectionId());
|
||||||
|
if (connection == null) {
|
||||||
|
result.put(entry.getKey(), new ObjectResolution(
|
||||||
|
historical, null, "MISSING",
|
||||||
|
DataspaceErrorCode.CONNECTION_NOT_FOUND.code(),
|
||||||
|
DataspaceErrorCode.CONNECTION_NOT_FOUND.defaultMessage()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!"ENABLED".equals(connection.getStatus())) {
|
||||||
|
result.put(entry.getKey(), new ObjectResolution(
|
||||||
|
historical, null, "CONNECTION_DISABLED",
|
||||||
|
DataspaceErrorCode.CONNECTION_DISABLED.code(),
|
||||||
|
DataspaceErrorCode.CONNECTION_DISABLED.defaultMessage()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
DataspaceObject current = currentByConnection
|
||||||
|
.getOrDefault(connection.getId(), Map.of())
|
||||||
|
.get(identity(historical, connection));
|
||||||
|
result.put(entry.getKey(), current == null
|
||||||
|
? new ObjectResolution(
|
||||||
|
historical, null, "MISSING",
|
||||||
|
DataspaceErrorCode.OBJECT_MISSING.code(),
|
||||||
|
"表或视图 “" + historical.getObjectName()
|
||||||
|
+ "” 已不在最新元数据中,请刷新或移除该表")
|
||||||
|
: new ObjectResolution(historical, current, "ACTIVE", null, null));
|
||||||
|
}
|
||||||
|
return Map.copyOf(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取连接当前对应的元数据快照 revision。
|
||||||
|
*
|
||||||
|
* <p>首期元数据快照随 Federation Definition 一同推进;解析逻辑集中在此处,
|
||||||
|
* 后续拆分独立 metadata revision 时无需修改调用方。</p>
|
||||||
|
*
|
||||||
|
* @param connection 物理连接
|
||||||
|
* @return 当前元数据 revision,缺失时返回 0
|
||||||
|
*/
|
||||||
|
public long currentMetadataRevision(DataspaceConnection connection) {
|
||||||
|
return connection == null || connection.getDefinitionRevision() == null
|
||||||
|
? 0L : connection.getDefinitionRevision();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断对象字段是否存在。
|
||||||
|
*
|
||||||
|
* @param object 元数据对象
|
||||||
|
* @param columnName 字段名
|
||||||
|
* @param databaseType 数据库类型
|
||||||
|
* @return 是否存在
|
||||||
|
*/
|
||||||
|
public boolean hasColumn(
|
||||||
|
DataspaceObject object,
|
||||||
|
String columnName,
|
||||||
|
String databaseType) {
|
||||||
|
if (object == null || columnName == null || columnName.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return columns(object).stream()
|
||||||
|
.map(column -> String.valueOf(column.get("name")))
|
||||||
|
.anyMatch(name -> sameIdentifier(name, columnName.trim(), databaseType));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回指定字段的元数据。
|
||||||
|
*
|
||||||
|
* @param object 元数据对象
|
||||||
|
* @param columnName 字段名
|
||||||
|
* @param databaseType 数据库类型
|
||||||
|
* @return 字段元数据,不存在时返回空映射
|
||||||
|
*/
|
||||||
|
public Map<String, Object> column(
|
||||||
|
DataspaceObject object,
|
||||||
|
String columnName,
|
||||||
|
String databaseType) {
|
||||||
|
if (object == null || columnName == null || columnName.isBlank()) {
|
||||||
|
return Map.of();
|
||||||
|
}
|
||||||
|
return columns(object).stream()
|
||||||
|
.filter(column -> sameIdentifier(
|
||||||
|
String.valueOf(column.get("name")),
|
||||||
|
columnName.trim(),
|
||||||
|
databaseType))
|
||||||
|
.findFirst()
|
||||||
|
.map(column -> java.util.Collections.unmodifiableMap(
|
||||||
|
new LinkedHashMap<>(column)))
|
||||||
|
.orElseGet(Map::of);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全读取对象字段,兼容历史异常数据中的空字段集合。
|
||||||
|
*
|
||||||
|
* @param object 元数据对象
|
||||||
|
* @return 非空字段集合
|
||||||
|
*/
|
||||||
|
private List<Map<String, Object>> columns(DataspaceObject object) {
|
||||||
|
return object.getColumnsJson() == null ? List.of() : object.getColumnsJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按数据库标识符规则比较字段名。
|
||||||
|
*
|
||||||
|
* @param left 元数据字段名
|
||||||
|
* @param right 保存的字段名
|
||||||
|
* @param databaseType 数据库类型
|
||||||
|
* @return 是否指向同一字段
|
||||||
|
*/
|
||||||
|
private boolean sameIdentifier(String left, String right, String databaseType) {
|
||||||
|
return "POSTGRESQL".equalsIgnoreCase(databaseType)
|
||||||
|
? left.equals(right)
|
||||||
|
: left.equalsIgnoreCase(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断两个字段的 JDBC 类型是否可用于等值关联。
|
||||||
|
*
|
||||||
|
* @param left 左字段元数据
|
||||||
|
* @param right 右字段元数据
|
||||||
|
* @return 是否可比较
|
||||||
|
*/
|
||||||
|
public boolean comparable(Map<String, Object> left, Map<String, Object> right) {
|
||||||
|
if (left.isEmpty() || right.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String leftFamily = typeFamily(left);
|
||||||
|
String rightFamily = typeFamily(right);
|
||||||
|
return leftFamily.equals(rightFamily);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造稳定物理身份。
|
||||||
|
*
|
||||||
|
* @param object 元数据对象
|
||||||
|
* @param connection 物理连接
|
||||||
|
* @return 物理身份
|
||||||
|
*/
|
||||||
|
private PhysicalIdentity identity(
|
||||||
|
DataspaceObject object,
|
||||||
|
DataspaceConnection connection) {
|
||||||
|
boolean caseSensitive = "POSTGRESQL".equalsIgnoreCase(connection.getDatabaseType());
|
||||||
|
return new PhysicalIdentity(
|
||||||
|
normalize(object.getCatalogName(), caseSensitive),
|
||||||
|
normalize(object.getSchemaName(), caseSensitive),
|
||||||
|
normalize(object.getObjectName(), caseSensitive),
|
||||||
|
normalize(object.getObjectType(), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化物理身份片段。
|
||||||
|
*
|
||||||
|
* @param value 原始值
|
||||||
|
* @param caseSensitive 是否保留大小写
|
||||||
|
* @return 大小写无关的稳定值
|
||||||
|
*/
|
||||||
|
private String normalize(String value, boolean caseSensitive) {
|
||||||
|
String normalized = value == null ? "" : value.trim();
|
||||||
|
return caseSensitive ? normalized : normalized.toUpperCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 JDBC 类型归一化为关联比较族。
|
||||||
|
*
|
||||||
|
* @param column 字段元数据
|
||||||
|
* @return 类型族
|
||||||
|
*/
|
||||||
|
private String typeFamily(Map<String, Object> column) {
|
||||||
|
int jdbcType;
|
||||||
|
try {
|
||||||
|
jdbcType = Integer.parseInt(String.valueOf(column.get("jdbcType")));
|
||||||
|
} catch (NumberFormatException exception) {
|
||||||
|
return "OTHER:" + String.valueOf(column.get("typeName")).toUpperCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
return switch (jdbcType) {
|
||||||
|
case java.sql.Types.TINYINT, java.sql.Types.SMALLINT, java.sql.Types.INTEGER,
|
||||||
|
java.sql.Types.BIGINT, java.sql.Types.FLOAT, java.sql.Types.REAL,
|
||||||
|
java.sql.Types.DOUBLE, java.sql.Types.NUMERIC, java.sql.Types.DECIMAL -> "NUMERIC";
|
||||||
|
case java.sql.Types.CHAR, java.sql.Types.VARCHAR, java.sql.Types.LONGVARCHAR,
|
||||||
|
java.sql.Types.NCHAR, java.sql.Types.NVARCHAR, java.sql.Types.LONGNVARCHAR -> "TEXT";
|
||||||
|
case java.sql.Types.DATE, java.sql.Types.TIME, java.sql.Types.TIME_WITH_TIMEZONE,
|
||||||
|
java.sql.Types.TIMESTAMP, java.sql.Types.TIMESTAMP_WITH_TIMEZONE -> "TEMPORAL";
|
||||||
|
case java.sql.Types.BOOLEAN, java.sql.Types.BIT -> "BOOLEAN";
|
||||||
|
case java.sql.Types.BINARY, java.sql.Types.VARBINARY, java.sql.Types.LONGVARBINARY -> "BINARY";
|
||||||
|
default -> "OTHER:" + String.valueOf(column.get("typeName")).toUpperCase(Locale.ROOT);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史对象到当前元数据对象的解析结果。
|
||||||
|
*
|
||||||
|
* @param historical 历史对象
|
||||||
|
* @param current 当前对象;不可用时为空
|
||||||
|
* @param status ACTIVE、MISSING 或 CONNECTION_DISABLED
|
||||||
|
* @param issueCode 稳定问题码
|
||||||
|
* @param issueMessage 用户可读提示
|
||||||
|
*/
|
||||||
|
public record ObjectResolution(
|
||||||
|
DataspaceObject historical,
|
||||||
|
DataspaceObject current,
|
||||||
|
String status,
|
||||||
|
Integer issueCode,
|
||||||
|
String issueMessage) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回当前对象;不可用时返回历史快照供页面定位与修复。
|
||||||
|
*
|
||||||
|
* @return 可展示对象
|
||||||
|
*/
|
||||||
|
public DataspaceObject displayObject() {
|
||||||
|
return current == null ? historical : current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断对象是否可用。
|
||||||
|
*
|
||||||
|
* @return 是否为 ACTIVE
|
||||||
|
*/
|
||||||
|
public boolean active() {
|
||||||
|
return "ACTIVE".equals(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据库对象稳定物理身份。
|
||||||
|
*
|
||||||
|
* @param catalog Catalog
|
||||||
|
* @param schema Schema
|
||||||
|
* @param name 对象名
|
||||||
|
* @param type 对象类型
|
||||||
|
*/
|
||||||
|
private record PhysicalIdentity(String catalog, String schema, String name, String type) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package tech.easyflow.dataspace.service;
|
||||||
|
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceErrorCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间查询结果的单字段与序列化数据量预算。
|
||||||
|
*/
|
||||||
|
final class DataspaceQueryResultBudget {
|
||||||
|
|
||||||
|
private final long maximumResultBytes;
|
||||||
|
private final long maximumCellBytes;
|
||||||
|
private long reservedBytes = 2L;
|
||||||
|
private int rows;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建结果预算。
|
||||||
|
*
|
||||||
|
* @param maximumResultBytes 结果行数据的最大 JSON 字节数
|
||||||
|
* @param maximumCellBytes 单个文本或二进制字段的最大原始字节数
|
||||||
|
* @throws IllegalArgumentException 上限不为正时抛出
|
||||||
|
*/
|
||||||
|
DataspaceQueryResultBudget(long maximumResultBytes, long maximumCellBytes) {
|
||||||
|
if (maximumResultBytes <= 0L || maximumCellBytes <= 0L) {
|
||||||
|
throw new IllegalArgumentException("查询结果预算必须为正数");
|
||||||
|
}
|
||||||
|
this.maximumResultBytes = maximumResultBytes;
|
||||||
|
this.maximumCellBytes = maximumCellBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为一行结果预留数组和分隔符开销。
|
||||||
|
*
|
||||||
|
* @param columnCount 当前行列数
|
||||||
|
* @throws IllegalArgumentException 列数为负数时抛出
|
||||||
|
*/
|
||||||
|
void beginRow(int columnCount) {
|
||||||
|
if (columnCount < 0) {
|
||||||
|
throw new IllegalArgumentException("列数不能为负数");
|
||||||
|
}
|
||||||
|
reserve(2L + Math.max(0, columnCount - 1) + (rows == 0 ? 0L : 1L));
|
||||||
|
rows++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验单个文本字段的 UTF-8 字节数。
|
||||||
|
*
|
||||||
|
* @param value 文本值
|
||||||
|
* @return 原文本值
|
||||||
|
* @throws tech.easyflow.common.web.exceptions.BusinessException 超过单字段上限时抛出
|
||||||
|
*/
|
||||||
|
String validateText(String value) {
|
||||||
|
if (utf8Bytes(value) > maximumCellBytes) {
|
||||||
|
throw DataspaceErrorCode.QUERY_LIMIT_EXCEEDED.exception(
|
||||||
|
"单个文本字段超过 1 MiB 限制");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验单个二进制字段的原始字节数。
|
||||||
|
*
|
||||||
|
* @param value 二进制值
|
||||||
|
* @return 原二进制值
|
||||||
|
* @throws tech.easyflow.common.web.exceptions.BusinessException 超过单字段上限时抛出
|
||||||
|
*/
|
||||||
|
byte[] validateBinary(byte[] value) {
|
||||||
|
if (value.length > maximumCellBytes) {
|
||||||
|
throw DataspaceErrorCode.QUERY_LIMIT_EXCEEDED.exception(
|
||||||
|
"单个二进制字段超过 1 MiB 限制");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将一个已规范化单元格计入结果序列化预算。
|
||||||
|
*
|
||||||
|
* @param value JSON 安全的单元格值
|
||||||
|
* @throws tech.easyflow.common.web.exceptions.BusinessException 超过结果上限时抛出
|
||||||
|
*/
|
||||||
|
void reserveCell(Object value) {
|
||||||
|
long bytes;
|
||||||
|
if (value == null) {
|
||||||
|
bytes = 4L;
|
||||||
|
} else if (value instanceof String text) {
|
||||||
|
bytes = jsonStringBytes(text);
|
||||||
|
} else {
|
||||||
|
bytes = utf8Bytes(String.valueOf(value));
|
||||||
|
}
|
||||||
|
reserve(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原子检查并增加已使用预算。
|
||||||
|
*
|
||||||
|
* @param bytes 新增字节数
|
||||||
|
* @throws tech.easyflow.common.web.exceptions.BusinessException 超过结果上限时抛出
|
||||||
|
*/
|
||||||
|
private void reserve(long bytes) {
|
||||||
|
if (bytes < 0L || bytes > maximumResultBytes - reservedBytes) {
|
||||||
|
throw DataspaceErrorCode.QUERY_LIMIT_EXCEEDED.exception(
|
||||||
|
"查询结果数据超过 16 MiB 限制,请减少返回行数或字段");
|
||||||
|
}
|
||||||
|
reservedBytes += bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算字符串的 UTF-8 字节数,避免为预算检查额外复制大字符串。
|
||||||
|
*
|
||||||
|
* @param value 字符串
|
||||||
|
* @return UTF-8 字节数
|
||||||
|
*/
|
||||||
|
private static long utf8Bytes(String value) {
|
||||||
|
long bytes = 0L;
|
||||||
|
for (int index = 0; index < value.length(); index++) {
|
||||||
|
char current = value.charAt(index);
|
||||||
|
if (current <= 0x7F) {
|
||||||
|
bytes++;
|
||||||
|
} else if (current <= 0x7FF) {
|
||||||
|
bytes += 2L;
|
||||||
|
} else if (Character.isHighSurrogate(current)
|
||||||
|
&& index + 1 < value.length()
|
||||||
|
&& Character.isLowSurrogate(value.charAt(index + 1))) {
|
||||||
|
bytes += 4L;
|
||||||
|
index++;
|
||||||
|
} else {
|
||||||
|
bytes += 3L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算 JSON 字符串编码后的字节数,包括引号与转义字符。
|
||||||
|
*
|
||||||
|
* @param value 字符串
|
||||||
|
* @return JSON 字符串字节数
|
||||||
|
*/
|
||||||
|
private static long jsonStringBytes(String value) {
|
||||||
|
long bytes = 2L;
|
||||||
|
for (int index = 0; index < value.length(); index++) {
|
||||||
|
char current = value.charAt(index);
|
||||||
|
if (current == '"' || current == '\\') {
|
||||||
|
bytes += 2L;
|
||||||
|
} else if (current <= 0x1F) {
|
||||||
|
bytes += 6L;
|
||||||
|
} else if (current <= 0x7F) {
|
||||||
|
bytes++;
|
||||||
|
} else if (current <= 0x7FF) {
|
||||||
|
bytes += 2L;
|
||||||
|
} else if (Character.isHighSurrogate(current)
|
||||||
|
&& index + 1 < value.length()
|
||||||
|
&& Character.isLowSurrogate(value.charAt(index + 1))) {
|
||||||
|
bytes += 4L;
|
||||||
|
index++;
|
||||||
|
} else {
|
||||||
|
bytes += 3L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,701 @@
|
|||||||
|
package tech.easyflow.dataspace.service;
|
||||||
|
|
||||||
|
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||||
|
import com.easyagents.federation.sql.api.SqlCompletionRequest;
|
||||||
|
import com.easyagents.federation.sql.api.SqlCompletionResult;
|
||||||
|
import com.easyagents.federation.sql.api.SqlQueryCommand;
|
||||||
|
import com.easyagents.federation.sql.compile.FederationFragmentExplain;
|
||||||
|
import com.easyagents.federation.sql.compile.SqlCompileRequest;
|
||||||
|
import com.easyagents.federation.sql.compile.SqlExplainLevel;
|
||||||
|
import com.easyagents.federation.sql.compile.SqlExplainRequest;
|
||||||
|
import com.easyagents.federation.sql.compile.SqlExplainResult;
|
||||||
|
import com.easyagents.federation.sql.execute.FederationColumn;
|
||||||
|
import com.easyagents.federation.sql.execute.FederationPhysicalExplain;
|
||||||
|
import com.easyagents.federation.sql.execute.FederationQueryMetricsSnapshot;
|
||||||
|
import com.easyagents.federation.sql.execute.FederationResultCursor;
|
||||||
|
import com.easyagents.federation.sql.execute.QueryId;
|
||||||
|
import com.easyagents.federation.sql.execute.SqlExecutionOptions;
|
||||||
|
import com.easyagents.federation.sql.federation.FederationExecutionPolicy;
|
||||||
|
import com.easyagents.federation.sql.federation.FederationLogicalTableDefinition;
|
||||||
|
import com.easyagents.federation.sql.federation.FederationQueryScopeDefinition;
|
||||||
|
import com.easyagents.federation.sql.federation.FederationSourceBindingDefinition;
|
||||||
|
import com.easyagents.federation.sql.source.FederationSourceDefinition;
|
||||||
|
import com.easyagents.federation.sql.source.SourceId;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.Reader;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.sql.Blob;
|
||||||
|
import java.sql.Clob;
|
||||||
|
import java.sql.SQLXML;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.temporal.TemporalAccessor;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ConcurrentMap;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceConnection;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceObject;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceQueryAudit;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceTableBinding;
|
||||||
|
import tech.easyflow.dataspace.federation.DataspaceDefinitionFactory;
|
||||||
|
import tech.easyflow.dataspace.federation.DataspaceFederationRuntime;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceQueryAuditMapper;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceExplainResult;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceErrorCode;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceQueryRequest;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceQueryResult;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceSqlCompletionRequest;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceSqlCompletionResult;
|
||||||
|
import tech.easyflow.dataspace.security.DataspaceActor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间单源与联邦只读 Query、Explain、Complete 和 Cancel 服务。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class DataspaceQueryService {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(DataspaceQueryService.class);
|
||||||
|
private static final int DEFAULT_MAX_ROWS = 500;
|
||||||
|
private static final int MAX_ROWS = 2_000;
|
||||||
|
private static final int DEFAULT_TIMEOUT_SECONDS = 30;
|
||||||
|
private static final int MAX_TIMEOUT_SECONDS = 60;
|
||||||
|
private static final long MAX_RESULT_BYTES = 16L * 1024L * 1024L;
|
||||||
|
private static final long MAX_CELL_BYTES = 1024L * 1024L;
|
||||||
|
private final DataspaceService dataspaceService;
|
||||||
|
private final DataspaceFederationRuntime runtime;
|
||||||
|
private final DataspaceDefinitionFactory definitionFactory;
|
||||||
|
private final DataspaceQueryAuditMapper auditMapper;
|
||||||
|
private final ConcurrentMap<String, ActiveQueryOwner> activeQueries = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建查询服务。
|
||||||
|
*
|
||||||
|
* @param dataspaceService 数据空间服务
|
||||||
|
* @param runtime Federation Runtime
|
||||||
|
* @param definitionFactory Definition 工厂
|
||||||
|
* @param auditMapper 查询审计 Mapper
|
||||||
|
*/
|
||||||
|
public DataspaceQueryService(
|
||||||
|
DataspaceService dataspaceService,
|
||||||
|
DataspaceFederationRuntime runtime,
|
||||||
|
DataspaceDefinitionFactory definitionFactory,
|
||||||
|
DataspaceQueryAuditMapper auditMapper) {
|
||||||
|
this.dataspaceService = dataspaceService;
|
||||||
|
this.runtime = runtime;
|
||||||
|
this.definitionFactory = definitionFactory;
|
||||||
|
this.auditMapper = auditMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行一条 Calcite 只读 SQL,并返回数据和常用消耗指标。
|
||||||
|
*
|
||||||
|
* @param request 查询请求
|
||||||
|
* @return 查询结果
|
||||||
|
*/
|
||||||
|
public DataspaceQueryResult query(DataspaceQueryRequest request) {
|
||||||
|
validateRequest(request);
|
||||||
|
DataspaceService.RuntimeSnapshot snapshot = dataspaceService.loadSnapshot(request.dataspaceId());
|
||||||
|
String executableSql = normalizeSql(request.sql());
|
||||||
|
FederationQueryScopeDefinition scope = scopeSafely(snapshot, false);
|
||||||
|
int maxRows = normalize(request.maxRows(), DEFAULT_MAX_ROWS, MAX_ROWS);
|
||||||
|
int timeout = normalize(request.timeoutSeconds(), DEFAULT_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS);
|
||||||
|
QueryId queryId = request.queryId() == null || request.queryId().isBlank()
|
||||||
|
? QueryId.create() : new QueryId(request.queryId().trim());
|
||||||
|
LoginAccount account = DataspaceActor.current();
|
||||||
|
ActiveQueryOwner owner = new ActiveQueryOwner(account.getTenantId(), account.getId());
|
||||||
|
if (activeQueries.putIfAbsent(queryId.value(), owner) != null) {
|
||||||
|
throw DataspaceErrorCode.QUERY_ID_CONFLICT.exception();
|
||||||
|
}
|
||||||
|
SqlQueryCommand command = new SqlQueryCommand(
|
||||||
|
queryId, executableSql, scope, List.of(),
|
||||||
|
new SqlExecutionOptions(500, maxRows, timeout, true),
|
||||||
|
Duration.ofSeconds(5).toMillis(), policyVersion(snapshot));
|
||||||
|
FederationQueryMetricsSnapshot metrics = null;
|
||||||
|
String failureCode = null;
|
||||||
|
String failureMessage = null;
|
||||||
|
try (FederationResultCursor cursor = runtime.engine().query(command)) {
|
||||||
|
List<DataspaceQueryResult.ColumnView> columns = cursor.columns().stream()
|
||||||
|
.map(this::columnView).toList();
|
||||||
|
List<List<Object>> rows = new ArrayList<>();
|
||||||
|
DataspaceQueryResultBudget resultBudget = new DataspaceQueryResultBudget(
|
||||||
|
MAX_RESULT_BYTES, MAX_CELL_BYTES);
|
||||||
|
while (cursor.next()) {
|
||||||
|
resultBudget.beginRow(columns.size());
|
||||||
|
List<Object> row = new ArrayList<>(columns.size());
|
||||||
|
for (int index = 1; index <= columns.size(); index++) {
|
||||||
|
Object value = normalizeCell(cursor.getObject(index), resultBudget);
|
||||||
|
resultBudget.reserveCell(value);
|
||||||
|
row.add(value);
|
||||||
|
}
|
||||||
|
// JDBC 结果允许 NULL;List.copyOf 会对合法的空值抛出 NullPointerException。
|
||||||
|
rows.add(java.util.Collections.unmodifiableList(row));
|
||||||
|
}
|
||||||
|
metrics = cursor.metrics();
|
||||||
|
return new DataspaceQueryResult(queryId.value(), columns, rows, metricsView(metrics));
|
||||||
|
} catch (FederationSqlException exception) {
|
||||||
|
failureCode = exception.errorCode().name();
|
||||||
|
failureMessage = exception.getMessage();
|
||||||
|
throw DataspaceErrorCode.fromFederation(exception, false);
|
||||||
|
} catch (BusinessException exception) {
|
||||||
|
failureCode = String.valueOf(exception.getErrorCode());
|
||||||
|
failureMessage = exception.getMessage();
|
||||||
|
throw exception;
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
failureCode = "UNEXPECTED";
|
||||||
|
failureMessage = exception.getMessage();
|
||||||
|
LOG.error("数据空间查询异常,dataspaceId={}, queryId={}",
|
||||||
|
request.dataspaceId(), queryId.value(), exception);
|
||||||
|
throw exception;
|
||||||
|
} finally {
|
||||||
|
activeQueries.remove(queryId.value(), owner);
|
||||||
|
writeAudit(snapshot, request.sql(), queryId, metrics, null, null,
|
||||||
|
failureCode, failureMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显式执行逻辑与数据库物理 Explain,不执行 ANALYZE。
|
||||||
|
*
|
||||||
|
* @param request Explain 请求
|
||||||
|
* @return Explain 结果
|
||||||
|
*/
|
||||||
|
public DataspaceExplainResult explain(DataspaceQueryRequest request) {
|
||||||
|
validateRequest(request);
|
||||||
|
DataspaceService.RuntimeSnapshot snapshot = dataspaceService.loadSnapshot(request.dataspaceId());
|
||||||
|
String executableSql = normalizeSql(request.sql());
|
||||||
|
FederationQueryScopeDefinition scope = scopeSafely(snapshot, true);
|
||||||
|
QueryId queryId = request.queryId() == null || request.queryId().isBlank()
|
||||||
|
? QueryId.create() : new QueryId(request.queryId().trim());
|
||||||
|
long startedNanos = System.nanoTime();
|
||||||
|
String queryMode = null;
|
||||||
|
String failureCode = null;
|
||||||
|
String failureMessage = null;
|
||||||
|
try {
|
||||||
|
SqlExplainResult explain = runtime.engine().explain(new SqlExplainRequest(
|
||||||
|
new SqlCompileRequest(executableSql, scope, List.of(), policyVersion(snapshot)),
|
||||||
|
SqlExplainLevel.PHYSICAL));
|
||||||
|
queryMode = explain.queryMode().name();
|
||||||
|
return explainView(explain);
|
||||||
|
} catch (FederationSqlException exception) {
|
||||||
|
failureCode = exception.errorCode().name();
|
||||||
|
failureMessage = exception.getMessage();
|
||||||
|
throw DataspaceErrorCode.fromFederation(exception, true);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
failureCode = "UNEXPECTED";
|
||||||
|
failureMessage = exception.getMessage();
|
||||||
|
LOG.error("数据空间 Explain 异常,dataspaceId={}, queryId={}",
|
||||||
|
request.dataspaceId(), queryId.value(), exception);
|
||||||
|
throw exception;
|
||||||
|
} finally {
|
||||||
|
writeAudit(snapshot, request.sql(), queryId, null, queryMode,
|
||||||
|
Duration.ofNanos(System.nanoTime() - startedNanos).toMillis(),
|
||||||
|
failureCode, failureMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据当前数据空间逻辑目录返回 Calcite SQL 补全候选。
|
||||||
|
*
|
||||||
|
* @param request 补全请求
|
||||||
|
* @return 补全替换区间与候选
|
||||||
|
*/
|
||||||
|
public DataspaceSqlCompletionResult complete(DataspaceSqlCompletionRequest request) {
|
||||||
|
validateCompletionRequest(request);
|
||||||
|
DataspaceService.RuntimeSnapshot snapshot =
|
||||||
|
dataspaceService.loadSnapshot(request.dataspaceId());
|
||||||
|
FederationQueryScopeDefinition scope = scopeSafely(snapshot, false);
|
||||||
|
try {
|
||||||
|
SqlCompletionResult result = runtime.engine().complete(new SqlCompletionRequest(
|
||||||
|
scope, request.sql(), request.cursorOffset()));
|
||||||
|
return new DataspaceSqlCompletionResult(
|
||||||
|
result.replaceStart(),
|
||||||
|
result.replaceEnd(),
|
||||||
|
result.items().stream()
|
||||||
|
.map(item -> new DataspaceSqlCompletionResult.ItemView(
|
||||||
|
item.label(), item.insertText(), item.kind().name(),
|
||||||
|
item.qualifiedName()))
|
||||||
|
.toList());
|
||||||
|
} catch (FederationSqlException exception) {
|
||||||
|
throw DataspaceErrorCode.fromFederation(exception, false);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
LOG.error("数据空间 SQL 补全异常,dataspaceId={}",
|
||||||
|
request.dataspaceId(), exception);
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试取消当前节点上的查询。
|
||||||
|
*
|
||||||
|
* @param queryId 查询 ID
|
||||||
|
* @return 是否找到并发起取消
|
||||||
|
*/
|
||||||
|
public boolean cancel(String queryId) {
|
||||||
|
if (queryId == null || queryId.isBlank()) {
|
||||||
|
throw DataspaceErrorCode.QUERY_REQUEST_INVALID.exception("查询 ID 不能为空");
|
||||||
|
}
|
||||||
|
String normalized = queryId.trim();
|
||||||
|
ActiveQueryOwner owner = activeQueries.get(normalized);
|
||||||
|
if (owner == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
LoginAccount account = DataspaceActor.current();
|
||||||
|
if (!Objects.equals(owner.tenantId(), account.getTenantId())
|
||||||
|
|| !Objects.equals(owner.accountId(), account.getId())) {
|
||||||
|
throw DataspaceErrorCode.ACCESS_DENIED.exception("无权取消其他用户的查询");
|
||||||
|
}
|
||||||
|
return runtime.engine().cancel(new QueryId(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 SQL 补全请求。
|
||||||
|
*
|
||||||
|
* @param request 补全请求
|
||||||
|
* @throws BusinessException 请求参数不合法时抛出
|
||||||
|
*/
|
||||||
|
private void validateCompletionRequest(DataspaceSqlCompletionRequest request) {
|
||||||
|
if (request == null || request.dataspaceId() == null || request.sql() == null
|
||||||
|
|| request.cursorOffset() == null
|
||||||
|
|| request.cursorOffset() < 0
|
||||||
|
|| request.cursorOffset() > request.sql().length()) {
|
||||||
|
throw DataspaceErrorCode.QUERY_REQUEST_INVALID.exception(
|
||||||
|
"SQL 补全参数不正确");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将数据库权威 revision 转换为本次查询的联邦作用域。
|
||||||
|
*
|
||||||
|
* @param snapshot 数据空间运行快照
|
||||||
|
* @return Federation 查询作用域
|
||||||
|
* @throws BusinessException 数据空间未配置表时抛出
|
||||||
|
*/
|
||||||
|
private FederationQueryScopeDefinition scope(DataspaceService.RuntimeSnapshot snapshot) {
|
||||||
|
Map<BigInteger, List<DataspaceTableBinding>> byConnection = new LinkedHashMap<>();
|
||||||
|
for (DataspaceTableBinding binding : activeBindings(snapshot)) {
|
||||||
|
byConnection.computeIfAbsent(binding.getConnectionId(), ignored -> new ArrayList<>())
|
||||||
|
.add(binding);
|
||||||
|
}
|
||||||
|
if (byConnection.isEmpty()) {
|
||||||
|
DataspaceService.BindingHealth issue = snapshot.bindingHealth().values().stream()
|
||||||
|
.filter(health -> !health.active())
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (issue != null && "CONNECTION_DISABLED".equals(issue.status())) {
|
||||||
|
throw DataspaceErrorCode.CONNECTION_DISABLED.exception(issue.issueMessage());
|
||||||
|
}
|
||||||
|
if (issue != null) {
|
||||||
|
throw DataspaceErrorCode.OBJECT_MISSING.exception(issue.issueMessage());
|
||||||
|
}
|
||||||
|
throw DataspaceErrorCode.DEFINITION_INVALID.exception(
|
||||||
|
"数据空间尚未配置可查询表");
|
||||||
|
}
|
||||||
|
Map<String, FederationSourceBindingDefinition> definitions = new LinkedHashMap<>();
|
||||||
|
List<FederationLogicalTableDefinition> logicalTables = new ArrayList<>();
|
||||||
|
for (Map.Entry<BigInteger, List<DataspaceTableBinding>> entry : byConnection.entrySet()) {
|
||||||
|
DataspaceConnection connection = snapshot.connections().get(entry.getKey());
|
||||||
|
FederationSourceDefinition source = runtime.ensureSource(connection);
|
||||||
|
String sourceAlias = entry.getValue().get(0).getSourceAlias();
|
||||||
|
Map<String, String> schemaMappings = new LinkedHashMap<>();
|
||||||
|
for (DataspaceTableBinding binding : entry.getValue()) {
|
||||||
|
schemaMappings.put(binding.getSchemaAlias(), binding.getSchemaAlias());
|
||||||
|
}
|
||||||
|
definitions.put(sourceAlias, FederationSourceBindingDefinition.of(
|
||||||
|
source.sourceId(), source.revision(), schemaMappings));
|
||||||
|
for (DataspaceTableBinding binding : entry.getValue()) {
|
||||||
|
DataspaceObject object = snapshot.objects().get(binding.getObjectId());
|
||||||
|
if (object != null) {
|
||||||
|
logicalTables.add(FederationLogicalTableDefinition.of(
|
||||||
|
binding.getTableAlias(), sourceAlias,
|
||||||
|
binding.getSchemaAlias(), object.getObjectName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String defaultBinding = definitions.keySet().iterator().next();
|
||||||
|
return FederationQueryScopeDefinition.virtual(
|
||||||
|
scopeId(snapshot), snapshot.revision().getRevisionNo(), definitions,
|
||||||
|
defaultBinding, logicalTables, FederationExecutionPolicy.basic());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回当前元数据快照中仍可查询的表绑定。
|
||||||
|
*
|
||||||
|
* @param snapshot 数据空间运行快照
|
||||||
|
* @return 活跃表绑定
|
||||||
|
*/
|
||||||
|
private List<DataspaceTableBinding> activeBindings(
|
||||||
|
DataspaceService.RuntimeSnapshot snapshot) {
|
||||||
|
return snapshot.bindings().stream()
|
||||||
|
.filter(binding -> {
|
||||||
|
DataspaceService.BindingHealth health =
|
||||||
|
snapshot.bindingHealth().get(binding.getId());
|
||||||
|
return health != null && health.active();
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 装配查询作用域并统一映射可能发生的 Federation 错误。
|
||||||
|
*
|
||||||
|
* @param snapshot 数据空间运行快照
|
||||||
|
* @param explain 是否用于 Explain
|
||||||
|
* @return Federation 查询作用域
|
||||||
|
*/
|
||||||
|
private FederationQueryScopeDefinition scopeSafely(
|
||||||
|
DataspaceService.RuntimeSnapshot snapshot,
|
||||||
|
boolean explain) {
|
||||||
|
try {
|
||||||
|
return scope(snapshot);
|
||||||
|
} catch (FederationSqlException exception) {
|
||||||
|
throw DataspaceErrorCode.fromFederation(exception, explain);
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
throw DataspaceErrorCode.DEFINITION_INVALID.exception(
|
||||||
|
"数据空间逻辑表配置无效:" + exception.getMessage(), exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换完整的 Federation Explain 信息。
|
||||||
|
*
|
||||||
|
* @param explain Federation Explain 结果
|
||||||
|
* @return API Explain 视图
|
||||||
|
*/
|
||||||
|
static DataspaceExplainResult explainView(SqlExplainResult explain) {
|
||||||
|
return new DataspaceExplainResult(
|
||||||
|
explain.queryMode().name(), explain.normalizedSql(), explain.executionPlan(),
|
||||||
|
explain.executable(), explain.diagnostic(),
|
||||||
|
explain.statisticsStatus().name(), explain.estimateAvailable(),
|
||||||
|
explain.estimatedTransferBytes(),
|
||||||
|
explain.estimatedLocalMemoryBytes(),
|
||||||
|
explain.joinOptimizations().stream()
|
||||||
|
.map(join -> new DataspaceExplainResult.JoinView(
|
||||||
|
join.stageIndex(), join.leftBindings(), join.rightBindings(),
|
||||||
|
join.leftBinding(), join.rightBinding(), join.buildBinding(),
|
||||||
|
join.algorithm().name(), join.reason().name(),
|
||||||
|
join.estimatedBuildBytes()))
|
||||||
|
.toList(),
|
||||||
|
explain.fragments().stream().map(DataspaceQueryService::fragmentView).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换单个物理分片的 Explain 信息。
|
||||||
|
*
|
||||||
|
* @param fragment Federation 分片 Explain
|
||||||
|
* @return API 分片视图
|
||||||
|
*/
|
||||||
|
static DataspaceExplainResult.FragmentView fragmentView(FederationFragmentExplain fragment) {
|
||||||
|
FederationPhysicalExplain physical = fragment.physicalExplain();
|
||||||
|
var cost = fragment.costEstimate();
|
||||||
|
return new DataspaceExplainResult.FragmentView(
|
||||||
|
fragment.bindingName(), fragment.executableSql(),
|
||||||
|
physical == null ? null : physical.scanType(),
|
||||||
|
physical == null ? List.of() : physical.candidateIndexes(),
|
||||||
|
physical == null ? null : physical.chosenIndex(),
|
||||||
|
physical == null ? null : physical.estimatedRows(),
|
||||||
|
cost.estimateAvailable(), cost.estimatedRows(), cost.estimatedRowWidthBytes(),
|
||||||
|
cost.estimatedTransferBytes(), cost.statisticsStatus().name(),
|
||||||
|
cost.statisticsSource(),
|
||||||
|
java.time.Instant.EPOCH.equals(cost.statisticsCollectedAt())
|
||||||
|
? null : cost.statisticsCollectedAt().toString(),
|
||||||
|
fragment.pushedDownOperators(),
|
||||||
|
physical == null ? "" : physical.nativePlan(),
|
||||||
|
physical == null ? "" : physical.diagnostic());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换查询结果列元数据。
|
||||||
|
*
|
||||||
|
* @param column Federation 结果列
|
||||||
|
* @return API 列视图
|
||||||
|
*/
|
||||||
|
private DataspaceQueryResult.ColumnView columnView(FederationColumn column) {
|
||||||
|
return new DataspaceQueryResult.ColumnView(
|
||||||
|
column.label(), column.jdbcType(), column.typeName(), column.nullable());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将底层纳秒指标转换为面向调用方的毫秒指标。
|
||||||
|
*
|
||||||
|
* @param metrics Federation 查询指标
|
||||||
|
* @return API 查询指标
|
||||||
|
*/
|
||||||
|
private DataspaceQueryResult.QueryMetricsView metricsView(FederationQueryMetricsSnapshot metrics) {
|
||||||
|
return new DataspaceQueryResult.QueryMetricsView(
|
||||||
|
metrics.queryMode().name(), metrics.planCacheHit(), millis(metrics.planningNanos()),
|
||||||
|
millis(metrics.databaseExecutionNanos()), millis(metrics.localExecutionNanos()),
|
||||||
|
millis(metrics.executionNanos()), millis(metrics.firstRowNanos()),
|
||||||
|
metrics.returnedRows(), metrics.intermediateRows(), metrics.truncated());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 JDBC 值转换为可稳定 JSON 序列化的结果值。
|
||||||
|
*
|
||||||
|
* @param value JDBC 原始值
|
||||||
|
* @param budget 查询结果预算
|
||||||
|
* @return JSON 安全值
|
||||||
|
*/
|
||||||
|
private Object normalizeCell(Object value, DataspaceQueryResultBudget budget) {
|
||||||
|
if (value == null || value instanceof Boolean || value instanceof Integer
|
||||||
|
|| value instanceof Short || value instanceof Byte || value instanceof Float
|
||||||
|
|| value instanceof Double) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value instanceof String text) {
|
||||||
|
return budget.validateText(text);
|
||||||
|
}
|
||||||
|
if (value instanceof BigInteger bigInteger) {
|
||||||
|
return budget.validateText(bigInteger.toString());
|
||||||
|
}
|
||||||
|
if (value instanceof Long longValue) {
|
||||||
|
return Math.abs(longValue) > 9_007_199_254_740_991L
|
||||||
|
? budget.validateText(longValue.toString()) : longValue;
|
||||||
|
}
|
||||||
|
if (value instanceof BigDecimal decimal) {
|
||||||
|
return budget.validateText(decimal.toPlainString());
|
||||||
|
}
|
||||||
|
if (value instanceof byte[] bytes) {
|
||||||
|
return Base64.getEncoder().encodeToString(budget.validateBinary(bytes));
|
||||||
|
}
|
||||||
|
if (value instanceof java.sql.Date || value instanceof java.sql.Time
|
||||||
|
|| value instanceof java.sql.Timestamp || value instanceof TemporalAccessor) {
|
||||||
|
return budget.validateText(value.toString());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (value instanceof Clob clob) {
|
||||||
|
return budget.validateText(readText(clob.getCharacterStream()));
|
||||||
|
}
|
||||||
|
if (value instanceof Blob blob) {
|
||||||
|
byte[] bytes = budget.validateBinary(readBytes(blob.getBinaryStream()));
|
||||||
|
return Base64.getEncoder().encodeToString(bytes);
|
||||||
|
}
|
||||||
|
if (value instanceof SQLXML xml) {
|
||||||
|
return budget.validateText(readText(xml.getCharacterStream()));
|
||||||
|
}
|
||||||
|
} catch (BusinessException exception) {
|
||||||
|
throw exception;
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new IllegalStateException("读取查询结果大字段失败", exception);
|
||||||
|
}
|
||||||
|
return budget.validateText(String.valueOf(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取受大小限制的字符流。
|
||||||
|
*
|
||||||
|
* @param reader 字符流
|
||||||
|
* @return 文本内容
|
||||||
|
* @throws Exception 读取失败或超过大小限制时抛出
|
||||||
|
*/
|
||||||
|
private String readText(Reader reader) throws Exception {
|
||||||
|
try (reader) {
|
||||||
|
char[] buffer = new char[8_192];
|
||||||
|
StringBuilder value = new StringBuilder();
|
||||||
|
int count;
|
||||||
|
while ((count = reader.read(buffer)) >= 0) {
|
||||||
|
value.append(buffer, 0, count);
|
||||||
|
if (value.length() > MAX_CELL_BYTES) {
|
||||||
|
throw DataspaceErrorCode.QUERY_LIMIT_EXCEEDED.exception(
|
||||||
|
"单个文本字段超过 1 MiB 限制");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取受大小限制的二进制流。
|
||||||
|
*
|
||||||
|
* @param stream 二进制流
|
||||||
|
* @return 字节内容
|
||||||
|
* @throws Exception 读取失败或超过大小限制时抛出
|
||||||
|
*/
|
||||||
|
private byte[] readBytes(InputStream stream) throws Exception {
|
||||||
|
try (stream) {
|
||||||
|
byte[] bytes = stream.readNBytes((int) MAX_CELL_BYTES + 1);
|
||||||
|
if (bytes.length > MAX_CELL_BYTES) {
|
||||||
|
throw DataspaceErrorCode.QUERY_LIMIT_EXCEEDED.exception(
|
||||||
|
"单个二进制字段超过 1 MiB 限制");
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 持久化查询审计;审计失败会记录完整异常,避免覆盖原始查询异常。
|
||||||
|
*
|
||||||
|
* @param snapshot 数据空间运行快照
|
||||||
|
* @param sql 原始 SQL
|
||||||
|
* @param queryId 查询 ID
|
||||||
|
* @param metrics 成功执行后的指标
|
||||||
|
* @param queryMode 无查询指标时的查询模式
|
||||||
|
* @param durationMillis 无查询指标时的总耗时毫秒
|
||||||
|
* @param failureCode 失败代码
|
||||||
|
* @param failureMessage 失败信息
|
||||||
|
*/
|
||||||
|
private void writeAudit(
|
||||||
|
DataspaceService.RuntimeSnapshot snapshot,
|
||||||
|
String sql,
|
||||||
|
QueryId queryId,
|
||||||
|
FederationQueryMetricsSnapshot metrics,
|
||||||
|
String queryMode,
|
||||||
|
Long durationMillis,
|
||||||
|
String failureCode,
|
||||||
|
String failureMessage) {
|
||||||
|
try {
|
||||||
|
LoginAccount account = DataspaceActor.current();
|
||||||
|
DataspaceQueryAudit audit = new DataspaceQueryAudit();
|
||||||
|
audit.setTenantId(account.getTenantId());
|
||||||
|
audit.setDataspaceId(snapshot.dataspace().getId());
|
||||||
|
audit.setRevisionNo(snapshot.revision().getRevisionNo());
|
||||||
|
audit.setQueryId(queryId.value());
|
||||||
|
audit.setQueryMode(metrics == null ? queryMode : metrics.queryMode().name());
|
||||||
|
audit.setSqlDigest(sha256(sql));
|
||||||
|
audit.setSqlText(sql.length() > 16_000 ? sql.substring(0, 16_000) : sql);
|
||||||
|
audit.setStatus(failureCode == null ? "SUCCESS" : "FAILED");
|
||||||
|
audit.setReturnedRows(metrics == null ? 0L : metrics.returnedRows());
|
||||||
|
audit.setIntermediateRows(metrics == null ? 0L : metrics.intermediateRows());
|
||||||
|
audit.setDurationMillis(metrics == null
|
||||||
|
? durationMillis == null ? 0L : durationMillis
|
||||||
|
: millis(metrics.executionNanos()));
|
||||||
|
audit.setErrorCode(failureCode);
|
||||||
|
audit.setErrorMessage(truncate(failureMessage, 500));
|
||||||
|
Date now = new Date();
|
||||||
|
audit.setCreated(now);
|
||||||
|
audit.setCreatedBy(account.getId());
|
||||||
|
audit.setModified(now);
|
||||||
|
audit.setModifiedBy(account.getId());
|
||||||
|
audit.setDeleted(0);
|
||||||
|
auditMapper.insert(audit);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
LOG.error("写入数据空间查询审计失败,queryId={}", queryId.value(), exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验查询必填参数。
|
||||||
|
*
|
||||||
|
* @param request 查询请求
|
||||||
|
* @throws BusinessException 数据空间或 SQL 为空时抛出
|
||||||
|
*/
|
||||||
|
private void validateRequest(DataspaceQueryRequest request) {
|
||||||
|
if (request == null || request.dataspaceId() == null
|
||||||
|
|| request.sql() == null || request.sql().isBlank()) {
|
||||||
|
throw DataspaceErrorCode.QUERY_REQUEST_INVALID.exception(
|
||||||
|
"数据空间和 SQL 不能为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 去除工作台常见的单个结尾分号,保持 Calcite 单语句解析兼容性。
|
||||||
|
*
|
||||||
|
* @param sql 调用方原始 SQL
|
||||||
|
* @return 可交给 Calcite 编译的 SQL
|
||||||
|
*/
|
||||||
|
private String normalizeSql(String sql) {
|
||||||
|
String normalized = sql.trim();
|
||||||
|
if (normalized.endsWith(";")) {
|
||||||
|
return normalized.substring(0, normalized.length() - 1).stripTrailing();
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成包含权威 revision 的作用域 ID。
|
||||||
|
*
|
||||||
|
* @param snapshot 数据空间运行快照
|
||||||
|
* @return 作用域 ID
|
||||||
|
*/
|
||||||
|
private String scopeId(DataspaceService.RuntimeSnapshot snapshot) {
|
||||||
|
return "dataspace:" + snapshot.dataspace().getId() + ":" + snapshot.revision().getRevisionNo();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成随数据空间 revision 变化的策略版本。
|
||||||
|
*
|
||||||
|
* @param snapshot 数据空间运行快照
|
||||||
|
* @return 策略版本
|
||||||
|
*/
|
||||||
|
private String policyVersion(DataspaceService.RuntimeSnapshot snapshot) {
|
||||||
|
return "dataspace-" + snapshot.dataspace().getId() + "-r" + snapshot.revision().getRevisionNo();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化正整数限制并应用服务端上限。
|
||||||
|
*
|
||||||
|
* @param value 调用方值
|
||||||
|
* @param defaultValue 默认值
|
||||||
|
* @param maximum 服务端上限
|
||||||
|
* @return 规范化结果
|
||||||
|
* @throws BusinessException 值不为正数时抛出
|
||||||
|
*/
|
||||||
|
private int normalize(Integer value, int defaultValue, int maximum) {
|
||||||
|
int normalized = value == null ? defaultValue : value;
|
||||||
|
if (normalized <= 0) {
|
||||||
|
throw DataspaceErrorCode.QUERY_REQUEST_INVALID.exception("查询限制必须大于零");
|
||||||
|
}
|
||||||
|
return Math.min(normalized, maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将纳秒转换为毫秒并保留未知值。
|
||||||
|
*
|
||||||
|
* @param nanos 纳秒值
|
||||||
|
* @return 毫秒值
|
||||||
|
*/
|
||||||
|
private long millis(long nanos) {
|
||||||
|
return nanos < 0 ? -1L : Duration.ofNanos(nanos).toMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算 SQL 文本摘要。
|
||||||
|
*
|
||||||
|
* @param value SQL 文本
|
||||||
|
* @return SHA-256 十六进制摘要
|
||||||
|
*/
|
||||||
|
private String sha256(String value) {
|
||||||
|
try {
|
||||||
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (NoSuchAlgorithmException exception) {
|
||||||
|
throw new IllegalStateException("SHA-256 不可用", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 截断可空文本。
|
||||||
|
*
|
||||||
|
* @param value 原始文本
|
||||||
|
* @param maximum 最大长度
|
||||||
|
* @return 截断后的文本
|
||||||
|
*/
|
||||||
|
private String truncate(String value, int maximum) {
|
||||||
|
if (value == null || value.length() <= maximum) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return value.substring(0, maximum);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前节点在途查询的租户与调用人归属。
|
||||||
|
*
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param accountId 调用账号 ID
|
||||||
|
*/
|
||||||
|
private record ActiveQueryOwner(BigInteger tenantId, BigInteger accountId) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,911 @@
|
|||||||
|
package tech.easyflow.dataspace.service;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.util.SearchKeywordUtil;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.dataspace.entity.Dataspace;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceConnection;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceObject;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceRelation;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceRevision;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceTableBinding;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceConnectionMapper;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceMapper;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceObjectMapper;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceRelationMapper;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceRevisionMapper;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceTableBindingMapper;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceDefinition;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceErrorCode;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceSummary;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceView;
|
||||||
|
import tech.easyflow.dataspace.provider.DataspaceDatabaseProviderRegistry;
|
||||||
|
import tech.easyflow.dataspace.provider.DataspaceDatabaseType;
|
||||||
|
import tech.easyflow.dataspace.security.DataspaceActor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间版本化建模和一致性读取服务。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class DataspaceService {
|
||||||
|
|
||||||
|
private static final Duration LOCK_WAIT = Duration.ofSeconds(3);
|
||||||
|
private static final Duration LOCK_LEASE = Duration.ofSeconds(30);
|
||||||
|
private static final int MAX_TABLES = 100;
|
||||||
|
private static final int MAX_SOURCES = 2;
|
||||||
|
private final DataspaceMapper dataspaceMapper;
|
||||||
|
private final DataspaceRevisionMapper revisionMapper;
|
||||||
|
private final DataspaceTableBindingMapper bindingMapper;
|
||||||
|
private final DataspaceRelationMapper relationMapper;
|
||||||
|
private final DataspaceObjectMapper objectMapper;
|
||||||
|
private final DataspaceConnectionMapper connectionMapper;
|
||||||
|
private final DataspaceDatabaseProviderRegistry providerRegistry;
|
||||||
|
private final DataspaceMetadataResolver metadataResolver;
|
||||||
|
private final RedisLockExecutor lockExecutor;
|
||||||
|
private final TransactionTemplate transactionTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建数据空间服务。
|
||||||
|
*
|
||||||
|
* @param dataspaceMapper 数据空间 Mapper
|
||||||
|
* @param revisionMapper revision Mapper
|
||||||
|
* @param bindingMapper 表绑定 Mapper
|
||||||
|
* @param relationMapper 关系 Mapper
|
||||||
|
* @param objectMapper 元数据对象 Mapper
|
||||||
|
* @param connectionMapper 连接 Mapper
|
||||||
|
* @param providerRegistry Provider 注册表
|
||||||
|
* @param metadataResolver 元数据兼容解析器
|
||||||
|
* @param lockExecutor 分布式锁执行器
|
||||||
|
* @param transactionTemplate 事务模板
|
||||||
|
*/
|
||||||
|
public DataspaceService(
|
||||||
|
DataspaceMapper dataspaceMapper,
|
||||||
|
DataspaceRevisionMapper revisionMapper,
|
||||||
|
DataspaceTableBindingMapper bindingMapper,
|
||||||
|
DataspaceRelationMapper relationMapper,
|
||||||
|
DataspaceObjectMapper objectMapper,
|
||||||
|
DataspaceConnectionMapper connectionMapper,
|
||||||
|
DataspaceDatabaseProviderRegistry providerRegistry,
|
||||||
|
DataspaceMetadataResolver metadataResolver,
|
||||||
|
RedisLockExecutor lockExecutor,
|
||||||
|
TransactionTemplate transactionTemplate) {
|
||||||
|
this.dataspaceMapper = dataspaceMapper;
|
||||||
|
this.revisionMapper = revisionMapper;
|
||||||
|
this.bindingMapper = bindingMapper;
|
||||||
|
this.relationMapper = relationMapper;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.connectionMapper = connectionMapper;
|
||||||
|
this.providerRegistry = providerRegistry;
|
||||||
|
this.metadataResolver = metadataResolver;
|
||||||
|
this.lockExecutor = lockExecutor;
|
||||||
|
this.transactionTemplate = transactionTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前租户数据空间列表。
|
||||||
|
*
|
||||||
|
* @param keyword 名称或说明关键词
|
||||||
|
* @return 列表摘要
|
||||||
|
*/
|
||||||
|
public List<DataspaceSummary> list(String keyword) {
|
||||||
|
QueryWrapper query = QueryWrapper.create().orderBy("modified desc, id desc");
|
||||||
|
if (keyword != null && !keyword.isBlank()) {
|
||||||
|
String pattern = SearchKeywordUtil.literalContainsPattern(keyword.trim());
|
||||||
|
query.and("(name LIKE ? ESCAPE '\\\\' OR description LIKE ? ESCAPE '\\\\')",
|
||||||
|
pattern, pattern);
|
||||||
|
}
|
||||||
|
List<Dataspace> spaces = dataspaceMapper.selectListByQuery(query);
|
||||||
|
if (spaces.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<BigInteger> ids = spaces.stream().map(Dataspace::getId).toList();
|
||||||
|
Map<BigInteger, Dataspace> spacesById = new HashMap<>();
|
||||||
|
spaces.forEach(space -> spacesById.put(space.getId(), space));
|
||||||
|
Map<BigInteger, DataspaceRevision> currentRevisions = new HashMap<>();
|
||||||
|
for (DataspaceRevision revision : revisionMapper.selectListByQuery(
|
||||||
|
QueryWrapper.create().in(DataspaceRevision::getDataspaceId, ids))) {
|
||||||
|
currentRevisions.putIfAbsent(revision.getDataspaceId(), revision);
|
||||||
|
Dataspace target = spacesById.get(revision.getDataspaceId());
|
||||||
|
if (target != null && target.getCurrentRevision() != null
|
||||||
|
&& target.getCurrentRevision().equals(revision.getRevisionNo())) {
|
||||||
|
currentRevisions.put(revision.getDataspaceId(), revision);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<BigInteger> revisionIds = currentRevisions.values().stream()
|
||||||
|
.map(DataspaceRevision::getId).toList();
|
||||||
|
Map<BigInteger, List<DataspaceTableBinding>> bindingsByRevision = new HashMap<>();
|
||||||
|
if (!revisionIds.isEmpty()) {
|
||||||
|
for (DataspaceTableBinding binding : bindingMapper.selectListByQuery(
|
||||||
|
QueryWrapper.create().in(DataspaceTableBinding::getRevisionId, revisionIds))) {
|
||||||
|
bindingsByRevision.computeIfAbsent(binding.getRevisionId(), ignored -> new ArrayList<>())
|
||||||
|
.add(binding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return spaces.stream().map(space -> {
|
||||||
|
DataspaceRevision revision = currentRevisions.get(space.getId());
|
||||||
|
List<DataspaceTableBinding> bindings = revision == null
|
||||||
|
? List.of() : bindingsByRevision.getOrDefault(revision.getId(), List.of());
|
||||||
|
long sources = bindings.stream().map(DataspaceTableBinding::getConnectionId).distinct().count();
|
||||||
|
return new DataspaceSummary(
|
||||||
|
space.getId(), space.getName(), space.getDescription(),
|
||||||
|
space.getCurrentRevision() == null ? 0L : space.getCurrentRevision(),
|
||||||
|
space.getStatus(), bindings.size(), sources, space.getModified());
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前 revision 完整详情。
|
||||||
|
*
|
||||||
|
* @param id 数据空间 ID
|
||||||
|
* @return 数据空间详情
|
||||||
|
*/
|
||||||
|
public DataspaceView detail(BigInteger id) {
|
||||||
|
RuntimeSnapshot snapshot = readSnapshot(id, false);
|
||||||
|
List<DataspaceView.TableView> tables = snapshot.bindings().stream().map(binding -> {
|
||||||
|
DataspaceObject object = snapshot.objects().get(binding.getObjectId());
|
||||||
|
DataspaceConnection connection = snapshot.connections().get(binding.getConnectionId());
|
||||||
|
BindingHealth health = snapshot.bindingHealth().get(binding.getId());
|
||||||
|
return new DataspaceView.TableView(
|
||||||
|
binding.getId(), binding.getConnectionId(), connection.getName(),
|
||||||
|
connection.getDatabaseType(), object.getId(),
|
||||||
|
object.getCatalogName(), object.getSchemaName(), object.getObjectName(),
|
||||||
|
binding.getSourceAlias(), binding.getSchemaAlias(), binding.getTableAlias(),
|
||||||
|
binding.getPositionX(), binding.getPositionY(), object.getColumnsJson(),
|
||||||
|
health.status(), health.issueCode(), health.issueMessage());
|
||||||
|
}).toList();
|
||||||
|
List<DataspaceView.RelationView> relations = snapshot.relations().stream()
|
||||||
|
.map(relation -> {
|
||||||
|
RelationHealth health = snapshot.relationHealth().get(relation.getId());
|
||||||
|
return new DataspaceView.RelationView(
|
||||||
|
relation.getId(), relation.getLeftBindingId(), relation.getRightBindingId(),
|
||||||
|
relation.getJoinType(), relation.getLeftColumn(), relation.getRightColumn(),
|
||||||
|
health.status(), health.issueCode(), health.issueMessage());
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
Dataspace space = snapshot.dataspace();
|
||||||
|
return new DataspaceView(
|
||||||
|
space.getId(), space.getName(), space.getDescription(),
|
||||||
|
snapshot.revision().getRevisionNo(), space.getStatus(), tables, relations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建或更新数据空间并生成新的不可变 revision。
|
||||||
|
*
|
||||||
|
* @param definition 建模定义
|
||||||
|
* @return 保存后的当前详情
|
||||||
|
*/
|
||||||
|
public DataspaceView save(DataspaceDefinition definition) {
|
||||||
|
if (definition == null) {
|
||||||
|
throw DataspaceErrorCode.DEFINITION_INVALID.exception("数据空间参数不能为空");
|
||||||
|
}
|
||||||
|
LoginAccount account = DataspaceActor.current();
|
||||||
|
String identity = definition.id() == null
|
||||||
|
? "name:" + required(definition.name(), "数据空间名称").toLowerCase(Locale.ROOT)
|
||||||
|
: definition.id().toString();
|
||||||
|
String lockKey = "easyflow:lock:dataspace:space:" + account.getTenantId() + ":" + identity;
|
||||||
|
BigInteger id = lockExecutor.executeWithLock(lockKey, LOCK_WAIT, LOCK_LEASE,
|
||||||
|
() -> transactionTemplate.execute(status -> writeRevision(definition, account)));
|
||||||
|
return detail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用或禁用数据空间。新查询始终从数据库读取状态,状态提交后立即生效。
|
||||||
|
*
|
||||||
|
* @param id 数据空间 ID
|
||||||
|
* @param enabled 是否启用
|
||||||
|
*/
|
||||||
|
public void setEnabled(BigInteger id, boolean enabled) {
|
||||||
|
LoginAccount account = DataspaceActor.current();
|
||||||
|
String lockKey = "easyflow:lock:dataspace:space:"
|
||||||
|
+ account.getTenantId() + ":" + (id == null ? "missing" : id);
|
||||||
|
lockExecutor.executeWithLock(lockKey, LOCK_WAIT, LOCK_LEASE, () ->
|
||||||
|
transactionTemplate.executeWithoutResult(status -> {
|
||||||
|
Dataspace space = requireDataspace(id);
|
||||||
|
String nextStatus = enabled ? "ENABLED" : "DISABLED";
|
||||||
|
if (nextStatus.equals(space.getStatus())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
space.setStatus(nextStatus);
|
||||||
|
space.setModified(new Date());
|
||||||
|
space.setModifiedBy(account.getId());
|
||||||
|
dataspaceMapper.update(space);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逻辑删除数据空间入口,历史 revision 仍保留用于审计。
|
||||||
|
*
|
||||||
|
* @param id 数据空间 ID
|
||||||
|
*/
|
||||||
|
public void remove(BigInteger id) {
|
||||||
|
LoginAccount account = DataspaceActor.current();
|
||||||
|
String lockKey = "easyflow:lock:dataspace:space:"
|
||||||
|
+ account.getTenantId() + ":" + (id == null ? "missing" : id);
|
||||||
|
lockExecutor.executeWithLock(lockKey, LOCK_WAIT, LOCK_LEASE, () ->
|
||||||
|
transactionTemplate.executeWithoutResult(status -> {
|
||||||
|
Dataspace space = requireDataspace(id);
|
||||||
|
dataspaceMapper.deleteById(space.getId());
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在查询开始时从数据库读取权威 current revision 快照。
|
||||||
|
*
|
||||||
|
* @param id 数据空间 ID
|
||||||
|
* @return 不可变运行快照
|
||||||
|
*/
|
||||||
|
public RuntimeSnapshot loadSnapshot(BigInteger id) {
|
||||||
|
return readSnapshot(id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从数据库装配指定数据空间的当前不可变 revision。
|
||||||
|
*
|
||||||
|
* @param id 数据空间 ID
|
||||||
|
* @param requireQueryable 是否要求数据空间处于可查询状态
|
||||||
|
* @return 当前 revision 快照
|
||||||
|
*/
|
||||||
|
private RuntimeSnapshot readSnapshot(BigInteger id, boolean requireQueryable) {
|
||||||
|
Dataspace space = requireDataspace(id);
|
||||||
|
if ((requireQueryable && !"ENABLED".equals(space.getStatus()))
|
||||||
|
|| space.getCurrentRevision() == null) {
|
||||||
|
throw DataspaceErrorCode.DATASPACE_DISABLED.exception();
|
||||||
|
}
|
||||||
|
DataspaceRevision revision = revisionMapper.selectOneByQuery(QueryWrapper.create()
|
||||||
|
.eq(DataspaceRevision::getDataspaceId, id)
|
||||||
|
.eq(DataspaceRevision::getRevisionNo, space.getCurrentRevision()));
|
||||||
|
if (revision == null) {
|
||||||
|
throw new IllegalStateException("数据空间 current revision 记录缺失");
|
||||||
|
}
|
||||||
|
List<DataspaceTableBinding> bindings = bindingMapper.selectListByQuery(QueryWrapper.create()
|
||||||
|
.eq(DataspaceTableBinding::getRevisionId, revision.getId())
|
||||||
|
.orderBy("id asc"));
|
||||||
|
List<DataspaceRelation> relations = relationMapper.selectListByQuery(QueryWrapper.create()
|
||||||
|
.eq(DataspaceRelation::getRevisionId, revision.getId())
|
||||||
|
.orderBy("id asc"));
|
||||||
|
Set<BigInteger> objectIds = new LinkedHashSet<>();
|
||||||
|
Set<BigInteger> connectionIds = new LinkedHashSet<>();
|
||||||
|
bindings.forEach(binding -> {
|
||||||
|
objectIds.add(binding.getObjectId());
|
||||||
|
connectionIds.add(binding.getConnectionId());
|
||||||
|
});
|
||||||
|
Map<BigInteger, DataspaceObject> objects = new LinkedHashMap<>();
|
||||||
|
if (!objectIds.isEmpty()) {
|
||||||
|
objectMapper.selectListByQuery(QueryWrapper.create().in(DataspaceObject::getId, objectIds))
|
||||||
|
.forEach(item -> objects.put(item.getId(), item));
|
||||||
|
}
|
||||||
|
Map<BigInteger, DataspaceConnection> connections = new LinkedHashMap<>();
|
||||||
|
if (!connectionIds.isEmpty()) {
|
||||||
|
connectionMapper.selectListByQuery(QueryWrapper.create().in(DataspaceConnection::getId, connectionIds))
|
||||||
|
.forEach(item -> connections.put(item.getId(), item));
|
||||||
|
}
|
||||||
|
if (objects.size() != objectIds.size() || connections.size() != connectionIds.size()) {
|
||||||
|
throw new IllegalStateException("数据空间 revision 引用记录不完整");
|
||||||
|
}
|
||||||
|
Map<BigInteger, DataspaceMetadataResolver.ObjectResolution> resolutions =
|
||||||
|
metadataResolver.resolve(objects, connections);
|
||||||
|
Map<BigInteger, DataspaceObject> resolvedObjects = new LinkedHashMap<>();
|
||||||
|
Map<BigInteger, BindingHealth> bindingHealth = new LinkedHashMap<>();
|
||||||
|
for (DataspaceTableBinding binding : bindings) {
|
||||||
|
DataspaceMetadataResolver.ObjectResolution resolution =
|
||||||
|
resolutions.get(binding.getObjectId());
|
||||||
|
if (resolution == null) {
|
||||||
|
throw new IllegalStateException("数据空间元数据解析结果不完整");
|
||||||
|
}
|
||||||
|
resolvedObjects.put(binding.getObjectId(), resolution.displayObject());
|
||||||
|
bindingHealth.put(binding.getId(), new BindingHealth(
|
||||||
|
resolution.status(), resolution.issueCode(), resolution.issueMessage()));
|
||||||
|
}
|
||||||
|
Map<BigInteger, RelationHealth> relationHealth = relationHealth(
|
||||||
|
bindings, relations, resolvedObjects, connections, bindingHealth);
|
||||||
|
return new RuntimeSnapshot(
|
||||||
|
space, revision, bindings, relations, resolvedObjects, connections,
|
||||||
|
bindingHealth, relationHealth);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在同一事务中写入新 revision,并通过 CAS 切换当前 revision。
|
||||||
|
*
|
||||||
|
* @param definition 数据空间定义
|
||||||
|
* @param account 当前登录账号
|
||||||
|
* @return 数据空间 ID
|
||||||
|
* @throws BusinessException revision 冲突或定义校验失败时抛出
|
||||||
|
*/
|
||||||
|
private BigInteger writeRevision(DataspaceDefinition definition, LoginAccount account) {
|
||||||
|
Dataspace space;
|
||||||
|
long expected;
|
||||||
|
if (definition.id() == null) {
|
||||||
|
ensureNameUnique(definition.name(), null);
|
||||||
|
space = new Dataspace();
|
||||||
|
space.setTenantId(account.getTenantId());
|
||||||
|
space.setName(required(definition.name(), "数据空间名称"));
|
||||||
|
space.setDescription(trim(definition.description()));
|
||||||
|
space.setCurrentRevision(0L);
|
||||||
|
space.setStatus("ENABLED");
|
||||||
|
fillCreate(space, account);
|
||||||
|
dataspaceMapper.insert(space);
|
||||||
|
expected = 0L;
|
||||||
|
} else {
|
||||||
|
space = requireDataspace(definition.id());
|
||||||
|
expected = definition.expectedRevision() == null ? -1L : definition.expectedRevision();
|
||||||
|
if (space.getCurrentRevision() == null || space.getCurrentRevision() != expected) {
|
||||||
|
throw DataspaceErrorCode.REVISION_CONFLICT.exception(
|
||||||
|
"数据空间已被其他节点更新,请刷新后重试");
|
||||||
|
}
|
||||||
|
ensureNameUnique(definition.name(), space.getId());
|
||||||
|
}
|
||||||
|
ValidatedDefinition validated = validateDefinition(definition);
|
||||||
|
long nextRevision = expected + 1L;
|
||||||
|
DataspaceRevision revision = new DataspaceRevision();
|
||||||
|
revision.setTenantId(account.getTenantId());
|
||||||
|
revision.setDataspaceId(space.getId());
|
||||||
|
revision.setRevisionNo(nextRevision);
|
||||||
|
revision.setSnapshotChecksum(checksum(definition, validated));
|
||||||
|
fillCreate(revision, account);
|
||||||
|
revisionMapper.insert(revision);
|
||||||
|
|
||||||
|
Map<String, BigInteger> bindingIds = new HashMap<>();
|
||||||
|
for (ValidatedTable item : validated.tables()) {
|
||||||
|
DataspaceTableBinding binding = new DataspaceTableBinding();
|
||||||
|
binding.setTenantId(account.getTenantId());
|
||||||
|
binding.setDataspaceId(space.getId());
|
||||||
|
binding.setRevisionId(revision.getId());
|
||||||
|
binding.setConnectionId(item.connection().getId());
|
||||||
|
binding.setSourceRevision(item.connection().getDefinitionRevision());
|
||||||
|
binding.setObjectId(item.object().getId());
|
||||||
|
binding.setSourceAlias(item.sourceAlias());
|
||||||
|
binding.setSchemaAlias(item.schemaAlias());
|
||||||
|
binding.setTableAlias(item.logicalTableName());
|
||||||
|
binding.setPositionX(item.definition().positionX());
|
||||||
|
binding.setPositionY(item.definition().positionY());
|
||||||
|
fillCreate(binding, account);
|
||||||
|
bindingMapper.insert(binding);
|
||||||
|
bindingIds.put(item.definition().clientKey(), binding.getId());
|
||||||
|
}
|
||||||
|
for (DataspaceDefinition.RelationDefinition item : definition.relations()) {
|
||||||
|
DataspaceRelation relation = new DataspaceRelation();
|
||||||
|
relation.setTenantId(account.getTenantId());
|
||||||
|
relation.setDataspaceId(space.getId());
|
||||||
|
relation.setRevisionId(revision.getId());
|
||||||
|
relation.setLeftBindingId(bindingIds.get(item.leftClientKey()));
|
||||||
|
relation.setRightBindingId(bindingIds.get(item.rightClientKey()));
|
||||||
|
relation.setJoinType(normalizeJoin(item.joinType()));
|
||||||
|
relation.setLeftColumn(item.leftColumn().trim());
|
||||||
|
relation.setRightColumn(item.rightColumn().trim());
|
||||||
|
fillCreate(relation, account);
|
||||||
|
relationMapper.insert(relation);
|
||||||
|
}
|
||||||
|
int changed = dataspaceMapper.compareAndSetCurrentRevision(
|
||||||
|
space.getId(), account.getTenantId(), expected, nextRevision);
|
||||||
|
if (changed != 1) {
|
||||||
|
throw DataspaceErrorCode.REVISION_CONFLICT.exception(
|
||||||
|
"数据空间已被其他节点更新,请刷新后重试");
|
||||||
|
}
|
||||||
|
space.setName(required(definition.name(), "数据空间名称"));
|
||||||
|
space.setDescription(trim(definition.description()));
|
||||||
|
space.setCurrentRevision(nextRevision);
|
||||||
|
space.setModified(new Date());
|
||||||
|
space.setModifiedBy(account.getId());
|
||||||
|
dataspaceMapper.update(space);
|
||||||
|
return space.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验纳管表、数据源版本、别名与关联关系。
|
||||||
|
*
|
||||||
|
* @param definition 数据空间定义
|
||||||
|
* @return 解析完成的定义
|
||||||
|
* @throws BusinessException 定义不完整或元数据过期时抛出
|
||||||
|
*/
|
||||||
|
private ValidatedDefinition validateDefinition(DataspaceDefinition definition) {
|
||||||
|
if (definition.tables().isEmpty()) {
|
||||||
|
throw DataspaceErrorCode.DEFINITION_INVALID.exception("数据空间至少需要一张表");
|
||||||
|
}
|
||||||
|
if (definition.tables().size() > MAX_TABLES) {
|
||||||
|
throw DataspaceErrorCode.DEFINITION_INVALID.exception(
|
||||||
|
"单个数据空间最多支持 " + MAX_TABLES + " 张表");
|
||||||
|
}
|
||||||
|
Set<String> clientKeys = new HashSet<>();
|
||||||
|
Set<BigInteger> objectIds = new LinkedHashSet<>();
|
||||||
|
for (DataspaceDefinition.TableDefinition table : definition.tables()) {
|
||||||
|
if (table.clientKey() == null || table.clientKey().isBlank()
|
||||||
|
|| !clientKeys.add(table.clientKey())) {
|
||||||
|
throw DataspaceErrorCode.DEFINITION_INVALID.exception(
|
||||||
|
"表节点标识不能为空且不能重复");
|
||||||
|
}
|
||||||
|
if (table.objectId() == null || !objectIds.add(table.objectId())) {
|
||||||
|
throw DataspaceErrorCode.DEFINITION_INVALID.exception(
|
||||||
|
"同一物理表不能重复加入数据空间");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<BigInteger, DataspaceObject> objects = new HashMap<>();
|
||||||
|
objectMapper.selectListByQuery(QueryWrapper.create().in(DataspaceObject::getId, objectIds))
|
||||||
|
.forEach(item -> objects.put(item.getId(), item));
|
||||||
|
if (objects.size() != objectIds.size()) {
|
||||||
|
throw DataspaceErrorCode.OBJECT_MISSING.exception(
|
||||||
|
"部分数据库对象不存在,请刷新元数据后重试");
|
||||||
|
}
|
||||||
|
Set<BigInteger> connectionIds = objects.values().stream()
|
||||||
|
.map(DataspaceObject::getConnectionId).collect(java.util.stream.Collectors.toSet());
|
||||||
|
if (connectionIds.size() > MAX_SOURCES) {
|
||||||
|
throw DataspaceErrorCode.DEFINITION_INVALID.exception(
|
||||||
|
"当前阶段单个数据空间最多支持两个物理数据源");
|
||||||
|
}
|
||||||
|
Map<BigInteger, DataspaceConnection> connections = new HashMap<>();
|
||||||
|
connectionMapper.selectListByQuery(QueryWrapper.create().in(DataspaceConnection::getId, connectionIds))
|
||||||
|
.forEach(item -> connections.put(item.getId(), item));
|
||||||
|
Map<BigInteger, DataspaceMetadataResolver.ObjectResolution> resolutions =
|
||||||
|
metadataResolver.resolve(objects, connections);
|
||||||
|
Map<BigInteger, String> aliasesByConnection = new LinkedHashMap<>();
|
||||||
|
Set<String> usedAliases = new HashSet<>();
|
||||||
|
Set<String> logicalTableAliases = new HashSet<>();
|
||||||
|
Set<BigInteger> currentObjectIds = new HashSet<>();
|
||||||
|
List<ValidatedTable> tables = new ArrayList<>();
|
||||||
|
int index = 1;
|
||||||
|
for (DataspaceDefinition.TableDefinition table : definition.tables()) {
|
||||||
|
DataspaceObject historical = objects.get(table.objectId());
|
||||||
|
DataspaceConnection connection = connections.get(historical.getConnectionId());
|
||||||
|
if (connection == null) {
|
||||||
|
throw DataspaceErrorCode.CONNECTION_NOT_FOUND.exception();
|
||||||
|
}
|
||||||
|
if (!"ENABLED".equals(connection.getStatus())) {
|
||||||
|
throw DataspaceErrorCode.CONNECTION_DISABLED.exception(
|
||||||
|
"数据连接 “" + connection.getName() + "” 已禁用,请先启用并测试连接");
|
||||||
|
}
|
||||||
|
DataspaceMetadataResolver.ObjectResolution resolution = resolutions.get(table.objectId());
|
||||||
|
DataspaceObject object = resolution == null ? null : resolution.current();
|
||||||
|
if (object == null) {
|
||||||
|
throw DataspaceErrorCode.OBJECT_MISSING.exception(
|
||||||
|
"表或视图 “" + historical.getObjectName()
|
||||||
|
+ "” 已不在最新元数据中,请刷新或移除该表");
|
||||||
|
}
|
||||||
|
if (!currentObjectIds.add(object.getId())) {
|
||||||
|
throw DataspaceErrorCode.OBJECT_MISSING.exception("同一物理表不能重复加入数据空间");
|
||||||
|
}
|
||||||
|
String logicalTableName = required(table.tableAlias(), "逻辑表名").trim();
|
||||||
|
requireIdentifier(logicalTableName, "逻辑表名");
|
||||||
|
if (!logicalTableAliases.add(logicalTableName.toUpperCase(Locale.ROOT))) {
|
||||||
|
throw DataspaceErrorCode.DEFINITION_INVALID.exception(
|
||||||
|
"数据空间内的逻辑表名必须唯一,名称 “" + logicalTableName + "” 重复");
|
||||||
|
}
|
||||||
|
String requested = table.sourceAlias() == null || table.sourceAlias().isBlank()
|
||||||
|
? defaultAlias(connection.getName(), index) : table.sourceAlias().trim().toUpperCase(Locale.ROOT);
|
||||||
|
requireIdentifier(requested, "数据源别名");
|
||||||
|
String existingAlias = aliasesByConnection.putIfAbsent(connection.getId(), requested);
|
||||||
|
if (existingAlias != null && !existingAlias.equals(requested)) {
|
||||||
|
throw DataspaceErrorCode.DEFINITION_INVALID.exception(
|
||||||
|
"同一物理连接必须使用相同的数据源别名");
|
||||||
|
}
|
||||||
|
if (existingAlias == null && !usedAliases.add(requested)) {
|
||||||
|
throw DataspaceErrorCode.DEFINITION_INVALID.exception(
|
||||||
|
"不同物理连接的数据源别名不能重复");
|
||||||
|
}
|
||||||
|
String schemaAlias = providerRegistry
|
||||||
|
.require(DataspaceDatabaseType.parse(connection.getDatabaseType()))
|
||||||
|
.logicalSchema(object.getSchemaName());
|
||||||
|
tables.add(new ValidatedTable(
|
||||||
|
table, object, connection, requested, schemaAlias, logicalTableName));
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
Map<String, ValidatedTable> byKey = new HashMap<>();
|
||||||
|
tables.forEach(item -> byKey.put(item.definition().clientKey(), item));
|
||||||
|
for (DataspaceDefinition.RelationDefinition relation : definition.relations()) {
|
||||||
|
ValidatedTable left = byKey.get(relation.leftClientKey());
|
||||||
|
ValidatedTable right = byKey.get(relation.rightClientKey());
|
||||||
|
if (left == null || right == null || left == right) {
|
||||||
|
throw DataspaceErrorCode.DEFINITION_INVALID.exception("表关系引用无效");
|
||||||
|
}
|
||||||
|
normalizeJoin(relation.joinType());
|
||||||
|
requireColumn(left.object(), left.connection(), relation.leftColumn());
|
||||||
|
requireColumn(right.object(), right.connection(), relation.rightColumn());
|
||||||
|
requireComparable(
|
||||||
|
left.object(), left.connection(), relation.leftColumn(),
|
||||||
|
right.object(), right.connection(), relation.rightColumn());
|
||||||
|
}
|
||||||
|
return new ValidatedDefinition(List.copyOf(tables));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认元数据对象包含指定字段。
|
||||||
|
*
|
||||||
|
* @param object 元数据对象
|
||||||
|
* @param connection 物理连接
|
||||||
|
* @param columnName 字段名
|
||||||
|
* @throws BusinessException 字段不存在时抛出
|
||||||
|
*/
|
||||||
|
private void requireColumn(
|
||||||
|
DataspaceObject object,
|
||||||
|
DataspaceConnection connection,
|
||||||
|
String columnName) {
|
||||||
|
String required = required(columnName, "关联字段");
|
||||||
|
if (!metadataResolver.hasColumn(object, required, connection.getDatabaseType())) {
|
||||||
|
throw DataspaceErrorCode.RELATION_FIELD_MISSING.exception(
|
||||||
|
"表 “" + object.getObjectName() + "” 的关联字段 “" + required
|
||||||
|
+ "” 已不存在,请重新连线或删除该关系");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验等值关系两端字段类型可比较。
|
||||||
|
*
|
||||||
|
* @param leftObject 左表对象
|
||||||
|
* @param leftConnection 左表连接
|
||||||
|
* @param leftColumn 左字段
|
||||||
|
* @param rightObject 右表对象
|
||||||
|
* @param rightConnection 右表连接
|
||||||
|
* @param rightColumn 右字段
|
||||||
|
* @throws BusinessException 字段类型不兼容时抛出
|
||||||
|
*/
|
||||||
|
private void requireComparable(
|
||||||
|
DataspaceObject leftObject,
|
||||||
|
DataspaceConnection leftConnection,
|
||||||
|
String leftColumn,
|
||||||
|
DataspaceObject rightObject,
|
||||||
|
DataspaceConnection rightConnection,
|
||||||
|
String rightColumn) {
|
||||||
|
if (!metadataResolver.comparable(
|
||||||
|
metadataResolver.column(
|
||||||
|
leftObject, leftColumn, leftConnection.getDatabaseType()),
|
||||||
|
metadataResolver.column(
|
||||||
|
rightObject, rightColumn, rightConnection.getDatabaseType()))) {
|
||||||
|
throw DataspaceErrorCode.RELATION_TYPE_INCOMPATIBLE.exception(
|
||||||
|
"关联字段 “" + leftObject.getObjectName() + "." + leftColumn + "” 与 “"
|
||||||
|
+ rightObject.getObjectName() + "." + rightColumn
|
||||||
|
+ "” 类型不兼容,请重新选择字段");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算当前元数据下每条关系的局部健康状态。
|
||||||
|
*
|
||||||
|
* @param bindings 当前表绑定
|
||||||
|
* @param relations 当前关系
|
||||||
|
* @param objects 已解析到最新快照的对象
|
||||||
|
* @param connections 当前物理连接
|
||||||
|
* @param bindingHealth 表绑定健康状态
|
||||||
|
* @return 关系 ID 到健康状态的映射
|
||||||
|
*/
|
||||||
|
private Map<BigInteger, RelationHealth> relationHealth(
|
||||||
|
List<DataspaceTableBinding> bindings,
|
||||||
|
List<DataspaceRelation> relations,
|
||||||
|
Map<BigInteger, DataspaceObject> objects,
|
||||||
|
Map<BigInteger, DataspaceConnection> connections,
|
||||||
|
Map<BigInteger, BindingHealth> bindingHealth) {
|
||||||
|
Map<BigInteger, DataspaceTableBinding> bindingsById = new HashMap<>();
|
||||||
|
bindings.forEach(binding -> bindingsById.put(binding.getId(), binding));
|
||||||
|
Map<BigInteger, RelationHealth> result = new LinkedHashMap<>();
|
||||||
|
for (DataspaceRelation relation : relations) {
|
||||||
|
DataspaceTableBinding leftBinding = bindingsById.get(relation.getLeftBindingId());
|
||||||
|
DataspaceTableBinding rightBinding = bindingsById.get(relation.getRightBindingId());
|
||||||
|
if (leftBinding == null || rightBinding == null) {
|
||||||
|
throw new IllegalStateException("数据空间关系引用的表绑定不存在");
|
||||||
|
}
|
||||||
|
BindingHealth leftHealth = bindingHealth.get(leftBinding.getId());
|
||||||
|
BindingHealth rightHealth = bindingHealth.get(rightBinding.getId());
|
||||||
|
if (leftHealth == null || rightHealth == null
|
||||||
|
|| !leftHealth.active() || !rightHealth.active()) {
|
||||||
|
BindingHealth issue = leftHealth != null && !leftHealth.active()
|
||||||
|
? leftHealth : rightHealth;
|
||||||
|
result.put(relation.getId(), new RelationHealth(
|
||||||
|
"INVALID",
|
||||||
|
issue == null || issue.issueCode() == null
|
||||||
|
? DataspaceErrorCode.OBJECT_MISSING.code() : issue.issueCode(),
|
||||||
|
issue == null || issue.issueMessage() == null
|
||||||
|
? "关系引用的表当前不可用,请移除失效表或重新添加后连线"
|
||||||
|
: issue.issueMessage()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
DataspaceObject left = objects.get(leftBinding.getObjectId());
|
||||||
|
DataspaceObject right = objects.get(rightBinding.getObjectId());
|
||||||
|
DataspaceConnection leftConnection = connections.get(leftBinding.getConnectionId());
|
||||||
|
DataspaceConnection rightConnection = connections.get(rightBinding.getConnectionId());
|
||||||
|
if (!metadataResolver.hasColumn(
|
||||||
|
left, relation.getLeftColumn(), leftConnection.getDatabaseType())
|
||||||
|
|| !metadataResolver.hasColumn(
|
||||||
|
right, relation.getRightColumn(), rightConnection.getDatabaseType())) {
|
||||||
|
result.put(relation.getId(), new RelationHealth(
|
||||||
|
"INVALID", DataspaceErrorCode.RELATION_FIELD_MISSING.code(),
|
||||||
|
"关联字段已不存在,请重新连线或删除该关系"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!metadataResolver.comparable(
|
||||||
|
metadataResolver.column(
|
||||||
|
left, relation.getLeftColumn(), leftConnection.getDatabaseType()),
|
||||||
|
metadataResolver.column(
|
||||||
|
right, relation.getRightColumn(), rightConnection.getDatabaseType()))) {
|
||||||
|
result.put(relation.getId(), new RelationHealth(
|
||||||
|
"INVALID", DataspaceErrorCode.RELATION_TYPE_INCOMPATIBLE.code(),
|
||||||
|
"关联字段类型已不兼容,请重新选择字段"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.put(relation.getId(), RelationHealth.active());
|
||||||
|
}
|
||||||
|
return Map.copyOf(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算数据空间 revision 的稳定快照摘要。
|
||||||
|
*
|
||||||
|
* @param definition 原始定义
|
||||||
|
* @param validated 已校验定义
|
||||||
|
* @return SHA-256 十六进制摘要
|
||||||
|
*/
|
||||||
|
private String checksum(DataspaceDefinition definition, ValidatedDefinition validated) {
|
||||||
|
StringBuilder canonical = new StringBuilder("dataspace-v1\n")
|
||||||
|
.append(required(definition.name(), "数据空间名称")).append('\n')
|
||||||
|
.append(trim(definition.description())).append('\n');
|
||||||
|
validated.tables().stream()
|
||||||
|
.sorted(Comparator.comparing(item -> item.definition().clientKey()))
|
||||||
|
.forEach(item -> canonical.append(item.definition().clientKey()).append('|')
|
||||||
|
.append(item.object().getId()).append('|').append(item.sourceAlias()).append('|')
|
||||||
|
.append(item.schemaAlias()).append('|').append(item.object().getObjectName()).append('|')
|
||||||
|
.append(item.definition().positionX()).append('|').append(item.definition().positionY())
|
||||||
|
.append('\n'));
|
||||||
|
definition.relations().stream()
|
||||||
|
.sorted(Comparator.comparing(item -> item.leftClientKey() + ":" + item.rightClientKey()))
|
||||||
|
.forEach(item -> canonical.append(item.leftClientKey()).append('|')
|
||||||
|
.append(item.rightClientKey()).append('|').append(normalizeJoin(item.joinType())).append('|')
|
||||||
|
.append(item.leftColumn()).append('|').append(item.rightColumn()).append('\n'));
|
||||||
|
try {
|
||||||
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(canonical.toString().getBytes(StandardCharsets.UTF_8)));
|
||||||
|
} catch (NoSuchAlgorithmException exception) {
|
||||||
|
throw new IllegalStateException("SHA-256 不可用", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验当前租户内的数据空间名称唯一性。
|
||||||
|
*
|
||||||
|
* @param name 数据空间名称
|
||||||
|
* @param excludedId 更新时排除的数据空间 ID
|
||||||
|
* @throws BusinessException 名称重复时抛出
|
||||||
|
*/
|
||||||
|
private void ensureNameUnique(String name, BigInteger excludedId) {
|
||||||
|
QueryWrapper query = QueryWrapper.create().eq(Dataspace::getName, required(name, "数据空间名称"));
|
||||||
|
if (excludedId != null) {
|
||||||
|
query.ne(Dataspace::getId, excludedId);
|
||||||
|
}
|
||||||
|
if (dataspaceMapper.selectCountByQuery(query) > 0) {
|
||||||
|
throw DataspaceErrorCode.NAME_CONFLICT.exception("数据空间名称已存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前租户可见的数据空间。
|
||||||
|
*
|
||||||
|
* @param id 数据空间 ID
|
||||||
|
* @return 数据空间记录
|
||||||
|
* @throws BusinessException ID 为空或记录不存在时抛出
|
||||||
|
*/
|
||||||
|
private Dataspace requireDataspace(BigInteger id) {
|
||||||
|
if (id == null) {
|
||||||
|
throw DataspaceErrorCode.DEFINITION_INVALID.exception("数据空间 ID 不能为空");
|
||||||
|
}
|
||||||
|
Dataspace space = dataspaceMapper.selectOneById(id);
|
||||||
|
if (space == null) {
|
||||||
|
throw DataspaceErrorCode.DATASPACE_NOT_FOUND.exception();
|
||||||
|
}
|
||||||
|
return space;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 填充新增记录的公共审计字段。
|
||||||
|
*
|
||||||
|
* @param target 目标记录
|
||||||
|
* @param account 当前登录账号
|
||||||
|
*/
|
||||||
|
private void fillCreate(tech.easyflow.dataspace.entity.DataspaceRecordBase target, LoginAccount account) {
|
||||||
|
Date now = new Date();
|
||||||
|
target.setCreated(now);
|
||||||
|
target.setCreatedBy(account.getId());
|
||||||
|
target.setModified(now);
|
||||||
|
target.setModifiedBy(account.getId());
|
||||||
|
target.setDeleted(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据连接名称生成合法且稳定的默认数据源别名。
|
||||||
|
*
|
||||||
|
* @param name 连接名称
|
||||||
|
* @param index 连接序号
|
||||||
|
* @return SQL 数据源别名
|
||||||
|
*/
|
||||||
|
private String defaultAlias(String name, int index) {
|
||||||
|
String normalized = name == null ? "SOURCE_" + index
|
||||||
|
: name.trim().toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9_]", "_");
|
||||||
|
if (normalized.isBlank()) {
|
||||||
|
normalized = "SOURCE_" + index;
|
||||||
|
}
|
||||||
|
if (Character.isDigit(normalized.charAt(0))) {
|
||||||
|
normalized = "S_" + normalized;
|
||||||
|
}
|
||||||
|
return normalized.length() > 48 ? normalized.substring(0, 48) : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 Calcite 标识符格式。
|
||||||
|
*
|
||||||
|
* @param value 标识符
|
||||||
|
* @param label 字段标签
|
||||||
|
* @throws BusinessException 格式非法时抛出
|
||||||
|
*/
|
||||||
|
private void requireIdentifier(String value, String label) {
|
||||||
|
if (!isIdentifier(value)) {
|
||||||
|
throw DataspaceErrorCode.DEFINITION_INVALID.exception(
|
||||||
|
label + "仅支持字母、数字和下划线,且不能以数字开头");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断名称是否为 Calcite 可直接引用的非引号标识符。
|
||||||
|
*
|
||||||
|
* @param value 待校验名称
|
||||||
|
* @return 名称是否合法
|
||||||
|
*/
|
||||||
|
static boolean isIdentifier(String value) {
|
||||||
|
return value != null && value.matches("[A-Za-z_][A-Za-z0-9_]{0,63}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化并校验 Join 类型。
|
||||||
|
*
|
||||||
|
* @param value Join 类型
|
||||||
|
* @return 大写 Join 类型
|
||||||
|
* @throws BusinessException 类型不受支持时抛出
|
||||||
|
*/
|
||||||
|
private String normalizeJoin(String value) {
|
||||||
|
String normalized = required(value, "Join 类型").toUpperCase(Locale.ROOT);
|
||||||
|
if (!Set.of("INNER", "LEFT", "RIGHT", "FULL").contains(normalized)) {
|
||||||
|
throw DataspaceErrorCode.DEFINITION_INVALID.exception(
|
||||||
|
"暂不支持该 Join 类型: " + value);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取必填文本并去除首尾空白。
|
||||||
|
*
|
||||||
|
* @param value 原始值
|
||||||
|
* @param label 字段标签
|
||||||
|
* @return 规范化文本
|
||||||
|
* @throws BusinessException 文本为空时抛出
|
||||||
|
*/
|
||||||
|
private String required(String value, String label) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw DataspaceErrorCode.DEFINITION_INVALID.exception(label + "不能为空");
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将可空文本规范化为空串或去除首尾空白的文本。
|
||||||
|
*
|
||||||
|
* @param value 原始值
|
||||||
|
* @return 规范化文本
|
||||||
|
*/
|
||||||
|
private String trim(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询使用的数据库权威 revision 快照。
|
||||||
|
*
|
||||||
|
* @param dataspace 数据空间
|
||||||
|
* @param revision 当前 revision
|
||||||
|
* @param bindings 表绑定
|
||||||
|
* @param relations 表关系
|
||||||
|
* @param objects 元数据对象映射
|
||||||
|
* @param connections 连接映射
|
||||||
|
* @param bindingHealth 表绑定健康状态
|
||||||
|
* @param relationHealth 关系健康状态
|
||||||
|
*/
|
||||||
|
public record RuntimeSnapshot(
|
||||||
|
Dataspace dataspace,
|
||||||
|
DataspaceRevision revision,
|
||||||
|
List<DataspaceTableBinding> bindings,
|
||||||
|
List<DataspaceRelation> relations,
|
||||||
|
Map<BigInteger, DataspaceObject> objects,
|
||||||
|
Map<BigInteger, DataspaceConnection> connections,
|
||||||
|
Map<BigInteger, BindingHealth> bindingHealth,
|
||||||
|
Map<BigInteger, RelationHealth> relationHealth) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防御性复制运行快照。
|
||||||
|
*/
|
||||||
|
public RuntimeSnapshot {
|
||||||
|
bindings = List.copyOf(bindings);
|
||||||
|
relations = List.copyOf(relations);
|
||||||
|
objects = Map.copyOf(objects);
|
||||||
|
connections = Map.copyOf(connections);
|
||||||
|
bindingHealth = Map.copyOf(bindingHealth);
|
||||||
|
relationHealth = Map.copyOf(relationHealth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表绑定当前健康状态。
|
||||||
|
*
|
||||||
|
* @param status ACTIVE、MISSING 或 CONNECTION_DISABLED
|
||||||
|
* @param issueCode 稳定问题码
|
||||||
|
* @param issueMessage 用户提示
|
||||||
|
*/
|
||||||
|
public record BindingHealth(String status, Integer issueCode, String issueMessage) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断表绑定是否可用。
|
||||||
|
*
|
||||||
|
* @return 是否为 ACTIVE
|
||||||
|
*/
|
||||||
|
public boolean active() {
|
||||||
|
return "ACTIVE".equals(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表关系当前健康状态。
|
||||||
|
*
|
||||||
|
* @param status ACTIVE 或 INVALID
|
||||||
|
* @param issueCode 稳定问题码
|
||||||
|
* @param issueMessage 用户提示
|
||||||
|
*/
|
||||||
|
public record RelationHealth(String status, Integer issueCode, String issueMessage) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建健康关系状态。
|
||||||
|
*
|
||||||
|
* @return ACTIVE 状态
|
||||||
|
*/
|
||||||
|
public static RelationHealth active() {
|
||||||
|
return new RelationHealth("ACTIVE", null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过校验并补全运行信息的数据空间定义。
|
||||||
|
*
|
||||||
|
* @param tables 纳管表
|
||||||
|
*/
|
||||||
|
private record ValidatedDefinition(List<ValidatedTable> tables) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已解析物理对象、连接和逻辑别名的纳管表。
|
||||||
|
*
|
||||||
|
* @param definition 表定义
|
||||||
|
* @param object 元数据对象
|
||||||
|
* @param connection 物理连接
|
||||||
|
* @param sourceAlias 数据源别名
|
||||||
|
* @param schemaAlias Schema 别名
|
||||||
|
* @param logicalTableName 数据空间内全局唯一的逻辑表名
|
||||||
|
*/
|
||||||
|
private record ValidatedTable(
|
||||||
|
DataspaceDefinition.TableDefinition definition,
|
||||||
|
DataspaceObject object,
|
||||||
|
DataspaceConnection connection,
|
||||||
|
String sourceAlias,
|
||||||
|
String schemaAlias,
|
||||||
|
String logicalTableName) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package tech.easyflow.dataspace.model;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson.JSON;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间保存请求反序列化测试。
|
||||||
|
*/
|
||||||
|
public class DataspaceDefinitionTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Fastjson 反序列化 record 时遗留的嵌套 JSON 对象会恢复为强类型定义。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldNormalizeNestedDefinitionsAfterFastjsonDeserialization() {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"id": "448492737717747712",
|
||||||
|
"expectedRevision": 1,
|
||||||
|
"name": "网点经营分析",
|
||||||
|
"description": "",
|
||||||
|
"tables": [{
|
||||||
|
"clientKey": "binding:1",
|
||||||
|
"objectId": "448679746354769926",
|
||||||
|
"sourceAlias": "MYSQL_1",
|
||||||
|
"schemaAlias": "MAIN",
|
||||||
|
"tableAlias": "outlet_main",
|
||||||
|
"positionX": 80,
|
||||||
|
"positionY": 80
|
||||||
|
}],
|
||||||
|
"relations": [{
|
||||||
|
"leftClientKey": "binding:1",
|
||||||
|
"rightClientKey": "binding:2",
|
||||||
|
"joinType": "INNER",
|
||||||
|
"leftColumn": "source_row",
|
||||||
|
"rightColumn": "source_row"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
DataspaceDefinition definition = JSON.parseObject(json, DataspaceDefinition.class);
|
||||||
|
|
||||||
|
assertEquals("outlet_main", definition.tables().get(0).tableAlias());
|
||||||
|
assertEquals("source_row", definition.relations().get(0).leftColumn());
|
||||||
|
assertTrue(definition.tables().get(0) instanceof DataspaceDefinition.TableDefinition);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package tech.easyflow.dataspace.model;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertSame;
|
||||||
|
|
||||||
|
import com.easyagents.federation.sql.api.FederationSqlErrorCode;
|
||||||
|
import com.easyagents.federation.sql.api.FederationSqlException;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.dataspace.security.DataspaceCredentialUnavailableException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间错误码映射契约测试。
|
||||||
|
*/
|
||||||
|
public class DataspaceErrorCodeTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 SQL 语法错误保留 HTTP 400 和稳定业务码。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldMapSqlParseFailure() {
|
||||||
|
BusinessException exception = DataspaceErrorCode.fromFederation(
|
||||||
|
new FederationSqlException(
|
||||||
|
FederationSqlErrorCode.SQL_PARSE_FAILED,
|
||||||
|
"Encountered token at line 1"),
|
||||||
|
false);
|
||||||
|
|
||||||
|
assertEquals(400, exception.getHttpStatus());
|
||||||
|
assertEquals(40061, exception.getErrorCode());
|
||||||
|
assertEquals(true, exception.getMessage().contains("SQL 语法解析失败"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证未知逻辑表返回可直接处理的中文提示。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldLocalizeUnknownLogicalTable() {
|
||||||
|
BusinessException exception = DataspaceErrorCode.fromFederation(
|
||||||
|
new FederationSqlException(
|
||||||
|
FederationSqlErrorCode.SQL_VALIDATION_FAILED,
|
||||||
|
"logical table is not declared in the query scope: missing_table"),
|
||||||
|
false);
|
||||||
|
|
||||||
|
assertEquals(400, exception.getHttpStatus());
|
||||||
|
assertEquals(40062, exception.getErrorCode());
|
||||||
|
assertEquals(true, exception.getMessage().contains("逻辑表 “missing_table” 不在当前数据空间中"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证数据源初始化失败映射为可恢复的 503 错误。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldMapSourceInitializationFailure() {
|
||||||
|
BusinessException exception = DataspaceErrorCode.fromFederation(
|
||||||
|
new FederationSqlException(
|
||||||
|
FederationSqlErrorCode.SOURCE_INITIALIZATION_FAILED,
|
||||||
|
"driver failure"),
|
||||||
|
false);
|
||||||
|
|
||||||
|
assertEquals(503, exception.getHttpStatus());
|
||||||
|
assertEquals(50361, exception.getErrorCode());
|
||||||
|
assertEquals(DataspaceErrorCode.SOURCE_UNAVAILABLE.defaultMessage(), exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证数据源初始化包装后仍保留凭据失效的精确恢复提示。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldPreserveCredentialUnavailableFailure() {
|
||||||
|
DataspaceCredentialUnavailableException credentialException =
|
||||||
|
new DataspaceCredentialUnavailableException(
|
||||||
|
new IllegalArgumentException("credential key mismatch"));
|
||||||
|
BusinessException exception = DataspaceErrorCode.fromFederation(
|
||||||
|
new FederationSqlException(
|
||||||
|
FederationSqlErrorCode.SOURCE_INITIALIZATION_FAILED,
|
||||||
|
"failed to initialize source",
|
||||||
|
credentialException),
|
||||||
|
false);
|
||||||
|
|
||||||
|
assertSame(credentialException, exception);
|
||||||
|
assertEquals(409, exception.getHttpStatus());
|
||||||
|
assertEquals(40905, exception.getErrorCode());
|
||||||
|
assertEquals(DataspaceCredentialUnavailableException.USER_MESSAGE,
|
||||||
|
exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证联邦资源限制映射为 422,便于页面提示缩小查询范围。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldMapFederationResourceLimit() {
|
||||||
|
BusinessException exception = DataspaceErrorCode.fromFederation(
|
||||||
|
new FederationSqlException(
|
||||||
|
FederationSqlErrorCode.FEDERATION_RESOURCE_LIMIT_EXCEEDED,
|
||||||
|
"too many rows"),
|
||||||
|
false);
|
||||||
|
|
||||||
|
assertEquals(422, exception.getHttpStatus());
|
||||||
|
assertEquals(42261, exception.getErrorCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证补全内核异常使用独立错误码,避免伪装成数据源不可用。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldMapSqlCompletionFailure() {
|
||||||
|
BusinessException exception = DataspaceErrorCode.fromFederation(
|
||||||
|
new FederationSqlException(
|
||||||
|
FederationSqlErrorCode.SQL_COMPLETION_FAILED,
|
||||||
|
"advisor failed"),
|
||||||
|
false);
|
||||||
|
|
||||||
|
assertEquals(503, exception.getHttpStatus());
|
||||||
|
assertEquals(50364, exception.getErrorCode());
|
||||||
|
assertEquals(DataspaceErrorCode.SQL_COMPLETION_FAILED.defaultMessage(),
|
||||||
|
exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 SQL 编译超时使用独立错误码和可恢复提示。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldMapSqlCompileTimeout() {
|
||||||
|
BusinessException exception = DataspaceErrorCode.fromFederation(
|
||||||
|
new FederationSqlException(
|
||||||
|
FederationSqlErrorCode.SQL_COMPILE_TIMEOUT,
|
||||||
|
"plan cache wait expired"),
|
||||||
|
false);
|
||||||
|
|
||||||
|
assertEquals(408, exception.getHttpStatus());
|
||||||
|
assertEquals(40863, exception.getErrorCode());
|
||||||
|
assertEquals(DataspaceErrorCode.SQL_COMPILE_TIMEOUT.defaultMessage(),
|
||||||
|
exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证连接池等待超时不会被泛化为数据源不可用。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldMapConnectionAcquisitionTimeout() {
|
||||||
|
BusinessException exception = DataspaceErrorCode.fromFederation(
|
||||||
|
new FederationSqlException(
|
||||||
|
FederationSqlErrorCode.CONNECTION_ACQUISITION_TIMEOUT,
|
||||||
|
"pool acquisition timeout"),
|
||||||
|
false);
|
||||||
|
|
||||||
|
assertEquals(503, exception.getHttpStatus());
|
||||||
|
assertEquals(50365, exception.getErrorCode());
|
||||||
|
assertEquals(DataspaceErrorCode.CONNECTION_ACQUISITION_TIMEOUT.defaultMessage(),
|
||||||
|
exception.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package tech.easyflow.dataspace.provider;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.junit.Assume;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本机 Docker MySQL 与 PostgreSQL 元数据接入验证。
|
||||||
|
*/
|
||||||
|
public class DataspaceDatabaseProviderIntegrationTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 MySQL data-sheet 元数据可被读取。
|
||||||
|
*
|
||||||
|
* @throws Exception JDBC 连接或元数据读取失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldInspectMysqlDataSheet() throws Exception {
|
||||||
|
Assume.assumeTrue(Boolean.getBoolean("dataspace.integration.enabled"));
|
||||||
|
MySqlProvider provider = new MySqlProvider();
|
||||||
|
DataspaceConnectionConfig config = new DataspaceConnectionConfig(
|
||||||
|
DataspaceDatabaseType.MYSQL,
|
||||||
|
System.getProperty("dataspace.mysql.host", "127.0.0.1"),
|
||||||
|
Integer.getInteger("dataspace.mysql.port", 33306),
|
||||||
|
System.getProperty("dataspace.mysql.database", "data-sheet"),
|
||||||
|
System.getProperty("dataspace.mysql.username", "root"),
|
||||||
|
System.getProperty("dataspace.mysql.password", "root"),
|
||||||
|
false,
|
||||||
|
Map.of());
|
||||||
|
|
||||||
|
try (Connection connection = DriverManager.getConnection(
|
||||||
|
provider.jdbcUrl(config), config.username(), config.password())) {
|
||||||
|
List<DataspaceObjectMetadata> objects = provider.inspect(connection, config);
|
||||||
|
assertFalse(objects.isEmpty());
|
||||||
|
assertTrue(objects.stream().anyMatch(object -> "outlet".equalsIgnoreCase(object.name())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 PostgreSQL public Schema 元数据可被读取。
|
||||||
|
*
|
||||||
|
* @throws Exception JDBC 连接或元数据读取失败
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldInspectPostgresql() throws Exception {
|
||||||
|
Assume.assumeTrue(Boolean.getBoolean("dataspace.integration.enabled"));
|
||||||
|
PostgresqlProvider provider = new PostgresqlProvider();
|
||||||
|
DataspaceConnectionConfig config = new DataspaceConnectionConfig(
|
||||||
|
DataspaceDatabaseType.POSTGRESQL,
|
||||||
|
System.getProperty("dataspace.pg.host", "127.0.0.1"),
|
||||||
|
Integer.getInteger("dataspace.pg.port", 54329),
|
||||||
|
System.getProperty("dataspace.pg.database", "harmony_adapter"),
|
||||||
|
System.getProperty("dataspace.pg.username", "harmony"),
|
||||||
|
System.getProperty("dataspace.pg.password", "harmony"),
|
||||||
|
false,
|
||||||
|
Map.of());
|
||||||
|
|
||||||
|
try (Connection connection = DriverManager.getConnection(
|
||||||
|
provider.jdbcUrl(config), config.username(), config.password())) {
|
||||||
|
List<DataspaceObjectMetadata> objects = provider.inspect(connection, config);
|
||||||
|
assertFalse(objects.isEmpty());
|
||||||
|
assertTrue(objects.stream().allMatch(object -> !object.schema().startsWith("pg_")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package tech.easyflow.dataspace.provider;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertThrows;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MySQL 与 PostgreSQL Provider 契约测试。
|
||||||
|
*/
|
||||||
|
public class DataspaceDatabaseProviderTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 MySQL URL 与固定逻辑 Schema。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldBuildMysqlDefinition() {
|
||||||
|
MySqlProvider provider = new MySqlProvider();
|
||||||
|
DataspaceConnectionConfig config = new DataspaceConnectionConfig(
|
||||||
|
DataspaceDatabaseType.MYSQL,
|
||||||
|
"127.0.0.1",
|
||||||
|
33306,
|
||||||
|
"data-sheet",
|
||||||
|
"root",
|
||||||
|
"root",
|
||||||
|
false,
|
||||||
|
Map.of());
|
||||||
|
|
||||||
|
assertEquals("MAIN", provider.logicalSchema(null));
|
||||||
|
assertEquals(
|
||||||
|
"jdbc:mysql://127.0.0.1:33306/data-sheet"
|
||||||
|
+ "?useUnicode=true&characterEncoding=utf8&useSSL=false"
|
||||||
|
+ "&serverTimezone=UTC&allowMultiQueries=false",
|
||||||
|
provider.jdbcUrl(config));
|
||||||
|
assertFalse(provider.jdbcUrl(config).contains("allowMultiQueries=true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 PostgreSQL URL、SSL 策略与 Schema 规范化。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldBuildPostgresqlDefinition() {
|
||||||
|
PostgresqlProvider provider = new PostgresqlProvider();
|
||||||
|
DataspaceConnectionConfig config = new DataspaceConnectionConfig(
|
||||||
|
DataspaceDatabaseType.POSTGRESQL,
|
||||||
|
"127.0.0.1",
|
||||||
|
54329,
|
||||||
|
"harmony_adapter",
|
||||||
|
"harmony",
|
||||||
|
"harmony",
|
||||||
|
false,
|
||||||
|
Map.of());
|
||||||
|
|
||||||
|
assertEquals("PUBLIC", provider.logicalSchema(null));
|
||||||
|
assertEquals("SALES", provider.logicalSchema("sales"));
|
||||||
|
assertEquals(
|
||||||
|
"jdbc:postgresql://127.0.0.1:54329/harmony_adapter"
|
||||||
|
+ "?sslmode=disable&ApplicationName=easyflow-dataspace",
|
||||||
|
provider.jdbcUrl(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证未知数据库类型被显式拒绝。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectUnknownDatabaseType() {
|
||||||
|
assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> DataspaceDatabaseType.parse("oracle"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package tech.easyflow.dataspace.security;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertNotEquals;
|
||||||
|
import static org.junit.Assert.assertThrows;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间凭据认证加密测试。
|
||||||
|
*/
|
||||||
|
public class DataspaceCredentialCipherTest {
|
||||||
|
|
||||||
|
private static final String MASTER_KEY = "dataspace-test-master-key-32-characters";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证同一明文使用随机 IV,并能正确解密。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRoundTripWithRandomIv() {
|
||||||
|
DataspaceCredentialCipher cipher = new DataspaceCredentialCipher(MASTER_KEY);
|
||||||
|
|
||||||
|
String first = cipher.encrypt("root");
|
||||||
|
String second = cipher.encrypt("root");
|
||||||
|
|
||||||
|
assertNotEquals(first, second);
|
||||||
|
assertEquals("root", cipher.decrypt(first));
|
||||||
|
assertEquals("root", cipher.decrypt(second));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证认证标签能够拒绝被篡改的密文。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectTamperedCipherText() {
|
||||||
|
DataspaceCredentialCipher cipher = new DataspaceCredentialCipher(MASTER_KEY);
|
||||||
|
String encrypted = cipher.encrypt("root");
|
||||||
|
char replacement = encrypted.endsWith("A") ? 'B' : 'A';
|
||||||
|
String tampered = encrypted.substring(0, encrypted.length() - 1) + replacement;
|
||||||
|
|
||||||
|
DataspaceCredentialUnavailableException exception = assertThrows(
|
||||||
|
DataspaceCredentialUnavailableException.class,
|
||||||
|
() -> cipher.decrypt(tampered));
|
||||||
|
|
||||||
|
assertEquals(409, exception.getHttpStatus());
|
||||||
|
assertEquals(DataspaceCredentialUnavailableException.ERROR_CODE, exception.getErrorCode());
|
||||||
|
assertEquals(DataspaceCredentialUnavailableException.USER_MESSAGE, exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证部署密钥变更后返回可恢复的稳定业务错误。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldDescribeCredentialRecoveryWhenMasterKeyChanges() {
|
||||||
|
DataspaceCredentialCipher original = new DataspaceCredentialCipher(MASTER_KEY);
|
||||||
|
DataspaceCredentialCipher changed = new DataspaceCredentialCipher(
|
||||||
|
"changed-dataspace-master-key-32-characters");
|
||||||
|
|
||||||
|
DataspaceCredentialUnavailableException exception = assertThrows(
|
||||||
|
DataspaceCredentialUnavailableException.class,
|
||||||
|
() -> changed.decrypt(original.encrypt("root")));
|
||||||
|
|
||||||
|
assertEquals(409, exception.getHttpStatus());
|
||||||
|
assertEquals(DataspaceCredentialUnavailableException.ERROR_CODE, exception.getErrorCode());
|
||||||
|
assertEquals(DataspaceCredentialUnavailableException.USER_MESSAGE, exception.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证部署主密钥长度下限。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectShortMasterKey() {
|
||||||
|
assertThrows(
|
||||||
|
IllegalStateException.class,
|
||||||
|
() -> new DataspaceCredentialCipher("too-short"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
package tech.easyflow.dataspace.service;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.sql.Types;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||||
|
import tech.easyflow.dataspace.entity.Dataspace;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceConnection;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceObject;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceRelation;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceRevision;
|
||||||
|
import tech.easyflow.dataspace.entity.DataspaceTableBinding;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceConnectionMapper;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceMapper;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceObjectMapper;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceRelationMapper;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceRevisionMapper;
|
||||||
|
import tech.easyflow.dataspace.mapper.DataspaceTableBindingMapper;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceErrorCode;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceView;
|
||||||
|
import tech.easyflow.dataspace.provider.DataspaceDatabaseProviderRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间历史绑定与最新元数据兼容解析测试。
|
||||||
|
*/
|
||||||
|
public class DataspaceMetadataCompatibilityTest {
|
||||||
|
|
||||||
|
private static final BigInteger SPACE_ID = BigInteger.valueOf(1L);
|
||||||
|
private static final BigInteger REVISION_ID = BigInteger.valueOf(2L);
|
||||||
|
private static final BigInteger BINDING_ID = BigInteger.valueOf(3L);
|
||||||
|
private static final BigInteger CONNECTION_ID = BigInteger.valueOf(4L);
|
||||||
|
private static final BigInteger HISTORICAL_OBJECT_ID = BigInteger.valueOf(5L);
|
||||||
|
private static final BigInteger CURRENT_OBJECT_ID = BigInteger.valueOf(6L);
|
||||||
|
private static final BigInteger SECOND_BINDING_ID = BigInteger.valueOf(7L);
|
||||||
|
private static final BigInteger SECOND_HISTORICAL_OBJECT_ID = BigInteger.valueOf(8L);
|
||||||
|
private static final BigInteger SECOND_CURRENT_OBJECT_ID = BigInteger.valueOf(9L);
|
||||||
|
private static final BigInteger RELATION_ID = BigInteger.valueOf(10L);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证逻辑表名兼容产品默认使用的小写名称和手动下划线命名。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldAcceptLowercaseLogicalTableNameWithUnderscore() {
|
||||||
|
assertTrue(DataspaceService.isIdentifier("outlet_main"));
|
||||||
|
assertTrue(DataspaceService.isIdentifier("OUTLET_2"));
|
||||||
|
assertFalse(DataspaceService.isIdentifier("2_outlet"));
|
||||||
|
assertFalse(DataspaceService.isIdentifier("outlet-name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证兼容元数据刷新后详情自动使用最新对象和字段。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldResolveHistoricalBindingToCurrentMetadata() {
|
||||||
|
Fixture fixture = fixture(List.of(currentObject()));
|
||||||
|
|
||||||
|
DataspaceView detail = fixture.service().detail(SPACE_ID);
|
||||||
|
|
||||||
|
assertEquals(1, detail.tables().size());
|
||||||
|
assertEquals(CURRENT_OBJECT_ID, detail.tables().get(0).objectId());
|
||||||
|
assertEquals("MYSQL", detail.tables().get(0).databaseType());
|
||||||
|
assertEquals("ACTIVE", detail.tables().get(0).status());
|
||||||
|
assertEquals(2, detail.tables().get(0).columns().size());
|
||||||
|
assertNull(detail.tables().get(0).issueCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证表从最新快照消失时仅返回局部失效状态,不阻断详情加载。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldExposeMissingBindingWithoutRejectingWholeDataspace() {
|
||||||
|
Fixture fixture = fixture(List.of());
|
||||||
|
|
||||||
|
DataspaceView detail = fixture.service().detail(SPACE_ID);
|
||||||
|
|
||||||
|
assertEquals(1, detail.tables().size());
|
||||||
|
assertEquals(HISTORICAL_OBJECT_ID, detail.tables().get(0).objectId());
|
||||||
|
assertEquals("MISSING", detail.tables().get(0).status());
|
||||||
|
assertEquals(
|
||||||
|
Integer.valueOf(DataspaceErrorCode.OBJECT_MISSING.code()),
|
||||||
|
detail.tables().get(0).issueCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证关联字段被删除时仅标记该关系失效,其他表仍可继续查询。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldInvalidateOnlyRelationWhenJoinColumnDisappears() {
|
||||||
|
DataspaceObject historicalRegion = object(
|
||||||
|
SECOND_HISTORICAL_OBJECT_ID,
|
||||||
|
1L,
|
||||||
|
"outlet_region",
|
||||||
|
List.of(column("outlet_id", Types.BIGINT)));
|
||||||
|
DataspaceObject currentRegion = object(
|
||||||
|
SECOND_CURRENT_OBJECT_ID,
|
||||||
|
2L,
|
||||||
|
"outlet_region",
|
||||||
|
List.of(column("region_name", Types.VARCHAR)));
|
||||||
|
Fixture fixture = fixture(
|
||||||
|
List.of(historicalObject(), historicalRegion),
|
||||||
|
List.of(binding(), secondBinding()),
|
||||||
|
List.of(relation()),
|
||||||
|
List.of(currentObject(), currentRegion));
|
||||||
|
|
||||||
|
DataspaceView detail = fixture.service().detail(SPACE_ID);
|
||||||
|
|
||||||
|
assertEquals("ACTIVE", detail.tables().get(0).status());
|
||||||
|
assertEquals("ACTIVE", detail.tables().get(1).status());
|
||||||
|
assertEquals("INVALID", detail.relations().get(0).status());
|
||||||
|
assertEquals(
|
||||||
|
Integer.valueOf(DataspaceErrorCode.RELATION_FIELD_MISSING.code()),
|
||||||
|
detail.relations().get(0).issueCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建只覆盖详情读取路径的测试夹具。
|
||||||
|
*
|
||||||
|
* @param currentObjects 最新元数据对象
|
||||||
|
* @return 测试夹具
|
||||||
|
*/
|
||||||
|
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||||
|
private Fixture fixture(List<DataspaceObject> currentObjects) {
|
||||||
|
return fixture(
|
||||||
|
List.of(historicalObject()),
|
||||||
|
List.of(binding()),
|
||||||
|
List.of(),
|
||||||
|
currentObjects);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建指定历史与当前元数据的测试夹具。
|
||||||
|
*
|
||||||
|
* @param historicalObjects revision 保存的历史对象
|
||||||
|
* @param bindings revision 表绑定
|
||||||
|
* @param relations revision 表关系
|
||||||
|
* @param currentObjects 最新元数据对象
|
||||||
|
* @return 测试夹具
|
||||||
|
*/
|
||||||
|
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||||
|
private Fixture fixture(
|
||||||
|
List<DataspaceObject> historicalObjects,
|
||||||
|
List<DataspaceTableBinding> bindings,
|
||||||
|
List<DataspaceRelation> relations,
|
||||||
|
List<DataspaceObject> currentObjects) {
|
||||||
|
DataspaceMapper dataspaceMapper = mock(DataspaceMapper.class);
|
||||||
|
DataspaceRevisionMapper revisionMapper = mock(DataspaceRevisionMapper.class);
|
||||||
|
DataspaceTableBindingMapper bindingMapper = mock(DataspaceTableBindingMapper.class);
|
||||||
|
DataspaceRelationMapper relationMapper = mock(DataspaceRelationMapper.class);
|
||||||
|
DataspaceObjectMapper objectMapper = mock(DataspaceObjectMapper.class);
|
||||||
|
DataspaceConnectionMapper connectionMapper = mock(DataspaceConnectionMapper.class);
|
||||||
|
|
||||||
|
when(dataspaceMapper.selectOneById(SPACE_ID)).thenReturn(dataspace());
|
||||||
|
when(revisionMapper.selectOneByQuery(any(QueryWrapper.class))).thenReturn(revision());
|
||||||
|
when(bindingMapper.selectListByQuery(any(QueryWrapper.class)))
|
||||||
|
.thenReturn(bindings);
|
||||||
|
when(relationMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(relations);
|
||||||
|
// 首次读取不可变历史对象,随后由解析器读取连接当前快照。
|
||||||
|
when(objectMapper.selectListByQuery(any(QueryWrapper.class)))
|
||||||
|
.thenReturn(historicalObjects, currentObjects);
|
||||||
|
when(connectionMapper.selectListByQuery(any(QueryWrapper.class)))
|
||||||
|
.thenReturn(List.of(connection()));
|
||||||
|
|
||||||
|
DataspaceMetadataResolver resolver = new DataspaceMetadataResolver(objectMapper);
|
||||||
|
DataspaceService service = new DataspaceService(
|
||||||
|
dataspaceMapper,
|
||||||
|
revisionMapper,
|
||||||
|
bindingMapper,
|
||||||
|
relationMapper,
|
||||||
|
objectMapper,
|
||||||
|
connectionMapper,
|
||||||
|
mock(DataspaceDatabaseProviderRegistry.class),
|
||||||
|
resolver,
|
||||||
|
mock(RedisLockExecutor.class),
|
||||||
|
mock(org.springframework.transaction.support.TransactionTemplate.class));
|
||||||
|
return new Fixture(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 数据空间记录 */
|
||||||
|
private Dataspace dataspace() {
|
||||||
|
Dataspace value = new Dataspace();
|
||||||
|
value.setId(SPACE_ID);
|
||||||
|
value.setName("网点经营分析");
|
||||||
|
value.setDescription("");
|
||||||
|
value.setCurrentRevision(1L);
|
||||||
|
value.setStatus("ENABLED");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 当前 revision */
|
||||||
|
private DataspaceRevision revision() {
|
||||||
|
DataspaceRevision value = new DataspaceRevision();
|
||||||
|
value.setId(REVISION_ID);
|
||||||
|
value.setDataspaceId(SPACE_ID);
|
||||||
|
value.setRevisionNo(1L);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 历史表绑定 */
|
||||||
|
private DataspaceTableBinding binding() {
|
||||||
|
DataspaceTableBinding value = new DataspaceTableBinding();
|
||||||
|
value.setId(BINDING_ID);
|
||||||
|
value.setDataspaceId(SPACE_ID);
|
||||||
|
value.setRevisionId(REVISION_ID);
|
||||||
|
value.setConnectionId(CONNECTION_ID);
|
||||||
|
value.setSourceRevision(1L);
|
||||||
|
value.setObjectId(HISTORICAL_OBJECT_ID);
|
||||||
|
value.setSourceAlias("MYSQL_1");
|
||||||
|
value.setSchemaAlias("MAIN");
|
||||||
|
value.setTableAlias("outlet");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 第二张历史表绑定 */
|
||||||
|
private DataspaceTableBinding secondBinding() {
|
||||||
|
DataspaceTableBinding value = binding();
|
||||||
|
value.setId(SECOND_BINDING_ID);
|
||||||
|
value.setObjectId(SECOND_HISTORICAL_OBJECT_ID);
|
||||||
|
value.setTableAlias("outlet_region");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 依赖已删除字段的表关系 */
|
||||||
|
private DataspaceRelation relation() {
|
||||||
|
DataspaceRelation value = new DataspaceRelation();
|
||||||
|
value.setId(RELATION_ID);
|
||||||
|
value.setDataspaceId(SPACE_ID);
|
||||||
|
value.setRevisionId(REVISION_ID);
|
||||||
|
value.setLeftBindingId(BINDING_ID);
|
||||||
|
value.setRightBindingId(SECOND_BINDING_ID);
|
||||||
|
value.setJoinType("INNER");
|
||||||
|
value.setLeftColumn("id");
|
||||||
|
value.setRightColumn("outlet_id");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 当前物理连接 */
|
||||||
|
private DataspaceConnection connection() {
|
||||||
|
DataspaceConnection value = new DataspaceConnection();
|
||||||
|
value.setId(CONNECTION_ID);
|
||||||
|
value.setName("销售主库");
|
||||||
|
value.setDatabaseType("MYSQL");
|
||||||
|
value.setDefinitionRevision(2L);
|
||||||
|
value.setStatus("ENABLED");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 历史元数据对象 */
|
||||||
|
private DataspaceObject historicalObject() {
|
||||||
|
return object(
|
||||||
|
HISTORICAL_OBJECT_ID,
|
||||||
|
1L,
|
||||||
|
"outlet",
|
||||||
|
List.of(column("id", Types.BIGINT)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 最新元数据对象 */
|
||||||
|
private DataspaceObject currentObject() {
|
||||||
|
return object(
|
||||||
|
CURRENT_OBJECT_ID,
|
||||||
|
2L,
|
||||||
|
"outlet",
|
||||||
|
List.of(column("id", Types.BIGINT), column("name", Types.VARCHAR)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建元数据对象。
|
||||||
|
*
|
||||||
|
* @param id 对象 ID
|
||||||
|
* @param revision 元数据 revision
|
||||||
|
* @param objectName 对象名
|
||||||
|
* @param columns 字段
|
||||||
|
* @return 元数据对象
|
||||||
|
*/
|
||||||
|
private DataspaceObject object(
|
||||||
|
BigInteger id,
|
||||||
|
long revision,
|
||||||
|
String objectName,
|
||||||
|
List<Map<String, Object>> columns) {
|
||||||
|
DataspaceObject value = new DataspaceObject();
|
||||||
|
value.setId(id);
|
||||||
|
value.setConnectionId(CONNECTION_ID);
|
||||||
|
value.setCatalogName("data-sheet");
|
||||||
|
value.setSchemaName(null);
|
||||||
|
value.setObjectName(objectName);
|
||||||
|
value.setObjectType("TABLE");
|
||||||
|
value.setColumnsJson(columns);
|
||||||
|
value.setMetadataRevision(revision);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建字段元数据,并保留空备注验证兼容复制。
|
||||||
|
*
|
||||||
|
* @param name 字段名
|
||||||
|
* @param jdbcType JDBC 类型
|
||||||
|
* @return 字段元数据
|
||||||
|
*/
|
||||||
|
private Map<String, Object> column(String name, int jdbcType) {
|
||||||
|
Map<String, Object> value = new LinkedHashMap<>();
|
||||||
|
value.put("name", name);
|
||||||
|
value.put("jdbcType", jdbcType);
|
||||||
|
value.put("typeName", jdbcType == Types.BIGINT ? "BIGINT" : "VARCHAR");
|
||||||
|
value.put("remarks", null);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试夹具。
|
||||||
|
*
|
||||||
|
* @param service 数据空间服务
|
||||||
|
*/
|
||||||
|
private record Fixture(DataspaceService service) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package tech.easyflow.dataspace.service;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceErrorCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间查询结果字节预算测试。
|
||||||
|
*/
|
||||||
|
public class DataspaceQueryResultBudgetTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证多个合法单元格累计超过结果上限时返回稳定业务错误。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectAccumulatedResultBytes() {
|
||||||
|
DataspaceQueryResultBudget budget = new DataspaceQueryResultBudget(24L, 16L);
|
||||||
|
budget.beginRow(2);
|
||||||
|
budget.reserveCell(budget.validateText("12345678"));
|
||||||
|
|
||||||
|
BusinessException exception = expectLimitExceeded(() ->
|
||||||
|
budget.reserveCell(budget.validateText("abcdefgh")));
|
||||||
|
|
||||||
|
Assert.assertTrue(exception.getMessage().contains("查询结果数据"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证文本按 UTF-8 字节数校验,避免多字节字符绕过单字段上限。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectUtf8TextCellOverLimit() {
|
||||||
|
DataspaceQueryResultBudget budget = new DataspaceQueryResultBudget(128L, 4L);
|
||||||
|
|
||||||
|
BusinessException exception = expectLimitExceeded(() ->
|
||||||
|
budget.validateText("中文"));
|
||||||
|
|
||||||
|
Assert.assertTrue(exception.getMessage().contains("文本字段"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证二进制字段在 Base64 编码前按原始大小拦截。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRejectBinaryCellOverLimit() {
|
||||||
|
DataspaceQueryResultBudget budget = new DataspaceQueryResultBudget(128L, 4L);
|
||||||
|
|
||||||
|
BusinessException exception = expectLimitExceeded(() ->
|
||||||
|
budget.validateBinary(new byte[5]));
|
||||||
|
|
||||||
|
Assert.assertTrue(exception.getMessage().contains("二进制字段"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 JSON 转义字符计入总结果字节预算。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldAccountForJsonEscaping() {
|
||||||
|
DataspaceQueryResultBudget budget = new DataspaceQueryResultBudget(9L, 8L);
|
||||||
|
budget.beginRow(1);
|
||||||
|
|
||||||
|
expectLimitExceeded(() -> budget.reserveCell(budget.validateText("\"\n")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行操作并断言返回查询上限业务错误。
|
||||||
|
*
|
||||||
|
* @param action 待执行操作
|
||||||
|
* @return 捕获的业务异常
|
||||||
|
*/
|
||||||
|
private BusinessException expectLimitExceeded(Runnable action) {
|
||||||
|
try {
|
||||||
|
action.run();
|
||||||
|
Assert.fail("expected query result limit exception");
|
||||||
|
return null;
|
||||||
|
} catch (BusinessException exception) {
|
||||||
|
Assert.assertEquals(
|
||||||
|
DataspaceErrorCode.QUERY_LIMIT_EXCEEDED.code(),
|
||||||
|
exception.getErrorCode());
|
||||||
|
return exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package tech.easyflow.dataspace.service;
|
||||||
|
|
||||||
|
import com.easyagents.federation.sql.compile.FederationFragmentExplain;
|
||||||
|
import com.easyagents.federation.sql.compile.SqlExplainLevel;
|
||||||
|
import com.easyagents.federation.sql.compile.SqlExplainResult;
|
||||||
|
import com.easyagents.federation.sql.execute.FederationPhysicalExplain;
|
||||||
|
import com.easyagents.federation.sql.federation.FederationCostEstimate;
|
||||||
|
import com.easyagents.federation.sql.federation.FederationJoinAlgorithm;
|
||||||
|
import com.easyagents.federation.sql.federation.FederationJoinOptimization;
|
||||||
|
import com.easyagents.federation.sql.federation.FederationJoinSelectionReason;
|
||||||
|
import com.easyagents.federation.sql.federation.FederationQueryMode;
|
||||||
|
import com.easyagents.federation.sql.federation.FederationStatisticsStatus;
|
||||||
|
import com.easyagents.federation.sql.source.SourceId;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.dataspace.model.DataspaceExplainResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据空间 Explain API 视图映射测试。
|
||||||
|
*/
|
||||||
|
public class DataspaceQueryServiceExplainTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证聚合成本、Join 决策和分片统计完整映射到 API 视图。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldMapFederationExplainDetails() {
|
||||||
|
Instant collectedAt = Instant.parse("2026-08-23T08:00:00Z");
|
||||||
|
FederationCostEstimate cost = new FederationCostEstimate(
|
||||||
|
120D, 64L, 7_680D, "catalog", "stats-7", collectedAt,
|
||||||
|
false, FederationStatisticsStatus.COMPLETE);
|
||||||
|
FederationPhysicalExplain physical = new FederationPhysicalExplain(
|
||||||
|
true, "Index Scan", "INDEX_SCAN", "INDEX", List.of("idx_region"),
|
||||||
|
"idx_region", 120L, null, "");
|
||||||
|
FederationFragmentExplain fragment = new FederationFragmentExplain(
|
||||||
|
"fragment-1", "REGION", new SourceId("region-source"), "jdbc",
|
||||||
|
"SELECT * FROM region", List.of(), List.of(), cost,
|
||||||
|
List.of("TableScan", "Filter"), physical);
|
||||||
|
FederationJoinOptimization join = new FederationJoinOptimization(
|
||||||
|
1, List.of("SALES"), List.of("REGION"), "REGION",
|
||||||
|
FederationJoinAlgorithm.HASH_JOIN,
|
||||||
|
FederationJoinSelectionReason.SMALLER_BUILD_SIDE, 7_680D);
|
||||||
|
SqlExplainResult explain = new SqlExplainResult(
|
||||||
|
SqlExplainLevel.PHYSICAL, FederationQueryMode.FEDERATED,
|
||||||
|
FederationStatisticsStatus.COMPLETE, true, 12_400D, 3_100L,
|
||||||
|
List.of(join), "SELECT * FROM sales JOIN region", "",
|
||||||
|
"logical", "execution", List.of(fragment),
|
||||||
|
Set.of(new SourceId("region-source")), null, true, false, "");
|
||||||
|
|
||||||
|
DataspaceExplainResult view = DataspaceQueryService.explainView(explain);
|
||||||
|
|
||||||
|
Assert.assertEquals("FEDERATED", view.queryMode());
|
||||||
|
Assert.assertEquals("COMPLETE", view.statisticsStatus());
|
||||||
|
Assert.assertTrue(view.estimateAvailable());
|
||||||
|
Assert.assertEquals(12_400D, view.estimatedTransferBytes(), 0D);
|
||||||
|
Assert.assertEquals(3_100L, view.estimatedLocalMemoryBytes());
|
||||||
|
Assert.assertEquals(1, view.joins().get(0).stageIndex());
|
||||||
|
Assert.assertEquals(List.of("SALES"), view.joins().get(0).leftSources());
|
||||||
|
Assert.assertEquals(List.of("REGION"), view.joins().get(0).rightSources());
|
||||||
|
Assert.assertEquals("REGION", view.joins().get(0).buildSource());
|
||||||
|
Assert.assertEquals("INDEX", view.fragments().get(0).scanType());
|
||||||
|
Assert.assertEquals("idx_region", view.fragments().get(0).chosenIndex());
|
||||||
|
Assert.assertEquals(collectedAt.toString(),
|
||||||
|
view.fragments().get(0).statisticsCollectedAt());
|
||||||
|
Assert.assertEquals(List.of("TableScan", "Filter"),
|
||||||
|
view.fragments().get(0).pushedDownOperators());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证未知估算保持显式不可用,避免页面把零值展示成精确结果。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldPreserveUnavailableEstimate() {
|
||||||
|
FederationCostEstimate unavailable = new FederationCostEstimate(
|
||||||
|
0D, 0L, 0D, "default", "none", Instant.EPOCH,
|
||||||
|
true, FederationStatisticsStatus.MISSING, false);
|
||||||
|
FederationFragmentExplain fragment = new FederationFragmentExplain(
|
||||||
|
"fragment-1", "SALES", new SourceId("sales-source"), "jdbc",
|
||||||
|
"SELECT 1", List.of(), List.of(), unavailable, List.of(), null);
|
||||||
|
SqlExplainResult explain = new SqlExplainResult(
|
||||||
|
SqlExplainLevel.LOGICAL, FederationQueryMode.SINGLE_SOURCE,
|
||||||
|
FederationStatisticsStatus.MISSING, false, 0D, 0L, List.of(),
|
||||||
|
"SELECT 1", "SELECT 1", "logical", "execution", List.of(fragment),
|
||||||
|
Set.of(new SourceId("sales-source")), null, true, false, "");
|
||||||
|
|
||||||
|
DataspaceExplainResult view = DataspaceQueryService.explainView(explain);
|
||||||
|
|
||||||
|
Assert.assertFalse(view.estimateAvailable());
|
||||||
|
Assert.assertFalse(view.fragments().get(0).estimateAvailable());
|
||||||
|
Assert.assertNull(view.fragments().get(0).statisticsCollectedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
<module>easyflow-module-skill</module>
|
<module>easyflow-module-skill</module>
|
||||||
<module>easyflow-module-job</module>
|
<module>easyflow-module-job</module>
|
||||||
<module>easyflow-module-datacenter</module>
|
<module>easyflow-module-datacenter</module>
|
||||||
|
<module>easyflow-module-dataspace</module>
|
||||||
</modules>
|
</modules>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -60,6 +60,10 @@
|
|||||||
<groupId>tech.easyflow</groupId>
|
<groupId>tech.easyflow</groupId>
|
||||||
<artifactId>easyflow-module-skill</artifactId>
|
<artifactId>easyflow-module-skill</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>tech.easyflow</groupId>
|
||||||
|
<artifactId>easyflow-module-dataspace</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>tech.easyflow</groupId>
|
<groupId>tech.easyflow</groupId>
|
||||||
<artifactId>easyflow-module-auth</artifactId>
|
<artifactId>easyflow-module-auth</artifactId>
|
||||||
|
|||||||
@@ -4,8 +4,16 @@ import com.mybatisflex.core.FlexGlobalConfig;
|
|||||||
import com.mybatisflex.core.audit.AuditManager;
|
import com.mybatisflex.core.audit.AuditManager;
|
||||||
import com.mybatisflex.core.audit.ConsoleMessageCollector;
|
import com.mybatisflex.core.audit.ConsoleMessageCollector;
|
||||||
import com.mybatisflex.core.audit.MessageCollector;
|
import com.mybatisflex.core.audit.MessageCollector;
|
||||||
|
import com.mybatisflex.core.tenant.TenantManager;
|
||||||
import com.mybatisflex.spring.boot.MyBatisFlexCustomizer;
|
import com.mybatisflex.spring.boot.MyBatisFlexCustomizer;
|
||||||
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
|
import cn.dev33.satoken.exception.NotWebContextException;
|
||||||
|
import cn.dev33.satoken.exception.SaTokenContextException;
|
||||||
|
import cn.dev33.satoken.context.SaHolder;
|
||||||
|
import java.math.BigInteger;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class MybatisConfig implements MyBatisFlexCustomizer {
|
public class MybatisConfig implements MyBatisFlexCustomizer {
|
||||||
@@ -20,6 +28,9 @@ public class MybatisConfig implements MyBatisFlexCustomizer {
|
|||||||
flexGlobalConfig.setNormalValueOfLogicDelete(0);
|
flexGlobalConfig.setNormalValueOfLogicDelete(0);
|
||||||
flexGlobalConfig.setDeletedValueOfLogicDelete(1);
|
flexGlobalConfig.setDeletedValueOfLogicDelete(1);
|
||||||
|
|
||||||
|
// 登录请求自动追加 tenant_id 条件;系统任务没有登录上下文时保持显式全局语义。
|
||||||
|
TenantManager.setTenantFactory(this::currentTenantIds);
|
||||||
|
|
||||||
//取消控制台的 Banner 打印
|
//取消控制台的 Banner 打印
|
||||||
flexGlobalConfig.setPrintBanner(false);
|
flexGlobalConfig.setPrintBanner(false);
|
||||||
|
|
||||||
@@ -27,4 +38,24 @@ public class MybatisConfig implements MyBatisFlexCustomizer {
|
|||||||
MessageCollector collector = new ConsoleMessageCollector();
|
MessageCollector collector = new ConsoleMessageCollector();
|
||||||
AuditManager.setMessageCollector(collector);
|
AuditManager.setMessageCollector(collector);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回当前请求的租户条件;启动与调度线程没有 Sa-Token 上下文时保留系统级查询语义。
|
||||||
|
*
|
||||||
|
* @return 当前租户 ID 数组;没有请求登录上下文时返回 {@code null}
|
||||||
|
*/
|
||||||
|
private Object[] currentTenantIds() {
|
||||||
|
try {
|
||||||
|
if (!SaHolder.getContext().isValid() || !StpUtil.isLogin()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
|
BigInteger tenantId = account == null || account.getTenantId() == null
|
||||||
|
? BigInteger.ZERO : account.getTenantId();
|
||||||
|
return new Object[]{tenantId};
|
||||||
|
} catch (NotWebContextException | SaTokenContextException ignored) {
|
||||||
|
// Bean 初始化与调度线程没有 Web 上下文,必须允许显式系统任务读取全局状态。
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE `tb_dataspace_connection` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL COMMENT '主键',
|
||||||
|
`tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
|
||||||
|
`name` VARCHAR(100) NOT NULL COMMENT '连接名称',
|
||||||
|
`database_type` VARCHAR(32) NOT NULL COMMENT '数据库类型',
|
||||||
|
`host` VARCHAR(255) NOT NULL COMMENT '主机地址',
|
||||||
|
`port` INT NOT NULL COMMENT '端口',
|
||||||
|
`database_name` VARCHAR(128) NOT NULL COMMENT '数据库名',
|
||||||
|
`username` VARCHAR(128) NOT NULL COMMENT '用户名',
|
||||||
|
`credential_cipher` TEXT NOT NULL COMMENT '凭据密文',
|
||||||
|
`ssl_enabled` TINYINT NOT NULL DEFAULT 0 COMMENT '是否启用SSL',
|
||||||
|
`driver_class_name` VARCHAR(255) NOT NULL COMMENT 'JDBC Driver类名',
|
||||||
|
`options_json` JSON NULL COMMENT '连接扩展选项',
|
||||||
|
`definition_revision` BIGINT NOT NULL COMMENT 'Federation Definition revision',
|
||||||
|
`definition_checksum` CHAR(64) NULL COMMENT 'Definition SHA-256',
|
||||||
|
`status` VARCHAR(24) NOT NULL DEFAULT 'ENABLED' COMMENT '连接状态',
|
||||||
|
`last_test_status` VARCHAR(24) NULL COMMENT '最近测试状态',
|
||||||
|
`last_test_message` VARCHAR(500) NULL COMMENT '最近测试信息',
|
||||||
|
`database_product` VARCHAR(128) NULL COMMENT '数据库产品',
|
||||||
|
`database_version` VARCHAR(128) NULL COMMENT '数据库版本',
|
||||||
|
`driver_name` VARCHAR(255) NULL COMMENT '驱动名称',
|
||||||
|
`driver_version` VARCHAR(128) NULL COMMENT '驱动版本',
|
||||||
|
`last_tested_at` DATETIME NULL COMMENT '最近测试时间',
|
||||||
|
`metadata_refreshed_at` DATETIME NULL COMMENT '元数据刷新时间',
|
||||||
|
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
|
||||||
|
`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
|
||||||
|
`modified_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '修改人',
|
||||||
|
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_dataspace_connection_tenant_name` (`tenant_id`, `name`, `is_deleted`),
|
||||||
|
KEY `idx_dataspace_connection_tenant_status` (`tenant_id`, `status`, `is_deleted`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='数据空间物理数据库连接';
|
||||||
|
|
||||||
|
CREATE TABLE `tb_dataspace_object` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL COMMENT '主键',
|
||||||
|
`tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
|
||||||
|
`connection_id` BIGINT UNSIGNED NOT NULL COMMENT '连接ID',
|
||||||
|
`catalog_name` VARCHAR(128) NULL COMMENT '物理Catalog',
|
||||||
|
`schema_name` VARCHAR(128) NULL COMMENT '物理Schema',
|
||||||
|
`object_name` VARCHAR(128) NOT NULL COMMENT '表或视图名称',
|
||||||
|
`object_type` VARCHAR(32) NOT NULL COMMENT '对象类型',
|
||||||
|
`remarks` VARCHAR(500) NULL COMMENT '对象说明',
|
||||||
|
`columns_json` JSON NOT NULL COMMENT '字段元数据快照',
|
||||||
|
`primary_keys_json` JSON NOT NULL COMMENT '主键字段快照',
|
||||||
|
`metadata_revision` BIGINT NOT NULL COMMENT '所属连接Definition revision',
|
||||||
|
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
|
||||||
|
`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
|
||||||
|
`modified_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '修改人',
|
||||||
|
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_dataspace_object_snapshot` (`tenant_id`, `connection_id`, `metadata_revision`, `schema_name`, `object_name`),
|
||||||
|
KEY `idx_dataspace_object_search` (`tenant_id`, `connection_id`, `object_name`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='数据空间版本化数据库对象元数据';
|
||||||
|
|
||||||
|
CREATE TABLE `tb_dataspace` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL COMMENT '主键',
|
||||||
|
`tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
|
||||||
|
`name` VARCHAR(100) NOT NULL COMMENT '数据空间名称',
|
||||||
|
`description` VARCHAR(500) NULL COMMENT '说明',
|
||||||
|
`current_revision` BIGINT NOT NULL DEFAULT 0 COMMENT '当前生效revision',
|
||||||
|
`status` VARCHAR(24) NOT NULL DEFAULT 'ENABLED' COMMENT '状态',
|
||||||
|
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
|
||||||
|
`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
|
||||||
|
`modified_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '修改人',
|
||||||
|
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_dataspace_tenant_name` (`tenant_id`, `name`, `is_deleted`),
|
||||||
|
KEY `idx_dataspace_tenant_status` (`tenant_id`, `status`, `is_deleted`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Agent隔离查询使用的虚拟数据空间';
|
||||||
|
|
||||||
|
CREATE TABLE `tb_dataspace_revision` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL COMMENT '主键',
|
||||||
|
`tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
|
||||||
|
`dataspace_id` BIGINT UNSIGNED NOT NULL COMMENT '数据空间ID',
|
||||||
|
`revision_no` BIGINT NOT NULL COMMENT '不可变revision',
|
||||||
|
`snapshot_checksum` CHAR(64) NOT NULL COMMENT '快照SHA-256',
|
||||||
|
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
|
||||||
|
`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
|
||||||
|
`modified_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '修改人',
|
||||||
|
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_dataspace_revision` (`tenant_id`, `dataspace_id`, `revision_no`),
|
||||||
|
KEY `idx_dataspace_revision_lookup` (`tenant_id`, `dataspace_id`, `revision_no`, `id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='数据空间不可变revision';
|
||||||
|
|
||||||
|
CREATE TABLE `tb_dataspace_table_binding` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL COMMENT '主键',
|
||||||
|
`tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
|
||||||
|
`dataspace_id` BIGINT UNSIGNED NOT NULL COMMENT '数据空间ID',
|
||||||
|
`revision_id` BIGINT UNSIGNED NOT NULL COMMENT 'revision ID',
|
||||||
|
`connection_id` BIGINT UNSIGNED NOT NULL COMMENT '物理连接ID',
|
||||||
|
`source_revision` BIGINT NOT NULL COMMENT '绑定时物理源revision',
|
||||||
|
`object_id` BIGINT UNSIGNED NOT NULL COMMENT '版本化元数据对象ID',
|
||||||
|
`source_alias` VARCHAR(64) NOT NULL COMMENT 'Calcite数据源别名',
|
||||||
|
`schema_alias` VARCHAR(64) NOT NULL COMMENT 'Calcite Schema别名',
|
||||||
|
`table_alias` VARCHAR(128) NOT NULL COMMENT 'Calcite表名',
|
||||||
|
`position_x` INT NULL COMMENT '画布横坐标',
|
||||||
|
`position_y` INT NULL COMMENT '画布纵坐标',
|
||||||
|
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
|
||||||
|
`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
|
||||||
|
`modified_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '修改人',
|
||||||
|
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_dataspace_binding_object` (`tenant_id`, `revision_id`, `object_id`),
|
||||||
|
KEY `idx_dataspace_binding_revision` (`tenant_id`, `revision_id`, `id`),
|
||||||
|
KEY `idx_dataspace_binding_connection` (`tenant_id`, `connection_id`, `revision_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='数据空间revision表绑定';
|
||||||
|
|
||||||
|
CREATE TABLE `tb_dataspace_relation` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL COMMENT '主键',
|
||||||
|
`tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
|
||||||
|
`dataspace_id` BIGINT UNSIGNED NOT NULL COMMENT '数据空间ID',
|
||||||
|
`revision_id` BIGINT UNSIGNED NOT NULL COMMENT 'revision ID',
|
||||||
|
`left_binding_id` BIGINT UNSIGNED NOT NULL COMMENT '左表绑定ID',
|
||||||
|
`right_binding_id` BIGINT UNSIGNED NOT NULL COMMENT '右表绑定ID',
|
||||||
|
`join_type` VARCHAR(16) NOT NULL COMMENT 'Join类型',
|
||||||
|
`left_column` VARCHAR(128) NOT NULL COMMENT '左字段',
|
||||||
|
`right_column` VARCHAR(128) NOT NULL COMMENT '右字段',
|
||||||
|
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
|
||||||
|
`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
|
||||||
|
`modified_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '修改人',
|
||||||
|
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_dataspace_relation_revision` (`tenant_id`, `revision_id`, `id`),
|
||||||
|
KEY `idx_dataspace_relation_left` (`tenant_id`, `left_binding_id`),
|
||||||
|
KEY `idx_dataspace_relation_right` (`tenant_id`, `right_binding_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='数据空间revision表关联';
|
||||||
|
|
||||||
|
CREATE TABLE `tb_dataspace_query_audit` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL COMMENT '主键',
|
||||||
|
`tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
|
||||||
|
`dataspace_id` BIGINT UNSIGNED NOT NULL COMMENT '数据空间ID',
|
||||||
|
`revision_no` BIGINT NOT NULL COMMENT '查询使用的revision',
|
||||||
|
`query_id` VARCHAR(64) NOT NULL COMMENT '查询ID',
|
||||||
|
`query_mode` VARCHAR(32) NULL COMMENT '单源或联邦模式',
|
||||||
|
`sql_digest` CHAR(64) NOT NULL COMMENT 'SQL SHA-256',
|
||||||
|
`sql_text` MEDIUMTEXT NULL COMMENT '有界SQL文本',
|
||||||
|
`status` VARCHAR(24) NOT NULL COMMENT '查询终态',
|
||||||
|
`returned_rows` BIGINT NOT NULL DEFAULT 0 COMMENT '返回行数',
|
||||||
|
`intermediate_rows` BIGINT NOT NULL DEFAULT 0 COMMENT '中间结果行数',
|
||||||
|
`duration_millis` BIGINT NOT NULL DEFAULT 0 COMMENT '总耗时毫秒',
|
||||||
|
`error_code` VARCHAR(64) NULL COMMENT '错误码',
|
||||||
|
`error_message` VARCHAR(500) NULL COMMENT '脱敏错误信息',
|
||||||
|
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
|
||||||
|
`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
|
||||||
|
`modified_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '修改人',
|
||||||
|
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_dataspace_query_id` (`tenant_id`, `query_id`),
|
||||||
|
KEY `idx_dataspace_query_audit` (`tenant_id`, `dataspace_id`, `created`, `id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='数据空间查询审计';
|
||||||
|
|
||||||
|
INSERT INTO `tb_sys_menu` (
|
||||||
|
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
|
||||||
|
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
368100000000000001, 0, 0, 'menus.ai.dataspace', '/dataspace', '/dataspace/DataspaceWorkspace', 'lucide:database-zap',
|
||||||
|
1, '', 54, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '统一数据空间与联邦SQL工作台'
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM `tb_sys_menu` WHERE `id` = 368100000000000001);
|
||||||
|
|
||||||
|
INSERT INTO `tb_sys_menu` (`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`, `is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`) VALUES
|
||||||
|
(368100000000000011, 368100000000000001, 1, '连接查询', '', '', '', 0, '/api/v1/dataspaceConnection/query', 1, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间连接查询'),
|
||||||
|
(368100000000000012, 368100000000000001, 1, '连接保存', '', '', '', 0, '/api/v1/dataspaceConnection/save', 2, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间连接保存'),
|
||||||
|
(368100000000000013, 368100000000000001, 1, '连接删除', '', '', '', 0, '/api/v1/dataspaceConnection/remove', 3, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间连接删除'),
|
||||||
|
(368100000000000014, 368100000000000001, 1, '连接测试', '', '', '', 0, '/api/v1/dataspaceConnection/test', 4, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间连接测试'),
|
||||||
|
(368100000000000015, 368100000000000001, 1, '元数据刷新', '', '', '', 0, '/api/v1/dataspaceConnection/metadata', 5, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间元数据刷新'),
|
||||||
|
(368100000000000021, 368100000000000001, 1, '空间查询', '', '', '', 0, '/api/v1/dataspace/query', 11, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间列表查询'),
|
||||||
|
(368100000000000022, 368100000000000001, 1, '空间详情', '', '', '', 0, '/api/v1/dataspace/detail', 12, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间详情'),
|
||||||
|
(368100000000000023, 368100000000000001, 1, '空间保存', '', '', '', 0, '/api/v1/dataspace/save', 13, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间保存'),
|
||||||
|
(368100000000000024, 368100000000000001, 1, '空间删除', '', '', '', 0, '/api/v1/dataspace/remove', 14, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间删除'),
|
||||||
|
(368100000000000031, 368100000000000001, 1, 'SQL查询', '', '', '', 0, '/api/v1/dataspaceSql/query', 21, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间SQL查询'),
|
||||||
|
(368100000000000032, 368100000000000001, 1, 'SQL Explain', '', '', '', 0, '/api/v1/dataspaceSql/explain', 22, 0, '2026-08-22 12:00:00', 1, '2026-08-22 12:00:00', 1, '数据空间SQL Explain');
|
||||||
|
|
||||||
|
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`) VALUES
|
||||||
|
(368100000000000101, 1, 368100000000000001),
|
||||||
|
(368100000000000111, 1, 368100000000000011),
|
||||||
|
(368100000000000112, 1, 368100000000000012),
|
||||||
|
(368100000000000113, 1, 368100000000000013),
|
||||||
|
(368100000000000114, 1, 368100000000000014),
|
||||||
|
(368100000000000115, 1, 368100000000000015),
|
||||||
|
(368100000000000121, 1, 368100000000000021),
|
||||||
|
(368100000000000122, 1, 368100000000000022),
|
||||||
|
(368100000000000123, 1, 368100000000000023),
|
||||||
|
(368100000000000124, 1, 368100000000000024),
|
||||||
|
(368100000000000131, 1, 368100000000000031),
|
||||||
|
(368100000000000132, 1, 368100000000000032);
|
||||||
12
pom.xml
12
pom.xml
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<revision>1.2.0-RC</revision>
|
<revision>1.2.0-RC</revision>
|
||||||
<easy-agents.version>1.1.0</easy-agents.version>
|
<easy-agents.version>1.2.0-RC</easy-agents.version>
|
||||||
<java.versionm>17</java.versionm>
|
<java.versionm>17</java.versionm>
|
||||||
<maven.compiler.source>17</maven.compiler.source>
|
<maven.compiler.source>17</maven.compiler.source>
|
||||||
<maven.compiler.target>17</maven.compiler.target>
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
@@ -224,6 +224,11 @@
|
|||||||
<artifactId>easy-agents-skill</artifactId>
|
<artifactId>easy-agents-skill</artifactId>
|
||||||
<version>${easy-agents.version}</version>
|
<version>${easy-agents.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.easyagents</groupId>
|
||||||
|
<artifactId>easy-agents-federation-sql-adapter-jdbc</artifactId>
|
||||||
|
<version>${easy-agents.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.squareup.okhttp3</groupId>
|
<groupId>com.squareup.okhttp3</groupId>
|
||||||
@@ -520,6 +525,11 @@
|
|||||||
<artifactId>easyflow-module-datacenter</artifactId>
|
<artifactId>easyflow-module-datacenter</artifactId>
|
||||||
<version>${revision}</version>
|
<version>${revision}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>tech.easyflow</groupId>
|
||||||
|
<artifactId>easyflow-module-dataspace</artifactId>
|
||||||
|
<version>${revision}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- starter -->
|
<!-- starter -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
Reference in New Issue
Block a user