From 73237507e9d6f874f6168ab922a76b956f66ec39 Mon Sep 17 00:00:00 2001 From: bruce Date: Mon, 1 Jun 2026 06:09:31 +0800 Subject: [PATCH] =?UTF-8?q?test(schema):=20=E8=A1=A5=E9=BD=90SQL=E4=B8=8E?= =?UTF-8?q?=E5=AE=9E=E4=BD=93=E6=98=A0=E5=B0=84=E5=A5=91=E7=BA=A6=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schema/SqlEntityMappingContractTests.java | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 common-agent-boot/src/test/java/com/bruce/integration/schema/SqlEntityMappingContractTests.java diff --git a/common-agent-boot/src/test/java/com/bruce/integration/schema/SqlEntityMappingContractTests.java b/common-agent-boot/src/test/java/com/bruce/integration/schema/SqlEntityMappingContractTests.java new file mode 100644 index 0000000..2eb4ba9 --- /dev/null +++ b/common-agent-boot/src/test/java/com/bruce/integration/schema/SqlEntityMappingContractTests.java @@ -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 建表脚本与实体字段映射保持一致。 + *

+ * 这组测试直接读取 script/sql 下的建表脚本,把数据库字段与 Java Entity 的映射关系做逐表比对, + * 用来补强此前偏结构性的 mapper/repository 验证。 + */ +class SqlEntityMappingContractTests { + + private static final Path SQL_DIR = Path.of("..", "script", "sql"); + + /** + * BaseEntity 约定的公共审计字段,需要在所有业务表脚本中保留。 + */ + private static final Set BASE_COLUMNS = Set.of( + "id", "create_by", "create_time", "update_by", "update_time", "version" + ); + + @Test + void entityMappedColumnsShouldExistInSqlScripts() throws IOException { + Map> tableColumns = loadTableColumns(); + for (Class entityClass : entityClasses()) { + TableName tableName = entityClass.getAnnotation(TableName.class); + assertNotNull(tableName, "实体缺少 @TableName: " + entityClass.getName()); + + String table = tableName.value(); + Set sqlColumns = tableColumns.get(table); + assertNotNull(sqlColumns, "SQL 脚本未找到表定义: " + table); + + Set entityColumns = collectEntityColumns(entityClass); + for (String column : entityColumns) { + assertTrue(sqlColumns.contains(column), + () -> "表 " + table + " 缺少实体映射字段 " + column + ",实体: " + entityClass.getSimpleName()); + } + } + } + + @Test + void sqlTablesShouldHaveExpectedEntityCoverage() throws IOException { + Map> tableColumns = loadTableColumns(); + Set entityTables = new LinkedHashSet<>(); + for (Class entityClass : entityClasses()) { + entityTables.add(entityClass.getAnnotation(TableName.class).value()); + } + + Set 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> tableColumns = loadTableColumns(); + for (Class entityClass : entityClasses()) { + String table = entityClass.getAnnotation(TableName.class).value(); + Set sqlColumns = tableColumns.get(table); + assertNotNull(sqlColumns, "SQL 脚本未找到表定义: " + table); + assertTrue(sqlColumns.containsAll(BASE_COLUMNS), + () -> "表 " + table + " 缺少 BaseEntity 审计字段,实际字段: " + sqlColumns); + } + } + + @Test + void sqlColumnParserShouldIgnoreConstraintsAndIndexes() throws IOException { + Map> tableColumns = loadTableColumns(); + Collection> 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> loadTableColumns() throws IOException { + Map> 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 lines = Files.readAllLines(sqlFile, StandardCharsets.UTF_8); + String currentTable = null; + Set 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 sqlFiles() throws IOException { + List 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 collectEntityColumns(Class entityClass) { + Set 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 allFields(Class entityClass) { + List 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> 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 + ); + } +}