feat: 新增统一数据空间与联邦查询能力
- 提供数据连接、元数据、逻辑表与关联编排能力 - 接入联邦查询、执行分析、统计估算和 SQL 补全接口 - 增加查询预算、凭据保护、租户隔离和 V61 初始化迁移
This commit is contained in:
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user