test(schema): 补齐SQL与实体映射契约校验
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user