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