Compare commits
4 Commits
fd5aab5e7f
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 14f9f1fce7 | |||
| 4dd4944107 | |||
| a03e1b4819 | |||
| 27dfeb8a4c |
@@ -274,10 +274,12 @@ public class SchemaCheckAnalyzer {
|
|||||||
paths.add(c.getOldValue());
|
paths.add(c.getOldValue());
|
||||||
}
|
}
|
||||||
if (c.getChangeType() == ChangeType.WRAPPER_ADDED
|
if (c.getChangeType() == ChangeType.WRAPPER_ADDED
|
||||||
&& c.getFieldPath() != null) {
|
|| c.getChangeType() == ChangeType.WRAPPER_REMOVED) {
|
||||||
|
if (c.getFieldPath() != null) {
|
||||||
paths.add(c.getFieldPath());
|
paths.add(c.getFieldPath());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return paths;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ public enum ChangeType {
|
|||||||
FIELD_REMOVED(Severity.P0, "字段删除"),
|
FIELD_REMOVED(Severity.P0, "字段删除"),
|
||||||
TYPE_CHANGED(Severity.P0, "字段类型变更"),
|
TYPE_CHANGED(Severity.P0, "字段类型变更"),
|
||||||
WRAPPER_ADDED(Severity.P0, "新增包装层"),
|
WRAPPER_ADDED(Severity.P0, "新增包装层"),
|
||||||
|
WRAPPER_REMOVED(Severity.P0, "删除包装层"),
|
||||||
FIELD_PATH_MOVED(Severity.P0, "字段路径迁移"),
|
FIELD_PATH_MOVED(Severity.P0, "字段路径迁移"),
|
||||||
FIELD_ADDED(Severity.P1, "新增字段"),
|
FIELD_ADDED(Severity.P1, "新增字段"),
|
||||||
KEY_PATTERN_CHANGED(Severity.P1, "Key 模式变更"),
|
KEY_PATTERN_CHANGED(Severity.P1, "Key 模式变更"),
|
||||||
|
|||||||
@@ -22,40 +22,57 @@ public class SchemaDiffer {
|
|||||||
public List<SchemaChange> diff(TypeSchema oldSchema, TypeSchema newSchema) {
|
public List<SchemaChange> diff(TypeSchema oldSchema, TypeSchema newSchema) {
|
||||||
List<SchemaChange> changes = new ArrayList<>();
|
List<SchemaChange> changes = new ArrayList<>();
|
||||||
|
|
||||||
Map<String, JsonType> oldLeaves = leaves(oldSchema);
|
Map<String, FieldSchema> oldAll = fieldsByPath(oldSchema);
|
||||||
Map<String, JsonType> newLeaves = leaves(newSchema);
|
Map<String, FieldSchema> newAll = fieldsByPath(newSchema);
|
||||||
|
|
||||||
|
Map<String, FieldSchema> oldLeaves = leaves(oldSchema);
|
||||||
|
Map<String, FieldSchema> newLeaves = leaves(newSchema);
|
||||||
|
|
||||||
|
Set<String> typeChangedPaths = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
// 类型变更:同路径下 JsonType 或 Java 类型任一变化(覆盖 BigDecimal→String 等同桶变更)
|
||||||
|
for (String path : oldAll.keySet()) {
|
||||||
|
FieldSchema oldField = oldAll.get(path);
|
||||||
|
FieldSchema newField = newAll.get(path);
|
||||||
|
if (newField == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isTypeChange(oldField, newField)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
typeChangedPaths.add(path);
|
||||||
|
String oldLabel = typeLabel(oldField);
|
||||||
|
String newLabel = typeLabel(newField);
|
||||||
|
SchemaChange c = new SchemaChange(ChangeType.TYPE_CHANGED);
|
||||||
|
c.setFieldPath(path);
|
||||||
|
c.setOldValue(oldLabel);
|
||||||
|
c.setNewValue(newLabel);
|
||||||
|
c.setMessage("字段 " + path + " 类型由 " + oldLabel + " 变为 " + newLabel);
|
||||||
|
changes.add(c);
|
||||||
|
}
|
||||||
|
|
||||||
Set<String> removed = new LinkedHashSet<>(oldLeaves.keySet());
|
Set<String> removed = new LinkedHashSet<>(oldLeaves.keySet());
|
||||||
removed.removeAll(newLeaves.keySet());
|
removed.removeAll(newLeaves.keySet());
|
||||||
|
removed.removeAll(typeChangedPaths);
|
||||||
Set<String> added = new LinkedHashSet<>(newLeaves.keySet());
|
Set<String> added = new LinkedHashSet<>(newLeaves.keySet());
|
||||||
added.removeAll(oldLeaves.keySet());
|
added.removeAll(oldLeaves.keySet());
|
||||||
|
added.removeAll(typeChangedPaths);
|
||||||
|
|
||||||
// 类型变更(同路径)
|
// 路径迁移:
|
||||||
for (String path : oldLeaves.keySet()) {
|
// - 下沉(加包装):旧路径是新路径后缀,如 dbName → vo.dbName
|
||||||
if (newLeaves.containsKey(path)) {
|
// - 上提(拆包装):新路径是旧路径后缀,如 vo.dbName → dbName
|
||||||
JsonType oldType = oldLeaves.get(path);
|
|
||||||
JsonType newType = newLeaves.get(path);
|
|
||||||
if (oldType != newType) {
|
|
||||||
SchemaChange c = new SchemaChange(ChangeType.TYPE_CHANGED);
|
|
||||||
c.setFieldPath(path);
|
|
||||||
c.setOldValue(oldType.name());
|
|
||||||
c.setNewValue(newType.name());
|
|
||||||
c.setMessage("字段 " + path + " 类型由 " + oldType + " 变为 " + newType);
|
|
||||||
changes.add(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 路径迁移检测(removed 的路径是某 added 路径的后缀)
|
|
||||||
List<String[]> moves = new ArrayList<>();
|
List<String[]> moves = new ArrayList<>();
|
||||||
Set<String> matchedRemoved = new LinkedHashSet<>();
|
Set<String> matchedRemoved = new LinkedHashSet<>();
|
||||||
Set<String> matchedAdded = new LinkedHashSet<>();
|
Set<String> matchedAdded = new LinkedHashSet<>();
|
||||||
for (String r : removed) {
|
for (String r : removed) {
|
||||||
|
if (matchedRemoved.contains(r)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
for (String a : added) {
|
for (String a : added) {
|
||||||
if (matchedAdded.contains(a)) {
|
if (matchedAdded.contains(a)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isSuffix(a, r)) {
|
if (isSuffix(a, r) || isSuffix(r, a)) {
|
||||||
moves.add(new String[]{r, a});
|
moves.add(new String[]{r, a});
|
||||||
matchedRemoved.add(r);
|
matchedRemoved.add(r);
|
||||||
matchedAdded.add(a);
|
matchedAdded.add(a);
|
||||||
@@ -64,16 +81,16 @@ public class SchemaDiffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 包装层检测:多个迁移共享同一新前缀
|
// 新增包装层:多个迁移共享同一新前缀
|
||||||
Map<String, Integer> prefixCount = new LinkedHashMap<>();
|
Map<String, Integer> newPrefixCount = new LinkedHashMap<>();
|
||||||
for (String[] move : moves) {
|
for (String[] move : moves) {
|
||||||
String a = move[1];
|
String a = move[1];
|
||||||
if (a.contains(".")) {
|
if (a.contains(".")) {
|
||||||
String prefix = a.substring(0, a.indexOf('.'));
|
String prefix = a.substring(0, a.indexOf('.'));
|
||||||
prefixCount.merge(prefix, 1, Integer::sum);
|
newPrefixCount.merge(prefix, 1, Integer::sum);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (Map.Entry<String, Integer> e : prefixCount.entrySet()) {
|
for (Map.Entry<String, Integer> e : newPrefixCount.entrySet()) {
|
||||||
if (e.getValue() >= 2) {
|
if (e.getValue() >= 2) {
|
||||||
SchemaChange c = new SchemaChange(ChangeType.WRAPPER_ADDED);
|
SchemaChange c = new SchemaChange(ChangeType.WRAPPER_ADDED);
|
||||||
c.setFieldPath(e.getKey());
|
c.setFieldPath(e.getKey());
|
||||||
@@ -83,6 +100,25 @@ public class SchemaDiffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 删除包装层:多个迁移共享同一旧前缀
|
||||||
|
Map<String, Integer> oldPrefixCount = new LinkedHashMap<>();
|
||||||
|
for (String[] move : moves) {
|
||||||
|
String r = move[0];
|
||||||
|
if (r.contains(".")) {
|
||||||
|
String prefix = r.substring(0, r.indexOf('.'));
|
||||||
|
oldPrefixCount.merge(prefix, 1, Integer::sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (Map.Entry<String, Integer> e : oldPrefixCount.entrySet()) {
|
||||||
|
if (e.getValue() >= 2) {
|
||||||
|
SchemaChange c = new SchemaChange(ChangeType.WRAPPER_REMOVED);
|
||||||
|
c.setFieldPath(e.getKey());
|
||||||
|
c.setOldValue(e.getKey());
|
||||||
|
c.setMessage("删除包装层 " + e.getKey() + ",其下字段被提升为外层(影响 " + e.getValue() + " 个字段)");
|
||||||
|
changes.add(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (String[] move : moves) {
|
for (String[] move : moves) {
|
||||||
SchemaChange c = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
|
SchemaChange c = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
|
||||||
c.setFieldPath(move[1]);
|
c.setFieldPath(move[1]);
|
||||||
@@ -99,7 +135,7 @@ public class SchemaDiffer {
|
|||||||
}
|
}
|
||||||
SchemaChange c = new SchemaChange(ChangeType.FIELD_REMOVED);
|
SchemaChange c = new SchemaChange(ChangeType.FIELD_REMOVED);
|
||||||
c.setFieldPath(r);
|
c.setFieldPath(r);
|
||||||
c.setOldValue(oldLeaves.get(r).name());
|
c.setOldValue(typeLabel(oldLeaves.get(r)));
|
||||||
c.setMessage("删除字段 " + r);
|
c.setMessage("删除字段 " + r);
|
||||||
changes.add(c);
|
changes.add(c);
|
||||||
}
|
}
|
||||||
@@ -111,7 +147,7 @@ public class SchemaDiffer {
|
|||||||
}
|
}
|
||||||
SchemaChange c = new SchemaChange(ChangeType.FIELD_ADDED);
|
SchemaChange c = new SchemaChange(ChangeType.FIELD_ADDED);
|
||||||
c.setFieldPath(a);
|
c.setFieldPath(a);
|
||||||
c.setNewValue(newLeaves.get(a).name());
|
c.setNewValue(typeLabel(newLeaves.get(a)));
|
||||||
c.setMessage("新增字段 " + a);
|
c.setMessage("新增字段 " + a);
|
||||||
changes.add(c);
|
changes.add(c);
|
||||||
}
|
}
|
||||||
@@ -119,11 +155,104 @@ public class SchemaDiffer {
|
|||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, JsonType> leaves(TypeSchema schema) {
|
private boolean isTypeChange(FieldSchema oldField, FieldSchema newField) {
|
||||||
Map<String, JsonType> result = new LinkedHashMap<>();
|
if (oldField.getJsonType() != newField.getJsonType()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String oldJava = normalizeJavaType(oldField.getJavaType());
|
||||||
|
String newJava = normalizeJavaType(newField.getJavaType());
|
||||||
|
if (oldJava.isEmpty() || newJava.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !oldJava.equals(newJava);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展示用类型标签:优先 Java 简单名,并附带 JsonType(便于识别同桶变更)。
|
||||||
|
*/
|
||||||
|
private String typeLabel(FieldSchema field) {
|
||||||
|
if (field == null) {
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
String java = simpleJavaType(field.getJavaType());
|
||||||
|
JsonType json = field.getJsonType();
|
||||||
|
if (java.isEmpty() || "?".equals(java)) {
|
||||||
|
return json == null ? "?" : json.name();
|
||||||
|
}
|
||||||
|
if (json == null) {
|
||||||
|
return java;
|
||||||
|
}
|
||||||
|
return java + "/" + json.name();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化后用于相等比较:去掉泛型与包名,基本类型与包装类型视为同一序列化类型。
|
||||||
|
*/
|
||||||
|
static String normalizeJavaType(String javaType) {
|
||||||
|
String simple = simpleJavaType(javaType);
|
||||||
|
if (simple.isEmpty() || "?".equals(simple)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
switch (simple) {
|
||||||
|
case "int":
|
||||||
|
return "Integer";
|
||||||
|
case "long":
|
||||||
|
return "Long";
|
||||||
|
case "short":
|
||||||
|
return "Short";
|
||||||
|
case "byte":
|
||||||
|
return "Byte";
|
||||||
|
case "double":
|
||||||
|
return "Double";
|
||||||
|
case "float":
|
||||||
|
return "Float";
|
||||||
|
case "boolean":
|
||||||
|
return "Boolean";
|
||||||
|
case "char":
|
||||||
|
return "Character";
|
||||||
|
default:
|
||||||
|
return simple;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String simpleJavaType(String javaType) {
|
||||||
|
if (javaType == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String s = javaType.trim();
|
||||||
|
if (s.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int lt = s.indexOf('<');
|
||||||
|
if (lt > 0) {
|
||||||
|
s = s.substring(0, lt).trim();
|
||||||
|
}
|
||||||
|
int dot = s.lastIndexOf('.');
|
||||||
|
if (dot >= 0 && dot < s.length() - 1) {
|
||||||
|
s = s.substring(dot + 1);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, FieldSchema> fieldsByPath(TypeSchema schema) {
|
||||||
|
Map<String, FieldSchema> result = new LinkedHashMap<>();
|
||||||
|
if (schema == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (FieldSchema f : schema.getFields().values()) {
|
||||||
|
result.put(f.getPath(), f);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, FieldSchema> leaves(TypeSchema schema) {
|
||||||
|
Map<String, FieldSchema> result = new LinkedHashMap<>();
|
||||||
|
if (schema == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
for (FieldSchema f : schema.getFields().values()) {
|
for (FieldSchema f : schema.getFields().values()) {
|
||||||
if (f.getJsonType() != JsonType.OBJECT && f.getJsonType() != JsonType.ARRAY) {
|
if (f.getJsonType() != JsonType.OBJECT && f.getJsonType() != JsonType.ARRAY) {
|
||||||
result.put(f.getPath(), f.getJsonType());
|
result.put(f.getPath(), f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.codechecker.cache.key;
|
package com.codechecker.cache.key;
|
||||||
|
|
||||||
import com.codechecker.cache.schema.SourceIndex;
|
import com.codechecker.cache.schema.SourceIndex;
|
||||||
|
import com.github.javaparser.Position;
|
||||||
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration;
|
||||||
import com.github.javaparser.ast.body.FieldDeclaration;
|
import com.github.javaparser.ast.body.FieldDeclaration;
|
||||||
import com.github.javaparser.ast.body.MethodDeclaration;
|
import com.github.javaparser.ast.body.MethodDeclaration;
|
||||||
import com.github.javaparser.ast.body.VariableDeclarator;
|
import com.github.javaparser.ast.body.VariableDeclarator;
|
||||||
|
import com.github.javaparser.ast.expr.AssignExpr;
|
||||||
import com.github.javaparser.ast.expr.BinaryExpr;
|
import com.github.javaparser.ast.expr.BinaryExpr;
|
||||||
import com.github.javaparser.ast.expr.Expression;
|
import com.github.javaparser.ast.expr.Expression;
|
||||||
import com.github.javaparser.ast.expr.FieldAccessExpr;
|
import com.github.javaparser.ast.expr.FieldAccessExpr;
|
||||||
@@ -17,6 +19,8 @@ import java.util.Optional;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 尽力将 Redis key 表达式静态推断为一个「key 模式」,动态部分以 {@code *} 表示。
|
* 尽力将 Redis key 表达式静态推断为一个「key 模式」,动态部分以 {@code *} 表示。
|
||||||
|
* <p>
|
||||||
|
* 对局部变量名:先查同名静态常量 / 同名方法 return,再回溯方法内使用点之前最近一次声明或赋值。
|
||||||
*/
|
*/
|
||||||
public class RedisKeyResolver {
|
public class RedisKeyResolver {
|
||||||
|
|
||||||
@@ -52,12 +56,17 @@ public class RedisKeyResolver {
|
|||||||
}
|
}
|
||||||
if (expr instanceof NameExpr) {
|
if (expr instanceof NameExpr) {
|
||||||
String name = ((NameExpr) expr).getNameAsString();
|
String name = ((NameExpr) expr).getNameAsString();
|
||||||
|
// 1) 静态常量 2) 同名方法 return 3) 局部变量定值(不改变既有优先级)
|
||||||
String constVal = lookupConstant(enclosingClass, name, context, depth);
|
String constVal = lookupConstant(enclosingClass, name, context, depth);
|
||||||
if (constVal != null) {
|
if (constVal != null) {
|
||||||
return constVal;
|
return constVal;
|
||||||
}
|
}
|
||||||
String methodVal = lookupMethodReturn(enclosingClass, name, context, depth);
|
String methodVal = lookupMethodReturn(enclosingClass, name, context, depth);
|
||||||
return methodVal != null ? methodVal : "*";
|
if (methodVal != null) {
|
||||||
|
return methodVal;
|
||||||
|
}
|
||||||
|
String localVal = lookupLocalBinding(expr, name, enclosingClass, context, depth);
|
||||||
|
return localVal != null ? localVal : "*";
|
||||||
}
|
}
|
||||||
if (expr instanceof FieldAccessExpr) {
|
if (expr instanceof FieldAccessExpr) {
|
||||||
FieldAccessExpr fae = (FieldAccessExpr) expr;
|
FieldAccessExpr fae = (FieldAccessExpr) expr;
|
||||||
@@ -94,6 +103,76 @@ public class RedisKeyResolver {
|
|||||||
return "*";
|
return "*";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在包围方法内,取使用点之前对 {@code name} 最近一次声明初始化或赋值的右侧表达式。
|
||||||
|
*/
|
||||||
|
private String lookupLocalBinding(Expression useSite, String name,
|
||||||
|
ClassOrInterfaceDeclaration enclosingClass,
|
||||||
|
SourceIndex.IndexedType context, int depth) {
|
||||||
|
if (useSite == null || name == null || name.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
MethodDeclaration method = useSite.findAncestor(MethodDeclaration.class).orElse(null);
|
||||||
|
if (method == null || !method.getBody().isPresent()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Position usePos = useSite.getBegin().orElse(null);
|
||||||
|
Expression bestRhs = null;
|
||||||
|
Position bestPos = null;
|
||||||
|
|
||||||
|
for (VariableDeclarator var : method.findAll(VariableDeclarator.class)) {
|
||||||
|
if (!name.equals(var.getNameAsString()) || !var.getInitializer().isPresent()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Position pos = var.getBegin().orElse(null);
|
||||||
|
if (!isUsableBinding(pos, usePos, bestPos)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
bestPos = pos;
|
||||||
|
bestRhs = var.getInitializer().get();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (AssignExpr assign : method.findAll(AssignExpr.class)) {
|
||||||
|
if (!(assign.getTarget() instanceof NameExpr)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!name.equals(((NameExpr) assign.getTarget()).getNameAsString())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Position pos = assign.getBegin().orElse(null);
|
||||||
|
if (!isUsableBinding(pos, usePos, bestPos)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
bestPos = pos;
|
||||||
|
bestRhs = assign.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestRhs == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 避免自引用(String key = key)陷入无意义递归
|
||||||
|
if (bestRhs instanceof NameExpr
|
||||||
|
&& name.equals(((NameExpr) bestRhs).getNameAsString())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolveExpr(bestRhs, enclosingClass, context, depth + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 候选须严格在使用点之前,并在多个候选中取最近一次。 */
|
||||||
|
private static boolean isUsableBinding(Position candidate, Position usePos, Position bestPos) {
|
||||||
|
if (candidate == null) {
|
||||||
|
return bestPos == null;
|
||||||
|
}
|
||||||
|
if (usePos != null && !candidate.isBefore(usePos)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (bestPos == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return candidate.isAfter(bestPos);
|
||||||
|
}
|
||||||
|
|
||||||
private String lookupConstant(ClassOrInterfaceDeclaration clazz, String name,
|
private String lookupConstant(ClassOrInterfaceDeclaration clazz, String name,
|
||||||
SourceIndex.IndexedType context, int depth) {
|
SourceIndex.IndexedType context, int depth) {
|
||||||
if (clazz == null) {
|
if (clazz == null) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.codechecker.cache.report;
|
package com.codechecker.cache.report;
|
||||||
|
|
||||||
|
import com.codechecker.cache.diff.ChangeType;
|
||||||
import com.codechecker.cache.diff.SchemaChange;
|
import com.codechecker.cache.diff.SchemaChange;
|
||||||
import com.codechecker.cache.diff.Severity;
|
import com.codechecker.cache.diff.Severity;
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ import java.util.Set;
|
|||||||
/**
|
/**
|
||||||
* 将 {@link CheckReport} 渲染为企微 Markdown / 控制台文本。
|
* 将 {@link CheckReport} 渲染为企微 Markdown / 控制台文本。
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>企微:按 key 展示位置/类型/序列化骨架变更,不含字段明细,不分 P0/P1/P2</li>
|
* <li>企微:按 key 展示位置/类型/序列化骨架变更;类型变更另附摘要行,不分 P0/P1/P2</li>
|
||||||
* <li>未解析 key 展示源码表达式 + 灰色「key 无法解析」提示</li>
|
* <li>未解析 key 展示源码表达式 + 灰色「key 无法解析」提示</li>
|
||||||
* <li>多 key 优先拼成一条;超过企微上限则按 key 拆成多条</li>
|
* <li>多 key 优先拼成一条;超过企微上限则按 key 拆成多条</li>
|
||||||
* <li>CI:先打字段明细,再完整输出企微 Markdown(拆分后的每条)</li>
|
* <li>CI:先打字段明细,再完整输出企微 Markdown(拆分后的每条)</li>
|
||||||
@@ -128,25 +129,92 @@ public class ReportBuilder {
|
|||||||
String oldJson = nvl(kc.getOldSkeletonJson());
|
String oldJson = nvl(kc.getOldSkeletonJson());
|
||||||
String newJson = nvl(kc.getNewSkeletonJson());
|
String newJson = nvl(kc.getNewSkeletonJson());
|
||||||
Set<String> oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails());
|
Set<String> oldHighlight = SkeletonAnnotator.pathsForOldSkeleton(kc.getFieldDetails());
|
||||||
Set<String> newHighlight = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails());
|
Set<String> wrappersRemoved = SkeletonAnnotator.pathsForWrapperRemoved(kc.getFieldDetails());
|
||||||
|
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(kc.getFieldDetails());
|
||||||
|
Set<String> wrappersAdded = SkeletonAnnotator.pathsForWrapperAdded(kc.getFieldDetails());
|
||||||
|
Set<String> typeGray = SkeletonAnnotator.pathsForTypeChanged(kc.getFieldDetails());
|
||||||
String oldRendered = oldJson.isEmpty()
|
String oldRendered = oldJson.isEmpty()
|
||||||
? "" : "“" + SkeletonAnnotator.annotateOldForWecom(oldJson, oldHighlight) + "”";
|
? "" : "“" + SkeletonAnnotator.annotateOldForWecom(
|
||||||
|
oldJson, oldHighlight, typeGray, wrappersRemoved) + "”";
|
||||||
String newRendered = newJson.isEmpty()
|
String newRendered = newJson.isEmpty()
|
||||||
? "" : "“" + SkeletonAnnotator.annotateNewForWecom(newJson, newHighlight) + "”";
|
? "" : "“" + SkeletonAnnotator.annotateNewForWecom(
|
||||||
|
newJson, newGreen, typeGray, wrappersAdded) + "”";
|
||||||
if (oldJson.isEmpty() && !newJson.isEmpty()) {
|
if (oldJson.isEmpty() && !newJson.isEmpty()) {
|
||||||
sb.append(" > **value 新增为:** ").append(newRendered).append("\n\n");
|
sb.append(" > **value 新增为:** ").append(newRendered).append('\n');
|
||||||
} else if (!oldJson.isEmpty() && newJson.isEmpty()) {
|
} else if (!oldJson.isEmpty() && newJson.isEmpty()) {
|
||||||
sb.append(" > **value 原结构:** ").append(oldRendered).append("(已删除写入)\n\n");
|
sb.append(" > **value 原结构:** ").append(oldRendered).append("(已删除写入)\n");
|
||||||
} else {
|
} else {
|
||||||
sb.append(" > **value值由:** ").append(oldRendered).append('\n');
|
sb.append(" > **value值由:** ").append(oldRendered).append('\n');
|
||||||
sb.append(" > **变更为:** ").append(newRendered).append("\n\n");
|
sb.append(" > **变更为:** ").append(newRendered).append('\n');
|
||||||
}
|
}
|
||||||
|
appendTypeChangeSummary(sb, kc.getFieldDetails());
|
||||||
|
sb.append('\n');
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类型变更单独一行摘要,避免仅靠骨架颜色/占位难以识别。
|
||||||
|
* 例:{@code > **类型变更**: amount <font color="warning">BigDecimal → Integer</font>}
|
||||||
|
*/
|
||||||
|
private void appendTypeChangeSummary(StringBuilder sb, List<SchemaChange> details) {
|
||||||
|
if (details == null || details.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<String> parts = new ArrayList<>();
|
||||||
|
for (SchemaChange c : details) {
|
||||||
|
if (c == null || c.getChangeType() != ChangeType.TYPE_CHANGED) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String path = nvl(c.getFieldPath());
|
||||||
|
if (path.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String oldType = displayJavaType(c.getOldValue());
|
||||||
|
String newType = displayJavaType(c.getNewValue());
|
||||||
|
StringBuilder part = new StringBuilder();
|
||||||
|
// 字段名用普通文本(避免反引号被企微渲染成色块)
|
||||||
|
part.append(path);
|
||||||
|
if (!oldType.isEmpty() || !newType.isEmpty()) {
|
||||||
|
part.append(" <font color=\"warning\">")
|
||||||
|
.append(oldType.isEmpty() ? "?" : oldType)
|
||||||
|
.append(" → ")
|
||||||
|
.append(newType.isEmpty() ? "?" : newType)
|
||||||
|
.append("</font>");
|
||||||
|
}
|
||||||
|
parts.add(part.toString());
|
||||||
|
}
|
||||||
|
if (parts.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sb.append(" > **类型变更**: ");
|
||||||
|
for (int i = 0; i < parts.size(); i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
sb.append(";");
|
||||||
|
}
|
||||||
|
sb.append(parts.get(i));
|
||||||
|
}
|
||||||
|
sb.append('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 {@code BigDecimal/NUMBER} 转为展示用 {@code BigDecimal}。 */
|
||||||
|
private static String displayJavaType(String typeLabel) {
|
||||||
|
if (typeLabel == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String s = typeLabel.trim();
|
||||||
|
if (s.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int slash = s.indexOf('/');
|
||||||
|
if (slash > 0) {
|
||||||
|
s = s.substring(0, slash).trim();
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Key 行:展示解析后的模式或未解析时的源码表达式;未解析时追加灰色提示。
|
* Key 行:展示解析后的模式或未解析时的源码表达式;未解析时追加灰色提示。
|
||||||
* 反引号仅包裹 key 文本,避免与加粗/颜色嵌套冲突。
|
* 反引号内仍须转义 {@code *},否则企微会把 {@code *:*} 当成斜体吃掉通配符。
|
||||||
*/
|
*/
|
||||||
private void appendKeyLine(StringBuilder sb, String displayKey, String keyExpression,
|
private void appendKeyLine(StringBuilder sb, String displayKey, String keyExpression,
|
||||||
boolean unresolved) {
|
boolean unresolved) {
|
||||||
@@ -156,17 +224,28 @@ public class ReportBuilder {
|
|||||||
if (keyText.isEmpty()) {
|
if (keyText.isEmpty()) {
|
||||||
keyText = "unknown-key";
|
keyText = "unknown-key";
|
||||||
}
|
}
|
||||||
sb.append("- Key --> `").append(keyText).append('`');
|
sb.append("- Key --> `").append(escapeWeComCode(keyText)).append('`');
|
||||||
if (unresolved) {
|
if (unresolved) {
|
||||||
sb.append(" <font color=\"comment\">(key 无法解析)</font>");
|
sb.append(" <font color=\"comment\">(key 无法解析)</font>");
|
||||||
}
|
}
|
||||||
sb.append('\n');
|
sb.append('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 位置、类型作为每个 Key 块的通用项。 */
|
/**
|
||||||
|
* 企微 markdown 代码片段内的转义:{@code *} 会触发斜体(即使包在反引号里)。
|
||||||
|
*/
|
||||||
|
static String escapeWeComCode(String text) {
|
||||||
|
if (text == null || text.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
// 反斜杠转义对企微不可靠;用全角 * 保留通配语义且不被吃掉
|
||||||
|
return text.replace("*", "*");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 位置、类型作为每个 Key 块的通用项(不加反引号,避免企微渲染成色块)。 */
|
||||||
private void appendMetaLines(StringBuilder sb, String writeLocation, String valueType) {
|
private void appendMetaLines(StringBuilder sb, String writeLocation, String valueType) {
|
||||||
sb.append(" > **位置**: `").append(nvl(writeLocation)).append("`\n");
|
sb.append(" > **位置**: ").append(nvl(writeLocation)).append('\n');
|
||||||
sb.append(" > **类型**: `").append(nvl(valueType)).append("`\n");
|
sb.append(" > **类型**: ").append(nvl(valueType)).append('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isUnresolvedKey(String keyPattern) {
|
private boolean isUnresolvedKey(String keyPattern) {
|
||||||
@@ -202,7 +281,7 @@ public class ReportBuilder {
|
|||||||
for (SchemaChange c : list) {
|
for (SchemaChange c : list) {
|
||||||
sb.append("- **").append(c.getChangeType().getLabel()).append("**");
|
sb.append("- **").append(c.getChangeType().getLabel()).append("**");
|
||||||
if (c.getKeyPattern() != null) {
|
if (c.getKeyPattern() != null) {
|
||||||
sb.append(" `").append(c.getKeyPattern()).append('`');
|
sb.append(" `").append(escapeWeComCode(c.getKeyPattern())).append('`');
|
||||||
}
|
}
|
||||||
sb.append('\n');
|
sb.append('\n');
|
||||||
if (c.getWriteLocation() != null) {
|
if (c.getWriteLocation() != null) {
|
||||||
@@ -250,8 +329,8 @@ public class ReportBuilder {
|
|||||||
+ trimmed.substring(colonEn + 1).trim();
|
+ trimmed.substring(colonEn + 1).trim();
|
||||||
}
|
}
|
||||||
String[] knownPrefixes = {
|
String[] knownPrefixes = {
|
||||||
"删除字段 ", "新增字段 ", "字段路径迁移 ", "新增包装层 ", "新增缓存写入点,",
|
"删除字段 ", "新增字段 ", "字段路径迁移 ", "新增包装层 ", "删除包装层 ",
|
||||||
"删除缓存写入点,原 value 类型: "
|
"新增缓存写入点,", "删除缓存写入点,原 value 类型: "
|
||||||
};
|
};
|
||||||
for (String prefix : knownPrefixes) {
|
for (String prefix : knownPrefixes) {
|
||||||
if (trimmed.startsWith(prefix)) {
|
if (trimmed.startsWith(prefix)) {
|
||||||
|
|||||||
@@ -12,23 +12,28 @@ import java.util.Set;
|
|||||||
/**
|
/**
|
||||||
* 在骨架 JSON 中为改动字段加企微颜色标注(仅改动片段染色,其余明文)。
|
* 在骨架 JSON 中为改动字段加企微颜色标注(仅改动片段染色,其余明文)。
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>删除字段 → 旧骨架,橙色 {@code warning}</li>
|
* <li>删除包装层 → 旧骨架优先整段标橙(先于子路径)</li>
|
||||||
* <li>新增字段 / 新增包装层 → 新骨架,绿色 {@code info}</li>
|
* <li>删除字段 / 路径迁移旧侧 → 旧骨架,橙色 {@code warning}</li>
|
||||||
* <li>路径迁移 → 旧路径橙、新路径绿</li>
|
* <li>新增包装层 → 新骨架优先整体标绿(先于子路径)</li>
|
||||||
|
* <li>新增字段 / 路径迁移新侧 → 新骨架,绿色 {@code info}</li>
|
||||||
|
* <li>类型变更(修改类)→ 新旧骨架均为灰色 {@code comment}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
final class SkeletonAnnotator {
|
final class SkeletonAnnotator {
|
||||||
|
|
||||||
/** 企微橙:删除 / 旧侧变更 */
|
/** 企微橙:删除 / 路径迁移旧侧 / 删除包装层 */
|
||||||
static final String COLOR_REMOVE = "warning";
|
static final String COLOR_REMOVE = "warning";
|
||||||
/** 企微绿:新增 / 新侧变更 */
|
/** 企微绿:新增 / 新侧路径迁移 */
|
||||||
static final String COLOR_ADD = "info";
|
static final String COLOR_ADD = "info";
|
||||||
|
/** 企微灰:类型变更(修改类) */
|
||||||
|
static final String COLOR_TYPE = "comment";
|
||||||
|
|
||||||
private static final String FONT_CLOSE = "</font>";
|
private static final String FONT_CLOSE = "</font>";
|
||||||
|
|
||||||
private SkeletonAnnotator() {
|
private SkeletonAnnotator() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 旧骨架应橙色标注的路径:删除、路径迁移旧侧(不含类型变更与包装层删除)。 */
|
||||||
static Set<String> pathsForOldSkeleton(List<SchemaChange> details) {
|
static Set<String> pathsForOldSkeleton(List<SchemaChange> details) {
|
||||||
Set<String> paths = new LinkedHashSet<>();
|
Set<String> paths = new LinkedHashSet<>();
|
||||||
if (details == null) {
|
if (details == null) {
|
||||||
@@ -47,6 +52,35 @@ final class SkeletonAnnotator {
|
|||||||
return paths;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 删除包装层路径(旧骨架优先整段标橙)。 */
|
||||||
|
static Set<String> pathsForWrapperRemoved(List<SchemaChange> details) {
|
||||||
|
Set<String> paths = new LinkedHashSet<>();
|
||||||
|
if (details == null) {
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
for (SchemaChange c : details) {
|
||||||
|
if (c != null && c.getChangeType() == ChangeType.WRAPPER_REMOVED) {
|
||||||
|
addIfPresent(paths, c.getFieldPath());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增包装层路径(新骨架优先整段标绿)。 */
|
||||||
|
static Set<String> pathsForWrapperAdded(List<SchemaChange> details) {
|
||||||
|
Set<String> paths = new LinkedHashSet<>();
|
||||||
|
if (details == null) {
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
for (SchemaChange c : details) {
|
||||||
|
if (c != null && c.getChangeType() == ChangeType.WRAPPER_ADDED) {
|
||||||
|
addIfPresent(paths, c.getFieldPath());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新骨架应绿色标注的路径:新增字段、路径迁移新侧(不含包装层与类型变更)。 */
|
||||||
static Set<String> pathsForNewSkeleton(List<SchemaChange> details) {
|
static Set<String> pathsForNewSkeleton(List<SchemaChange> details) {
|
||||||
Set<String> paths = new LinkedHashSet<>();
|
Set<String> paths = new LinkedHashSet<>();
|
||||||
if (details == null) {
|
if (details == null) {
|
||||||
@@ -56,8 +90,7 @@ final class SkeletonAnnotator {
|
|||||||
if (c == null || c.getChangeType() == null) {
|
if (c == null || c.getChangeType() == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (c.getChangeType() == ChangeType.FIELD_ADDED
|
if (c.getChangeType() == ChangeType.FIELD_ADDED) {
|
||||||
|| c.getChangeType() == ChangeType.WRAPPER_ADDED) {
|
|
||||||
addIfPresent(paths, c.getFieldPath());
|
addIfPresent(paths, c.getFieldPath());
|
||||||
} else if (c.getChangeType() == ChangeType.FIELD_PATH_MOVED) {
|
} else if (c.getChangeType() == ChangeType.FIELD_PATH_MOVED) {
|
||||||
addIfPresent(paths, c.getFieldPath());
|
addIfPresent(paths, c.getFieldPath());
|
||||||
@@ -69,23 +102,68 @@ final class SkeletonAnnotator {
|
|||||||
return paths;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 类型变更路径:新旧骨架均用灰色标注。 */
|
||||||
* 标注旧骨架改动字段(删除 → 橙色)。
|
static Set<String> pathsForTypeChanged(List<SchemaChange> details) {
|
||||||
*/
|
Set<String> paths = new LinkedHashSet<>();
|
||||||
static String annotateOldForWecom(String json, Set<String> highlightPaths) {
|
if (details == null) {
|
||||||
return annotateForWecom(json, highlightPaths, COLOR_REMOVE);
|
return paths;
|
||||||
|
}
|
||||||
|
for (SchemaChange c : details) {
|
||||||
|
if (c != null && c.getChangeType() == ChangeType.TYPE_CHANGED) {
|
||||||
|
addIfPresent(paths, c.getFieldPath());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标注新骨架改动字段(新增 → 绿色)。
|
* 标注旧骨架:删除等橙色;类型变更灰色。
|
||||||
*/
|
*/
|
||||||
static String annotateNewForWecom(String json, Set<String> highlightPaths) {
|
static String annotateOldForWecom(String json, Set<String> orangePaths) {
|
||||||
return annotateForWecom(json, highlightPaths, COLOR_ADD);
|
return annotateOldForWecom(json, orangePaths, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String annotateOldForWecom(String json, Set<String> orangePaths, Set<String> grayPaths) {
|
||||||
|
return annotateOldForWecom(json, orangePaths, grayPaths, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param wrapperRemovedPaths 删除的包装层,优先于普通橙路径标注
|
||||||
|
*/
|
||||||
|
static String annotateOldForWecom(String json, Set<String> orangePaths,
|
||||||
|
Set<String> grayPaths, Set<String> wrapperRemovedPaths) {
|
||||||
|
String result = annotateForWecom(json, grayPaths, COLOR_TYPE);
|
||||||
|
result = annotateForWecom(result, wrapperRemovedPaths, COLOR_REMOVE);
|
||||||
|
return annotateForWecom(result, orangePaths, COLOR_REMOVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标注新骨架:类型变更灰色,新增绿色。
|
||||||
|
*/
|
||||||
|
static String annotateNewForWecom(String json, Set<String> greenPaths) {
|
||||||
|
return annotateNewForWecom(json, greenPaths, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标注新骨架(类型变更 → 灰色;包装层先绿;其余新增/迁移 → 绿)。
|
||||||
|
*/
|
||||||
|
static String annotateNewForWecom(String json, Set<String> greenPaths, Set<String> grayTypePaths) {
|
||||||
|
return annotateNewForWecom(json, greenPaths, grayTypePaths, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param wrapperPaths 包装层路径,优先于 {@code greenPaths} 标注,使 {@code "vo":{...}} 能整段染色
|
||||||
|
*/
|
||||||
|
static String annotateNewForWecom(String json, Set<String> greenPaths,
|
||||||
|
Set<String> grayTypePaths, Set<String> wrapperPaths) {
|
||||||
|
String result = annotateForWecom(json, grayTypePaths, COLOR_TYPE);
|
||||||
|
result = annotateForWecom(result, wrapperPaths, COLOR_ADD);
|
||||||
|
return annotateForWecom(result, greenPaths, COLOR_ADD);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仅标注改动字段:其余正文保持普通文本。
|
* 仅标注改动字段:其余正文保持普通文本。
|
||||||
* 改动片段格式:{@code <font color="warning|info">"field":value</font>}
|
* 改动片段格式:{@code <font color="warning|info|comment">"field":value</font>}
|
||||||
*/
|
*/
|
||||||
static String annotateForWecom(String json, Set<String> highlightPaths) {
|
static String annotateForWecom(String json, Set<String> highlightPaths) {
|
||||||
return annotateForWecom(json, highlightPaths, COLOR_REMOVE);
|
return annotateForWecom(json, highlightPaths, COLOR_REMOVE);
|
||||||
|
|||||||
@@ -21,12 +21,11 @@ public class JavaSchemaExtractor {
|
|||||||
|
|
||||||
private static final Set<String> STRING_TYPES = new HashSet<>(Arrays.asList(
|
private static final Set<String> STRING_TYPES = new HashSet<>(Arrays.asList(
|
||||||
"String", "CharSequence", "char", "Character", "UUID",
|
"String", "CharSequence", "char", "Character", "UUID",
|
||||||
"Date", "LocalDate", "LocalDateTime", "LocalTime", "Instant", "Timestamp",
|
"Date", "LocalDate", "LocalDateTime", "LocalTime", "Instant", "Timestamp"));
|
||||||
"BigDecimal"));
|
|
||||||
private static final Set<String> NUMBER_TYPES = new HashSet<>(Arrays.asList(
|
private static final Set<String> NUMBER_TYPES = new HashSet<>(Arrays.asList(
|
||||||
"int", "long", "short", "byte", "double", "float",
|
"int", "long", "short", "byte", "double", "float",
|
||||||
"Integer", "Long", "Short", "Byte", "Double", "Float",
|
"Integer", "Long", "Short", "Byte", "Double", "Float",
|
||||||
"Number", "BigInteger", "AtomicInteger", "AtomicLong"));
|
"Number", "BigInteger", "BigDecimal", "AtomicInteger", "AtomicLong"));
|
||||||
private static final Set<String> BOOLEAN_TYPES = new HashSet<>(Arrays.asList(
|
private static final Set<String> BOOLEAN_TYPES = new HashSet<>(Arrays.asList(
|
||||||
"boolean", "Boolean"));
|
"boolean", "Boolean"));
|
||||||
private static final Set<String> COLLECTION_TYPES = new HashSet<>(Arrays.asList(
|
private static final Set<String> COLLECTION_TYPES = new HashSet<>(Arrays.asList(
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ public class SkeletonJsonRenderer {
|
|||||||
private Node buildTree(TypeSchema schema) {
|
private Node buildTree(TypeSchema schema) {
|
||||||
Node root = new Node(JsonType.OBJECT);
|
Node root = new Node(JsonType.OBJECT);
|
||||||
for (FieldSchema field : schema.getFields().values()) {
|
for (FieldSchema field : schema.getFields().values()) {
|
||||||
putPath(root, field.getPath(), field.getJsonType());
|
putPath(root, field.getPath(), field.getJsonType(), field.getJavaType());
|
||||||
}
|
}
|
||||||
// 根数组:字段以 [] / [].xxx 记录
|
// 根数组:字段以 [] / [].xxx 记录
|
||||||
if (root.children.size() == 1 && root.children.containsKey("[]")) {
|
if (root.children.size() == 1 && root.children.containsKey("[]")) {
|
||||||
@@ -56,7 +56,7 @@ public class SkeletonJsonRenderer {
|
|||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void putPath(Node root, String path, JsonType type) {
|
private void putPath(Node root, String path, JsonType type, String javaType) {
|
||||||
List<Seg> segs = parsePath(path);
|
List<Seg> segs = parsePath(path);
|
||||||
if (segs.isEmpty()) {
|
if (segs.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
@@ -70,10 +70,9 @@ public class SkeletonJsonRenderer {
|
|||||||
arr.type = JsonType.ARRAY;
|
arr.type = JsonType.ARRAY;
|
||||||
Node elem = arr.children.computeIfAbsent("[]", k -> new Node(JsonType.OBJECT));
|
Node elem = arr.children.computeIfAbsent("[]", k -> new Node(JsonType.OBJECT));
|
||||||
if (last) {
|
if (last) {
|
||||||
if (type == JsonType.OBJECT || type == JsonType.ARRAY || type == JsonType.MAP) {
|
|
||||||
elem.type = type;
|
|
||||||
} else {
|
|
||||||
elem.type = type;
|
elem.type = type;
|
||||||
|
elem.javaType = javaType;
|
||||||
|
if (type != JsonType.OBJECT && type != JsonType.ARRAY && type != JsonType.MAP) {
|
||||||
elem.leaf = true;
|
elem.leaf = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,6 +82,7 @@ public class SkeletonJsonRenderer {
|
|||||||
k -> new Node(last ? type : JsonType.OBJECT));
|
k -> new Node(last ? type : JsonType.OBJECT));
|
||||||
if (last) {
|
if (last) {
|
||||||
child.type = type;
|
child.type = type;
|
||||||
|
child.javaType = javaType;
|
||||||
child.leaf = type != JsonType.OBJECT && type != JsonType.ARRAY && type != JsonType.MAP;
|
child.leaf = type != JsonType.OBJECT && type != JsonType.ARRAY && type != JsonType.MAP;
|
||||||
} else if (child.type != JsonType.ARRAY) {
|
} else if (child.type != JsonType.ARRAY) {
|
||||||
child.type = JsonType.OBJECT;
|
child.type = JsonType.OBJECT;
|
||||||
@@ -129,7 +129,7 @@ public class SkeletonJsonRenderer {
|
|||||||
return "[" + write(elem, elemPath, protectedPaths, compact) + "]";
|
return "[" + write(elem, elemPath, protectedPaths, compact) + "]";
|
||||||
}
|
}
|
||||||
if (node.leaf || isScalar(node.type)) {
|
if (node.leaf || isScalar(node.type)) {
|
||||||
return placeholder(node.type);
|
return placeholder(node.type, node.javaType);
|
||||||
}
|
}
|
||||||
if (node.type == JsonType.MAP) {
|
if (node.type == JsonType.MAP) {
|
||||||
return "{}";
|
return "{}";
|
||||||
@@ -161,7 +161,7 @@ public class SkeletonJsonRenderer {
|
|||||||
} else if (child.type == JsonType.ARRAY) {
|
} else if (child.type == JsonType.ARRAY) {
|
||||||
sb.append(write(child, childPath, protectedPaths, compact));
|
sb.append(write(child, childPath, protectedPaths, compact));
|
||||||
} else if (child.leaf || isScalar(child.type)) {
|
} else if (child.leaf || isScalar(child.type)) {
|
||||||
sb.append(placeholder(child.type));
|
sb.append(placeholder(child.type, child.javaType));
|
||||||
} else {
|
} else {
|
||||||
sb.append(write(child, childPath, protectedPaths, compact));
|
sb.append(write(child, childPath, protectedPaths, compact));
|
||||||
}
|
}
|
||||||
@@ -177,7 +177,7 @@ public class SkeletonJsonRenderer {
|
|||||||
if (child.type == JsonType.OBJECT || child.type == JsonType.MAP) {
|
if (child.type == JsonType.OBJECT || child.type == JsonType.MAP) {
|
||||||
return "\"...\"";
|
return "\"...\"";
|
||||||
}
|
}
|
||||||
return placeholder(child.type);
|
return placeholder(child.type, child.javaType);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isProtectedUnder(String pathPrefix, Set<String> protectedPaths) {
|
private boolean isProtectedUnder(String pathPrefix, Set<String> protectedPaths) {
|
||||||
@@ -327,13 +327,17 @@ public class SkeletonJsonRenderer {
|
|||||||
|| type == JsonType.BOOLEAN || type == JsonType.UNKNOWN;
|
|| type == JsonType.BOOLEAN || type == JsonType.UNKNOWN;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String placeholder(JsonType type) {
|
/**
|
||||||
|
* 数字类型示意占位(合法 JSON number):整数 {@code 0}、浮点 {@code 0.0}、BigDecimal {@code 0.00}。
|
||||||
|
* 仅用于通知可读性,不代表真实精度/scale。
|
||||||
|
*/
|
||||||
|
private String placeholder(JsonType type, String javaType) {
|
||||||
if (type == null) {
|
if (type == null) {
|
||||||
return "null";
|
return "null";
|
||||||
}
|
}
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case NUMBER:
|
case NUMBER:
|
||||||
return "0";
|
return numberPlaceholder(javaType);
|
||||||
case BOOLEAN:
|
case BOOLEAN:
|
||||||
return "false";
|
return "false";
|
||||||
case STRING:
|
case STRING:
|
||||||
@@ -349,6 +353,37 @@ public class SkeletonJsonRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String numberPlaceholder(String javaType) {
|
||||||
|
String simple = simpleJavaType(javaType);
|
||||||
|
if ("BigDecimal".equals(simple)) {
|
||||||
|
return "0.00";
|
||||||
|
}
|
||||||
|
if ("float".equals(simple) || "Float".equals(simple)
|
||||||
|
|| "double".equals(simple) || "Double".equals(simple)) {
|
||||||
|
return "0.0";
|
||||||
|
}
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String simpleJavaType(String javaType) {
|
||||||
|
if (javaType == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String s = javaType.trim();
|
||||||
|
if (s.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
int lt = s.indexOf('<');
|
||||||
|
if (lt > 0) {
|
||||||
|
s = s.substring(0, lt).trim();
|
||||||
|
}
|
||||||
|
int dot = s.lastIndexOf('.');
|
||||||
|
if (dot >= 0 && dot < s.length() - 1) {
|
||||||
|
s = s.substring(dot + 1);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
private String escape(String s) {
|
private String escape(String s) {
|
||||||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||||
}
|
}
|
||||||
@@ -365,6 +400,7 @@ public class SkeletonJsonRenderer {
|
|||||||
|
|
||||||
private static final class Node {
|
private static final class Node {
|
||||||
JsonType type;
|
JsonType type;
|
||||||
|
String javaType;
|
||||||
boolean leaf;
|
boolean leaf;
|
||||||
final Map<String, Node> children = new LinkedHashMap<>();
|
final Map<String, Node> children = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,41 @@ class SchemaDifferTest {
|
|||||||
assertEquals(2, moved, "dbName 与 linkList[].id 均应迁移");
|
assertEquals(2, moved, "dbName 与 linkList[].id 均应迁移");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsWrapperRemovalAndUpwardMoves() {
|
||||||
|
TypeSchema oldS = new TypeSchema("CacheEnvelope");
|
||||||
|
oldS.add(new FieldSchema("vo", JsonType.OBJECT, "TenantVO"));
|
||||||
|
oldS.add(new FieldSchema("vo.dbName", JsonType.STRING, "String"));
|
||||||
|
oldS.add(new FieldSchema("vo.linkList", JsonType.ARRAY, "List"));
|
||||||
|
oldS.add(new FieldSchema("vo.linkList[]", JsonType.OBJECT, "TenantLinkModel"));
|
||||||
|
oldS.add(new FieldSchema("vo.linkList[].id", JsonType.STRING, "String"));
|
||||||
|
|
||||||
|
TypeSchema newS = new TypeSchema("TenantVO");
|
||||||
|
newS.add(new FieldSchema("dbName", JsonType.STRING, "String"));
|
||||||
|
newS.add(new FieldSchema("linkList", JsonType.ARRAY, "List"));
|
||||||
|
newS.add(new FieldSchema("linkList[]", JsonType.OBJECT, "TenantLinkModel"));
|
||||||
|
newS.add(new FieldSchema("linkList[].id", JsonType.STRING, "String"));
|
||||||
|
|
||||||
|
List<SchemaChange> changes = new SchemaDiffer().diff(oldS, newS);
|
||||||
|
List<ChangeType> types = changes.stream().map(SchemaChange::getChangeType).collect(Collectors.toList());
|
||||||
|
|
||||||
|
assertTrue(types.contains(ChangeType.WRAPPER_REMOVED), "应检测到包装层删除");
|
||||||
|
assertTrue(types.contains(ChangeType.FIELD_PATH_MOVED), "应检测到字段上提迁移");
|
||||||
|
assertTrue(types.stream().noneMatch(t -> t == ChangeType.FIELD_ADDED),
|
||||||
|
"上提字段不应再被当成新增");
|
||||||
|
assertTrue(types.stream().noneMatch(t -> t == ChangeType.FIELD_REMOVED),
|
||||||
|
"上提字段不应再被当成删除");
|
||||||
|
|
||||||
|
SchemaChange wrapper = changes.stream()
|
||||||
|
.filter(c -> c.getChangeType() == ChangeType.WRAPPER_REMOVED)
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(IllegalStateException::new);
|
||||||
|
assertEquals("vo", wrapper.getFieldPath());
|
||||||
|
|
||||||
|
long moved = changes.stream().filter(c -> c.getChangeType() == ChangeType.FIELD_PATH_MOVED).count();
|
||||||
|
assertEquals(2, moved, "vo.dbName / vo.linkList[].id 均应上提");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void detectsTypeChange() {
|
void detectsTypeChange() {
|
||||||
TypeSchema oldS = new TypeSchema("A");
|
TypeSchema oldS = new TypeSchema("A");
|
||||||
@@ -51,6 +86,57 @@ class SchemaDifferTest {
|
|||||||
assertEquals(1, changes.size());
|
assertEquals(1, changes.size());
|
||||||
assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType());
|
assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType());
|
||||||
assertEquals(Severity.P0, changes.get(0).getSeverity());
|
assertEquals(Severity.P0, changes.get(0).getSeverity());
|
||||||
|
assertTrue(changes.get(0).getMessage().contains("String"));
|
||||||
|
assertTrue(changes.get(0).getMessage().contains("Integer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsBigDecimalToStringEvenWhenBothWereStringBucketHistorically() {
|
||||||
|
// 即便 JsonType 同为 STRING,javaType 变化也必须检出(用户真实漏报场景)
|
||||||
|
TypeSchema oldS = new TypeSchema("A");
|
||||||
|
oldS.add(new FieldSchema("amount", JsonType.STRING, "BigDecimal"));
|
||||||
|
TypeSchema newS = new TypeSchema("A");
|
||||||
|
newS.add(new FieldSchema("amount", JsonType.STRING, "String"));
|
||||||
|
|
||||||
|
List<SchemaChange> changes = new SchemaDiffer().diff(oldS, newS);
|
||||||
|
assertEquals(1, changes.size());
|
||||||
|
assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType());
|
||||||
|
assertEquals("amount", changes.get(0).getFieldPath());
|
||||||
|
assertTrue(changes.get(0).getOldValue().contains("BigDecimal"));
|
||||||
|
assertTrue(changes.get(0).getNewValue().contains("String"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsBigDecimalToStringViaJsonTypeAndJavaType() {
|
||||||
|
TypeSchema oldS = new TypeSchema("A");
|
||||||
|
oldS.add(new FieldSchema("amount", JsonType.NUMBER, "BigDecimal"));
|
||||||
|
TypeSchema newS = new TypeSchema("A");
|
||||||
|
newS.add(new FieldSchema("amount", JsonType.STRING, "String"));
|
||||||
|
|
||||||
|
List<SchemaChange> changes = new SchemaDiffer().diff(oldS, newS);
|
||||||
|
assertEquals(1, changes.size());
|
||||||
|
assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsIntegerToLongWithinNumberBucket() {
|
||||||
|
TypeSchema oldS = new TypeSchema("A");
|
||||||
|
oldS.add(new FieldSchema("id", JsonType.NUMBER, "Integer"));
|
||||||
|
TypeSchema newS = new TypeSchema("A");
|
||||||
|
newS.add(new FieldSchema("id", JsonType.NUMBER, "Long"));
|
||||||
|
|
||||||
|
List<SchemaChange> changes = new SchemaDiffer().diff(oldS, newS);
|
||||||
|
assertEquals(1, changes.size());
|
||||||
|
assertEquals(ChangeType.TYPE_CHANGED, changes.get(0).getChangeType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void noChangeWhenPrimitiveAndWrapperEquivalent() {
|
||||||
|
TypeSchema a = new TypeSchema("A");
|
||||||
|
a.add(new FieldSchema("n", JsonType.NUMBER, "int"));
|
||||||
|
TypeSchema b = new TypeSchema("A");
|
||||||
|
b.add(new FieldSchema("n", JsonType.NUMBER, "Integer"));
|
||||||
|
assertTrue(new SchemaDiffer().diff(a, b).isEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -65,4 +65,91 @@ class RedisKeyResolverTest {
|
|||||||
buildCall, clazz, index.get("jnpf.util.TenantDbContentCacheHelper"));
|
buildCall, clazz, index.get("jnpf.util.TenantDbContentCacheHelper"));
|
||||||
assertEquals("tenant:db:content:*", pattern);
|
assertEquals("tenant:db:content:*", pattern);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesLocalVariableAssignedFromStringFormat() {
|
||||||
|
String source = ""
|
||||||
|
+ "package demo;\n"
|
||||||
|
+ "public class CombinationRepo {\n"
|
||||||
|
+ " private static final String COMBINATION_QUERY_KEY = "
|
||||||
|
+ "\"data_analysis:combination_query_key:%s:%s\";\n"
|
||||||
|
+ " public void write(String tenantId, String hashKey) {\n"
|
||||||
|
+ " String key = String.format(COMBINATION_QUERY_KEY, tenantId, hashKey);\n"
|
||||||
|
+ " redisSet(key);\n"
|
||||||
|
+ " }\n"
|
||||||
|
+ " void redisSet(String k) {}\n"
|
||||||
|
+ "}\n";
|
||||||
|
|
||||||
|
SourceIndex index = new SourceIndex();
|
||||||
|
index.addSource(source);
|
||||||
|
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||||
|
ClassOrInterfaceDeclaration clazz = cu.getType(0).asClassOrInterfaceDeclaration();
|
||||||
|
// redisSet(key) 的实参 key —— 局部变量使用点
|
||||||
|
MethodCallExpr redisSet = cu.findAll(MethodCallExpr.class).stream()
|
||||||
|
.filter(m -> "redisSet".equals(m.getNameAsString()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(IllegalStateException::new);
|
||||||
|
Expression keyArg = redisSet.getArgument(0);
|
||||||
|
|
||||||
|
String pattern = new RedisKeyResolver(index).resolve(
|
||||||
|
keyArg, clazz, index.get("demo.CombinationRepo"));
|
||||||
|
assertEquals("data_analysis:combination_query_key:*:*", pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void localReassignmentUsesNearestBindingBeforeUse() {
|
||||||
|
String source = ""
|
||||||
|
+ "package demo;\n"
|
||||||
|
+ "public class ReassignRepo {\n"
|
||||||
|
+ " private static final String A = \"prefix:a:%s\";\n"
|
||||||
|
+ " private static final String B = \"prefix:b:%s\";\n"
|
||||||
|
+ " public void write(String id) {\n"
|
||||||
|
+ " String key = String.format(A, id);\n"
|
||||||
|
+ " key = String.format(B, id);\n"
|
||||||
|
+ " redisSet(key);\n"
|
||||||
|
+ " }\n"
|
||||||
|
+ " void redisSet(String k) {}\n"
|
||||||
|
+ "}\n";
|
||||||
|
|
||||||
|
SourceIndex index = new SourceIndex();
|
||||||
|
index.addSource(source);
|
||||||
|
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||||
|
ClassOrInterfaceDeclaration clazz = cu.getType(0).asClassOrInterfaceDeclaration();
|
||||||
|
MethodCallExpr redisSet = cu.findAll(MethodCallExpr.class).stream()
|
||||||
|
.filter(m -> "redisSet".equals(m.getNameAsString()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(IllegalStateException::new);
|
||||||
|
|
||||||
|
String pattern = new RedisKeyResolver(index).resolve(
|
||||||
|
redisSet.getArgument(0), clazz, index.get("demo.ReassignRepo"));
|
||||||
|
assertEquals("prefix:b:*", pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void staticConstantStillPreferredOverLocalNameCollision() {
|
||||||
|
// 局部变量名与静态常量同名时:仍优先静态常量(保持旧语义)
|
||||||
|
String source = ""
|
||||||
|
+ "package demo;\n"
|
||||||
|
+ "public class Collision {\n"
|
||||||
|
+ " private static final String KEY = \"const:prefix:%s\";\n"
|
||||||
|
+ " public void write(String id) {\n"
|
||||||
|
+ " String KEY = \"local:\" + id;\n"
|
||||||
|
+ " redisSet(KEY);\n"
|
||||||
|
+ " }\n"
|
||||||
|
+ " void redisSet(String k) {}\n"
|
||||||
|
+ "}\n";
|
||||||
|
|
||||||
|
SourceIndex index = new SourceIndex();
|
||||||
|
index.addSource(source);
|
||||||
|
CompilationUnit cu = StaticJavaParser.parse(source);
|
||||||
|
ClassOrInterfaceDeclaration clazz = cu.getType(0).asClassOrInterfaceDeclaration();
|
||||||
|
MethodCallExpr redisSet = cu.findAll(MethodCallExpr.class).stream()
|
||||||
|
.filter(m -> "redisSet".equals(m.getNameAsString()))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(IllegalStateException::new);
|
||||||
|
|
||||||
|
String pattern = new RedisKeyResolver(index).resolve(
|
||||||
|
redisSet.getArgument(0), clazz, index.get("demo.Collision"));
|
||||||
|
assertEquals("const:prefix:%s", pattern);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.codechecker.cache.diff.SchemaChange;
|
|||||||
import com.codechecker.cache.diff.Severity;
|
import com.codechecker.cache.diff.Severity;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -60,8 +61,8 @@ class ReportBuilderTest {
|
|||||||
|
|
||||||
assertTrue(md.contains("- Key --> `saas:period-config:migration:current`"));
|
assertTrue(md.contains("- Key --> `saas:period-config:migration:current`"));
|
||||||
assertFalse(md.contains("(key 无法解析)"));
|
assertFalse(md.contains("(key 无法解析)"));
|
||||||
assertTrue(md.contains("> **位置**: `SaasPeriodConfigMigrationRedisSupport#putCurrent:41`"));
|
assertTrue(md.contains("> **位置**: SaasPeriodConfigMigrationRedisSupport#putCurrent:41"));
|
||||||
assertTrue(md.contains("> **类型**: `MigrationCurrentVo`"));
|
assertTrue(md.contains("> **类型**: MigrationCurrentVo"));
|
||||||
|
|
||||||
int oldSection = md.indexOf("> **value值由:**");
|
int oldSection = md.indexOf("> **value值由:**");
|
||||||
int newSection = md.indexOf("> **变更为:**");
|
int newSection = md.indexOf("> **变更为:**");
|
||||||
@@ -111,6 +112,174 @@ class ReportBuilderTest {
|
|||||||
assertFalse(newMd.startsWith("`"));
|
assertFalse(newMd.startsWith("`"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void typeChangedHighlightedGrayInBothSkeletons() {
|
||||||
|
SchemaChange typeChanged = new SchemaChange(ChangeType.TYPE_CHANGED);
|
||||||
|
typeChanged.setFieldPath("amount");
|
||||||
|
typeChanged.setOldValue("BigDecimal/NUMBER");
|
||||||
|
typeChanged.setNewValue("String/STRING");
|
||||||
|
typeChanged.setMessage("字段 amount 类型由 BigDecimal/NUMBER 变为 String/STRING");
|
||||||
|
|
||||||
|
String oldJson = "{\"amount\":0,\"name\":\"\"}";
|
||||||
|
String newJson = "{\"amount\":\"\",\"name\":\"\"}";
|
||||||
|
List<SchemaChange> details = Collections.singletonList(typeChanged);
|
||||||
|
|
||||||
|
Set<String> oldPaths = SkeletonAnnotator.pathsForOldSkeleton(details);
|
||||||
|
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(details);
|
||||||
|
Set<String> typeGray = SkeletonAnnotator.pathsForTypeChanged(details);
|
||||||
|
|
||||||
|
assertTrue(oldPaths.isEmpty());
|
||||||
|
assertTrue(newGreen.isEmpty());
|
||||||
|
assertEquals(Collections.singleton("amount"), typeGray);
|
||||||
|
|
||||||
|
String oldMd = SkeletonAnnotator.annotateOldForWecom(oldJson, oldPaths, typeGray);
|
||||||
|
String newMd = SkeletonAnnotator.annotateNewForWecom(newJson, newGreen, typeGray);
|
||||||
|
|
||||||
|
assertTrue(oldMd.contains("<font color=\"comment\">\"amount\":0</font>"));
|
||||||
|
assertFalse(oldMd.contains("<font color=\"warning\">\"amount\""));
|
||||||
|
assertFalse(oldMd.contains("<font color=\"comment\">\"name\":\"\"</font>"));
|
||||||
|
assertTrue(newMd.contains("<font color=\"comment\">\"amount\":\"\"</font>"));
|
||||||
|
assertFalse(newMd.contains("<font color=\"info\">\"amount\""));
|
||||||
|
assertFalse(newMd.contains("<font color=\"warning\">\"amount\""));
|
||||||
|
assertFalse(newMd.contains("<font color=\"comment\">\"name\":\"\"</font>"));
|
||||||
|
|
||||||
|
CheckReport report = baseReport();
|
||||||
|
KeyStructureChange key = new KeyStructureChange();
|
||||||
|
key.setKeyPattern("cache:amount");
|
||||||
|
key.setWriteLocation("Demo#put:1");
|
||||||
|
key.setValueType("AmountVo");
|
||||||
|
key.setOldSkeletonJson(oldJson);
|
||||||
|
key.setNewSkeletonJson(newJson);
|
||||||
|
key.getFieldDetails().add(typeChanged);
|
||||||
|
report.getKeyChanges().add(key);
|
||||||
|
|
||||||
|
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
|
||||||
|
int oldSection = md.indexOf("> **value值由:**");
|
||||||
|
int newSection = md.indexOf("> **变更为:**");
|
||||||
|
assertTrue(oldSection >= 0);
|
||||||
|
assertTrue(newSection > oldSection);
|
||||||
|
String oldPart = md.substring(oldSection, newSection);
|
||||||
|
String newPart = md.substring(newSection);
|
||||||
|
assertTrue(oldPart.contains("<font color=\"comment\">\"amount\":0</font>"));
|
||||||
|
assertTrue(newPart.contains("<font color=\"comment\">\"amount\":\"\"</font>"));
|
||||||
|
assertFalse(newPart.contains("<font color=\"info\">\"amount\""));
|
||||||
|
assertFalse(oldPart.contains("<font color=\"warning\">\"amount\""));
|
||||||
|
assertTrue(md.contains("> **类型变更**: amount <font color=\"warning\">BigDecimal → String</font>"),
|
||||||
|
"应包含类型变更摘要行,实际 markdown:\n" + md);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wrapperAddedHighlightedBeforeNestedMovedFields() {
|
||||||
|
SchemaChange wrapper = new SchemaChange(ChangeType.WRAPPER_ADDED);
|
||||||
|
wrapper.setFieldPath("vo");
|
||||||
|
wrapper.setMessage("新增包装层 vo");
|
||||||
|
|
||||||
|
SchemaChange movedDb = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
|
||||||
|
movedDb.setFieldPath("vo.dbName");
|
||||||
|
movedDb.setOldValue("dbName");
|
||||||
|
movedDb.setNewValue("vo.dbName");
|
||||||
|
|
||||||
|
SchemaChange movedLink = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
|
||||||
|
movedLink.setFieldPath("vo.linkList[].id");
|
||||||
|
movedLink.setOldValue("linkList[].id");
|
||||||
|
movedLink.setNewValue("vo.linkList[].id");
|
||||||
|
|
||||||
|
List<SchemaChange> details = new ArrayList<>();
|
||||||
|
details.add(wrapper);
|
||||||
|
details.add(movedDb);
|
||||||
|
details.add(movedLink);
|
||||||
|
|
||||||
|
String oldJson = "{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}";
|
||||||
|
String newJson = "{\"vo\":{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}}";
|
||||||
|
|
||||||
|
Set<String> wrappers = SkeletonAnnotator.pathsForWrapperAdded(details);
|
||||||
|
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(details);
|
||||||
|
assertEquals(Collections.singleton("vo"), wrappers);
|
||||||
|
assertTrue(newGreen.contains("vo.dbName"));
|
||||||
|
assertFalse(newGreen.contains("vo"), "包装层不应再混入普通新增路径");
|
||||||
|
|
||||||
|
// 先标包装层:整段 vo 对象应绿;子路径随后因已在 font 内可跳过
|
||||||
|
String newMd = SkeletonAnnotator.annotateNewForWecom(newJson, newGreen, null, wrappers);
|
||||||
|
assertTrue(newMd.contains("<font color=\"info\">\"vo\":{"), newMd);
|
||||||
|
assertTrue(newMd.contains("</font>"), newMd);
|
||||||
|
|
||||||
|
CheckReport report = baseReport();
|
||||||
|
KeyStructureChange key = new KeyStructureChange();
|
||||||
|
key.setKeyPattern("tenant:db:content:*");
|
||||||
|
key.setWriteLocation("TenantDbContentCacheHelper#cacheNotFound:62");
|
||||||
|
key.setValueType("TenantVO");
|
||||||
|
key.setOldSkeletonJson(oldJson);
|
||||||
|
key.setNewSkeletonJson(newJson);
|
||||||
|
key.getFieldDetails().addAll(details);
|
||||||
|
report.getKeyChanges().add(key);
|
||||||
|
|
||||||
|
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
|
||||||
|
int newSection = md.indexOf("> **变更为:**");
|
||||||
|
assertTrue(newSection >= 0);
|
||||||
|
String newPart = md.substring(newSection);
|
||||||
|
assertTrue(newPart.contains("<font color=\"info\">\"vo\":{"),
|
||||||
|
"包装层 vo 应整段绿色,实际:\n" + newPart);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wrapperRemovedHighlightedBeforeNestedMovedFieldsOnOldSkeleton() {
|
||||||
|
SchemaChange wrapper = new SchemaChange(ChangeType.WRAPPER_REMOVED);
|
||||||
|
wrapper.setFieldPath("vo");
|
||||||
|
wrapper.setOldValue("vo");
|
||||||
|
wrapper.setMessage("删除包装层 vo");
|
||||||
|
|
||||||
|
SchemaChange movedDb = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
|
||||||
|
movedDb.setFieldPath("dbName");
|
||||||
|
movedDb.setOldValue("vo.dbName");
|
||||||
|
movedDb.setNewValue("dbName");
|
||||||
|
|
||||||
|
SchemaChange movedLink = new SchemaChange(ChangeType.FIELD_PATH_MOVED);
|
||||||
|
movedLink.setFieldPath("linkList[].id");
|
||||||
|
movedLink.setOldValue("vo.linkList[].id");
|
||||||
|
movedLink.setNewValue("linkList[].id");
|
||||||
|
|
||||||
|
List<SchemaChange> details = new ArrayList<>();
|
||||||
|
details.add(wrapper);
|
||||||
|
details.add(movedDb);
|
||||||
|
details.add(movedLink);
|
||||||
|
|
||||||
|
String oldJson = "{\"vo\":{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}}";
|
||||||
|
String newJson = "{\"dbName\":\"\",\"linkList\":[{\"id\":\"\"}]}";
|
||||||
|
|
||||||
|
Set<String> wrappersRemoved = SkeletonAnnotator.pathsForWrapperRemoved(details);
|
||||||
|
Set<String> oldOrange = SkeletonAnnotator.pathsForOldSkeleton(details);
|
||||||
|
Set<String> newGreen = SkeletonAnnotator.pathsForNewSkeleton(details);
|
||||||
|
|
||||||
|
assertEquals(Collections.singleton("vo"), wrappersRemoved);
|
||||||
|
assertTrue(oldOrange.contains("vo.dbName"));
|
||||||
|
assertTrue(newGreen.contains("dbName"));
|
||||||
|
assertFalse(newGreen.contains("vo"));
|
||||||
|
|
||||||
|
String oldMd = SkeletonAnnotator.annotateOldForWecom(oldJson, oldOrange, null, wrappersRemoved);
|
||||||
|
assertTrue(oldMd.contains("<font color=\"warning\">\"vo\":{"), oldMd);
|
||||||
|
|
||||||
|
String newMd = SkeletonAnnotator.annotateNewForWecom(newJson, newGreen, null, null);
|
||||||
|
assertTrue(newMd.contains("<font color=\"info\">\"dbName\":\"\"</font>"), newMd);
|
||||||
|
assertFalse(newMd.contains("<font color=\"info\">\"vo\""), newMd);
|
||||||
|
|
||||||
|
CheckReport report = baseReport();
|
||||||
|
KeyStructureChange key = new KeyStructureChange();
|
||||||
|
key.setKeyPattern("tenant:db:content:*");
|
||||||
|
key.setWriteLocation("TenantDbContentCacheHelper#cacheNotFound:62");
|
||||||
|
key.setValueType("TenantVO");
|
||||||
|
key.setOldSkeletonJson(oldJson);
|
||||||
|
key.setNewSkeletonJson(newJson);
|
||||||
|
key.getFieldDetails().addAll(details);
|
||||||
|
report.getKeyChanges().add(key);
|
||||||
|
|
||||||
|
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
|
||||||
|
int oldSection = md.indexOf("> **value值由:**");
|
||||||
|
int newSection = md.indexOf("> **变更为:**");
|
||||||
|
assertTrue(oldSection >= 0 && newSection > oldSection);
|
||||||
|
assertTrue(md.substring(oldSection, newSection).contains("<font color=\"warning\">\"vo\":{"),
|
||||||
|
"旧骨架应整段橙标 vo,实际:\n" + md);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void consoleContainsFieldDetailsAndWecomMarkdown() {
|
void consoleContainsFieldDetailsAndWecomMarkdown() {
|
||||||
CheckReport report = new CheckReport();
|
CheckReport report = new CheckReport();
|
||||||
@@ -138,8 +307,8 @@ class ReportBuilderTest {
|
|||||||
assertTrue(console.contains("**删除字段**: x"));
|
assertTrue(console.contains("**删除字段**: x"));
|
||||||
assertTrue(console.contains("<font color=\"warning\">\"x\":\"\"</font>"));
|
assertTrue(console.contains("<font color=\"warning\">\"x\":\"\"</font>"));
|
||||||
assertFalse(console.contains("<font color=\"info\">\"x\":\"\"</font>"));
|
assertFalse(console.contains("<font color=\"info\">\"x\":\"\"</font>"));
|
||||||
assertTrue(console.contains("> **位置**: `"));
|
assertTrue(console.contains("> **位置**: "));
|
||||||
assertTrue(console.contains("> **类型**: `"));
|
assertTrue(console.contains("> **类型**: "));
|
||||||
assertTrue(console.contains("> **value值由:**"));
|
assertTrue(console.contains("> **value值由:**"));
|
||||||
assertTrue(console.contains("> **变更为:**"));
|
assertTrue(console.contains("> **变更为:**"));
|
||||||
assertFalse(console.contains("`{\"x\":\"\"}`"));
|
assertFalse(console.contains("`{\"x\":\"\"}`"));
|
||||||
@@ -163,8 +332,8 @@ class ReportBuilderTest {
|
|||||||
|
|
||||||
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
|
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
|
||||||
assertTrue(md.contains("- Key --> `req.getKey()` <font color=\"comment\">(key 无法解析)</font>"));
|
assertTrue(md.contains("- Key --> `req.getKey()` <font color=\"comment\">(key 无法解析)</font>"));
|
||||||
assertTrue(md.contains("> **位置**: `ClockInXxxService#export:128`"));
|
assertTrue(md.contains("> **位置**: ClockInXxxService#export:128"));
|
||||||
assertTrue(md.contains("> **类型**: `List<ClockInExportVo>`"));
|
assertTrue(md.contains("> **类型**: List<ClockInExportVo>"));
|
||||||
assertFalse(md.contains("`unknown-key`"));
|
assertFalse(md.contains("`unknown-key`"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +385,24 @@ class ReportBuilderTest {
|
|||||||
assertTrue(console.contains("超长已按 key 拆为 2 条"));
|
assertTrue(console.contains("超长已按 key 拆为 2 条"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void wildcardAsteriskInKeyEscapedForWeCom() {
|
||||||
|
assertEquals("data_analysis:combination_query_key:*:*",
|
||||||
|
ReportBuilder.escapeWeComCode("data_analysis:combination_query_key:*:*"));
|
||||||
|
|
||||||
|
CheckReport report = baseReport();
|
||||||
|
KeyStructureChange key = simpleKey(
|
||||||
|
"data_analysis:combination_query_key:*:*",
|
||||||
|
"{\"a\":0}", "{\"a\":0,\"b\":0}", "b");
|
||||||
|
report.getKeyChanges().add(key);
|
||||||
|
|
||||||
|
String md = new ReportBuilder("[序列化结构变更]").toMarkdown(report);
|
||||||
|
assertTrue(md.contains("- Key --> `data_analysis:combination_query_key:*:*`"),
|
||||||
|
"通配符 * 应转成全角*,实际:\n" + md);
|
||||||
|
assertFalse(md.contains("combination_query_key:*:*"),
|
||||||
|
"不应再输出未转义的 *:*, 实际:\n" + md);
|
||||||
|
}
|
||||||
|
|
||||||
private static CheckReport baseReport() {
|
private static CheckReport baseReport() {
|
||||||
CheckReport report = new CheckReport();
|
CheckReport report = new CheckReport();
|
||||||
report.setRepository("jnpf-java-cloud");
|
report.setRepository("jnpf-java-cloud");
|
||||||
|
|||||||
@@ -81,4 +81,21 @@ class JavaSchemaExtractorTest {
|
|||||||
assertTrue(AnnotationSupport.ignoredProperties(type).contains("a"));
|
assertTrue(AnnotationSupport.ignoredProperties(type).contains("a"));
|
||||||
assertTrue(AnnotationSupport.ignoredProperties(type).contains("b"));
|
assertTrue(AnnotationSupport.ignoredProperties(type).contains("b"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bigDecimalMappedAsNumberNotString() {
|
||||||
|
String source = ""
|
||||||
|
+ "package demo;\n"
|
||||||
|
+ "import java.math.BigDecimal;\n"
|
||||||
|
+ "public class MoneyVo {\n"
|
||||||
|
+ " private BigDecimal amount;\n"
|
||||||
|
+ " private String currency;\n"
|
||||||
|
+ "}\n";
|
||||||
|
SourceIndex index = new SourceIndex();
|
||||||
|
index.addSource(source);
|
||||||
|
TypeSchema schema = new JavaSchemaExtractor(index, 8).extract("demo.MoneyVo");
|
||||||
|
assertEquals(JsonType.NUMBER, schema.getFields().get("amount").getJsonType());
|
||||||
|
assertEquals("BigDecimal", schema.getFields().get("amount").getJavaType());
|
||||||
|
assertEquals(JsonType.STRING, schema.getFields().get("currency").getJsonType());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,4 +80,31 @@ class SkeletonJsonRendererTest {
|
|||||||
"截断后仍应保留改动相关字段: " + truncated);
|
"截断后仍应保留改动相关字段: " + truncated);
|
||||||
assertEquals(true, truncated.length() >= 10);
|
assertEquals(true, truncated.length() >= 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void numberPlaceholdersDistinguishIntegerFloatAndBigDecimal() {
|
||||||
|
TypeSchema schema = new TypeSchema("MoneyVo");
|
||||||
|
schema.add(new FieldSchema("count", JsonType.NUMBER, "Integer"));
|
||||||
|
schema.add(new FieldSchema("rate", JsonType.NUMBER, "Double"));
|
||||||
|
schema.add(new FieldSchema("amount", JsonType.NUMBER, "BigDecimal"));
|
||||||
|
schema.add(new FieldSchema("score", JsonType.NUMBER, "Float"));
|
||||||
|
|
||||||
|
String json = new SkeletonJsonRenderer().render(schema);
|
||||||
|
assertTrue(json.contains("\"count\":0"), json);
|
||||||
|
assertTrue(json.contains("\"rate\":0.0"), json);
|
||||||
|
assertTrue(json.contains("\"amount\":0.00"), json);
|
||||||
|
assertTrue(json.contains("\"score\":0.0"), json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void integerToBigDecimalSkeletonsLookDifferent() {
|
||||||
|
TypeSchema oldS = new TypeSchema("A");
|
||||||
|
oldS.add(new FieldSchema("amount", JsonType.NUMBER, "Integer"));
|
||||||
|
TypeSchema newS = new TypeSchema("A");
|
||||||
|
newS.add(new FieldSchema("amount", JsonType.NUMBER, "BigDecimal"));
|
||||||
|
|
||||||
|
SkeletonJsonRenderer renderer = new SkeletonJsonRenderer();
|
||||||
|
assertEquals("{\"amount\":0}", renderer.render(oldS));
|
||||||
|
assertEquals("{\"amount\":0.00}", renderer.render(newS));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user