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

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