test(schema): 补齐SQL与实体映射契约校验

This commit is contained in:
2026-06-01 06:09:31 +08:00
parent c8245ba0d6
commit 73237507e9

View File

@@ -0,0 +1,313 @@
package com.bruce.integration.schema;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.bruce.agent.entity.AgentCapabilityBinding;
import com.bruce.agent.entity.AgentDefinition;
import com.bruce.agent.entity.AgentMessage;
import com.bruce.agent.entity.AgentSession;
import com.bruce.common.domain.entity.SysAttachment;
import com.bruce.common.domain.entity.SysEnum;
import com.bruce.common.domain.model.BaseEntity;
import com.bruce.mcp.entity.McpCapability;
import com.bruce.mcp.entity.McpServer;
import com.bruce.modelprovider.entity.ModelCallLog;
import com.bruce.modelprovider.entity.ModelConfig;
import com.bruce.modelprovider.entity.ModelProvider;
import com.bruce.modelprovider.entity.ModelRouteRule;
import com.bruce.modelprovider.entity.RagStoreModelConfig;
import com.bruce.rag.entity.RagChunk;
import com.bruce.rag.entity.RagChunkEmbedding;
import com.bruce.rag.entity.RagDocument;
import com.bruce.rag.entity.RagDocumentParseResult;
import com.bruce.rag.entity.RagStore;
import com.bruce.skill.entity.SkillDefinition;
import com.bruce.skill.entity.SkillVersion;
import com.bruce.workflow.entity.StudioProject;
import com.bruce.workflow.entity.WorkflowDefinition;
import com.bruce.workflow.entity.WorkflowRun;
import com.bruce.workflow.entity.WorkflowRunStep;
import com.bruce.workflow.entity.WorkflowVersion;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
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 java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 校验 SQL 建表脚本与实体字段映射保持一致。
* <p>
* 这组测试直接读取 script/sql 下的建表脚本,把数据库字段与 Java Entity 的映射关系做逐表比对,
* 用来补强此前偏结构性的 mapper/repository 验证。
*/
class SqlEntityMappingContractTests {
private static final Path SQL_DIR = Path.of("..", "script", "sql");
/**
* BaseEntity 约定的公共审计字段,需要在所有业务表脚本中保留。
*/
private static final Set<String> BASE_COLUMNS = Set.of(
"id", "create_by", "create_time", "update_by", "update_time", "version"
);
@Test
void entityMappedColumnsShouldExistInSqlScripts() throws IOException {
Map<String, Set<String>> tableColumns = loadTableColumns();
for (Class<?> entityClass : entityClasses()) {
TableName tableName = entityClass.getAnnotation(TableName.class);
assertNotNull(tableName, "实体缺少 @TableName: " + entityClass.getName());
String table = tableName.value();
Set<String> sqlColumns = tableColumns.get(table);
assertNotNull(sqlColumns, "SQL 脚本未找到表定义: " + table);
Set<String> entityColumns = collectEntityColumns(entityClass);
for (String column : entityColumns) {
assertTrue(sqlColumns.contains(column),
() -> "" + table + " 缺少实体映射字段 " + column + ",实体: " + entityClass.getSimpleName());
}
}
}
@Test
void sqlTablesShouldHaveExpectedEntityCoverage() throws IOException {
Map<String, Set<String>> tableColumns = loadTableColumns();
Set<String> entityTables = new LinkedHashSet<>();
for (Class<?> entityClass : entityClasses()) {
entityTables.add(entityClass.getAnnotation(TableName.class).value());
}
Set<String> expectedTables = Set.of(
"sys_enum",
"sys_attachment",
"rag_store",
"rag_document",
"rag_document_parse_result",
"rag_chunk",
"rag_chunk_embedding",
"agent_definition",
"agent_session",
"agent_message",
"agent_capability_binding",
"model_provider",
"model_config",
"model_route_rule",
"rag_store_model_config",
"model_call_log",
"studio_project",
"workflow_definition",
"workflow_version",
"workflow_run",
"workflow_run_step",
"mcp_server",
"mcp_capability",
"skill_definition",
"skill_version"
);
assertEquals(expectedTables, entityTables, "实体覆盖的表清单应与当前模块表一致");
assertTrue(tableColumns.keySet().containsAll(expectedTables), "SQL 脚本应覆盖全部模块表");
}
@Test
void entityTablesShouldRetainBaseAuditColumns() throws IOException {
Map<String, Set<String>> tableColumns = loadTableColumns();
for (Class<?> entityClass : entityClasses()) {
String table = entityClass.getAnnotation(TableName.class).value();
Set<String> sqlColumns = tableColumns.get(table);
assertNotNull(sqlColumns, "SQL 脚本未找到表定义: " + table);
assertTrue(sqlColumns.containsAll(BASE_COLUMNS),
() -> "" + table + " 缺少 BaseEntity 审计字段,实际字段: " + sqlColumns);
}
}
@Test
void sqlColumnParserShouldIgnoreConstraintsAndIndexes() throws IOException {
Map<String, Set<String>> tableColumns = loadTableColumns();
Collection<Set<String>> allColumns = tableColumns.values();
assertFalse(allColumns.stream().flatMap(Set::stream).anyMatch(column -> column.startsWith("constraint")),
"列解析不应把约束名当成字段");
assertFalse(allColumns.stream().flatMap(Set::stream).anyMatch(column -> column.startsWith("foreign")),
"列解析不应把外键定义当成字段");
}
private Map<String, Set<String>> loadTableColumns() throws IOException {
Map<String, Set<String>> tableColumns = new LinkedHashMap<>();
Pattern createTablePattern = Pattern.compile("CREATE TABLE(?: IF NOT EXISTS)?\\s+([a-zA-Z0-9_]+)\\s*\\(",
Pattern.CASE_INSENSITIVE);
for (Path sqlFile : sqlFiles()) {
List<String> lines = Files.readAllLines(sqlFile, StandardCharsets.UTF_8);
String currentTable = null;
Set<String> currentColumns = null;
int nesting = 0;
for (String rawLine : lines) {
String line = rawLine.trim();
if (line.isEmpty() || line.startsWith("--")) {
continue;
}
if (currentTable == null) {
Matcher matcher = createTablePattern.matcher(line);
if (matcher.find()) {
currentTable = matcher.group(1).toLowerCase(Locale.ROOT);
currentColumns = new LinkedHashSet<>();
tableColumns.put(currentTable, currentColumns);
nesting = 1;
}
continue;
}
nesting += count(line, '(');
nesting -= count(line, ')');
if (!line.startsWith("CONSTRAINT")
&& !line.startsWith("PRIMARY KEY")
&& !line.startsWith("FOREIGN KEY")
&& !line.startsWith("UNIQUE")
&& !line.startsWith("CHECK")) {
String column = extractColumnName(line);
if (column != null) {
currentColumns.add(column);
}
}
if (nesting <= 0) {
currentTable = null;
currentColumns = null;
nesting = 0;
}
}
}
return tableColumns;
}
private List<Path> sqlFiles() throws IOException {
List<Path> sqlFiles = new ArrayList<>();
try (var paths = Files.list(SQL_DIR)) {
paths.filter(path -> path.getFileName().toString().endsWith(".sql"))
.sorted()
.forEach(sqlFiles::add);
}
return sqlFiles;
}
private String extractColumnName(String line) {
String sanitized = line.replace(",", "").trim();
if (sanitized.isEmpty()) {
return null;
}
int firstSpace = sanitized.indexOf(' ');
if (firstSpace <= 0) {
return null;
}
String column = sanitized.substring(0, firstSpace).trim();
if (!column.matches("[a-zA-Z_][a-zA-Z0-9_]*")) {
return null;
}
return column.toLowerCase(Locale.ROOT);
}
private int count(String text, char target) {
int result = 0;
for (int index = 0; index < text.length(); index++) {
if (text.charAt(index) == target) {
result++;
}
}
return result;
}
private Set<String> collectEntityColumns(Class<?> entityClass) {
Set<String> columns = new LinkedHashSet<>();
for (Field field : allFields(entityClass)) {
if (Modifier.isStatic(field.getModifiers()) || Modifier.isTransient(field.getModifiers())) {
continue;
}
TableField tableField = field.getAnnotation(TableField.class);
String column = tableField == null || tableField.value().isBlank()
? camelToSnake(field.getName())
: tableField.value();
columns.add(column.toLowerCase(Locale.ROOT));
}
return columns;
}
private List<Field> allFields(Class<?> entityClass) {
List<Field> fields = new ArrayList<>();
Class<?> current = entityClass;
while (current != null && current != Object.class) {
fields.addAll(Arrays.asList(current.getDeclaredFields()));
if (current == BaseEntity.class) {
break;
}
current = current.getSuperclass();
}
return fields;
}
private String camelToSnake(String value) {
StringBuilder builder = new StringBuilder();
for (int index = 0; index < value.length(); index++) {
char current = value.charAt(index);
if (Character.isUpperCase(current)) {
if (index > 0) {
builder.append('_');
}
builder.append(Character.toLowerCase(current));
} else {
builder.append(current);
}
}
return builder.toString();
}
private List<Class<?>> entityClasses() {
return List.of(
SysEnum.class,
SysAttachment.class,
RagStore.class,
RagDocument.class,
RagDocumentParseResult.class,
RagChunk.class,
RagChunkEmbedding.class,
AgentDefinition.class,
AgentSession.class,
AgentMessage.class,
AgentCapabilityBinding.class,
ModelProvider.class,
ModelConfig.class,
ModelRouteRule.class,
RagStoreModelConfig.class,
ModelCallLog.class,
StudioProject.class,
WorkflowDefinition.class,
WorkflowVersion.class,
WorkflowRun.class,
WorkflowRunStep.class,
McpServer.class,
McpCapability.class,
SkillDefinition.class,
SkillVersion.class
);
}
}