feat: 新增统一数据空间与联邦查询能力

- 提供数据连接、元数据、逻辑表与关联编排能力

- 接入联邦查询、执行分析、统计估算和 SQL 补全接口

- 增加查询预算、凭据保护、租户隔离和 V61 初始化迁移
This commit is contained in:
2026-08-25 01:04:45 +08:00
parent 9068d42f4d
commit c27e97bcc2
71 changed files with 7544 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ tech.easyflow.approval.config.ApprovalModuleConfig
tech.easyflow.auth.config.AuthModuleConfig
tech.easyflow.chatlog.config.ChatlogModuleConfig
tech.easyflow.datacenter.config.DatacenterModuleConfig
tech.easyflow.dataspace.config.DataspaceModuleConfig
tech.easyflow.job.config.JobModuleConfig
tech.easyflow.log.config.LogModuleConfig
tech.easyflow.skill.config.SkillModuleConfig

View 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 将数据库建模为 CatalogSchema 参数为空。
*
* @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";
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 结果允许 NULLList.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) {
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,7 @@
<module>easyflow-module-skill</module>
<module>easyflow-module-job</module>
<module>easyflow-module-datacenter</module>
<module>easyflow-module-dataspace</module>
</modules>
</project>