diff --git a/src/main/java/apijson/orm/AbstractSQLConfig.java b/src/main/java/apijson/orm/AbstractSQLConfig.java index ca47bdf2..6ef778b1 100755 --- a/src/main/java/apijson/orm/AbstractSQLConfig.java +++ b/src/main/java/apijson/orm/AbstractSQLConfig.java @@ -6585,9 +6585,30 @@ LEFT JOIN ( SELECT count(*) AS count FROM sys.Comment ) AS Comment ON Comment.m ///* SELECT count(*) AS count FROM sys.Moment AS Moment // LEFT JOIN ( SELECT * FROM sys.Comment ) AS Comment ON Comment.momentId = Moment.id LIMIT 1 OFFSET 0 */ if (column != null && joinConfig.isMSQL()) { // 暂时这样兼容 PostgreSQL 等不支持 SELECT 中不包含对应 key 的隐式 ON 关联字段的数据库 + // 当前副表自身的关联键(joinConfig 是当前副表) + Set columnSet = new LinkedHashSet<>(); for (On on : onList) { - column.add(on.getKey()); // TODO PostgreSQL 等需要找到具体的 targetTable 对应 targetKey 来加到 SELECT,比直接 SELECT * 性能更好 + columnSet.add(on.getKey()); } + // issue #824:多表 JOIN 时,其它副表的 ON 可能引用当前副表的字段(如 + // Warehose_info.id@ = /Location_info/warehouse_id 引用 Location_info.warehouse_id)。 + // 只收集当前副表自身的 on.getKey() 会漏掉被引用的字段,生成子查询缺列导致 Unknown column。 + // 这里把所有 JOIN 的 ON 里 target 指向当前副表的 targetKey 也补进 SELECT 列。 + for (Join other : joinList) { + List otherOnList = other.getOnList(); + if (otherOnList == null) { + continue; + } + // 当前副表的别名(含主表引用自身场景) + String currentAlias = SQLConfig.gainSQLAlias(table, alias); + for (On otherOn : otherOnList) { + if (currentAlias.equals(SQLConfig.gainSQLAlias(otherOn.getTargetTable(), otherOn.getTargetAlias()))) { + columnSet.add(otherOn.getTargetKey()); + } + } + } + column.addAll(columnSet); + // TODO PostgreSQL 等需要找到具体的 targetTable 对应 targetKey 来加到 SELECT,比直接 SELECT * 性能更好 } joinConfig.setMethod(GET); // 子查询不能为 SELECT count(*) ,而应该是 SELECT momentId diff --git a/src/test/java/apijson/orm/AbstractSQLConfigJoinCountTest.java b/src/test/java/apijson/orm/AbstractSQLConfigJoinCountTest.java new file mode 100644 index 00000000..2d98f551 --- /dev/null +++ b/src/test/java/apijson/orm/AbstractSQLConfigJoinCountTest.java @@ -0,0 +1,260 @@ +package apijson.orm; + +import apijson.JSON; +import apijson.JSONParser; +import apijson.RequestMethod; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertTrue; + +/** + * 复现 issue #824:多表 JOIN + HEAD(count 分页) 子查询字段缺失。 + * - PostgreSQL:LEFT JOIN 子查询回退 SELECT *(main 已修复原报告场景,此处作回归) + * - MySQL:子查询只选自身关联键,缺被其它 ON 引用的字段,仍会报 Unknown column(本次修复) + */ +public class AbstractSQLConfigJoinCountTest { + private static final String MAIN_TABLE = "Inventory"; + private static final String JOIN_TABLE = "Location_info"; + private static final String JOIN_TABLE2 = "Warehose_info"; + private static final String DATABASE = SQLConfig.DATABASE_POSTGRESQL; + + private static JSONParser, ? extends List> previousJSONParser; + + @BeforeClass + public static void installJSONParser() { + previousJSONParser = JSON.DEFAULT_JSON_PARSER; + JSON.DEFAULT_JSON_PARSER = new JSONParser, List>() { + @Override + public Map createJSONObject() { + return new LinkedHashMap<>(); + } + + @Override + public List createJSONArray() { + return new ArrayList<>(); + } + + @Override + public Object parse(Object json) { + return json; + } + + @Override + @SuppressWarnings("unchecked") + public Map parseObject(Object json) { + return (Map) json; + } + + @Override + public T parseObject(Object json, Class clazz) { + return clazz.cast(json); + } + + @Override + @SuppressWarnings("unchecked") + public List parseArray(Object json) { + return (List) json; + } + + @Override + @SuppressWarnings("unchecked") + public List parseArray(Object json, Class clazz) { + return (List) json; + } + + @Override + public String toJSONString(Object obj, boolean format) { + return String.valueOf(obj); + } + }; + } + + @AfterClass + public static void restoreJSONParser() { + JSON.DEFAULT_JSON_PARSER = previousJSONParser; + } + + /** + * 复现 issue #824 的 count 分页 SQL(PostgreSQL 场景): + * SELECT count(*) AS "count" FROM "Inventory" AS "Inventory" + * LEFT JOIN ( SELECT * FROM "Location_info" ) AS "Location_info" + * ON "Location_info"."code" = "Inventory"."location_code" + * LEFT JOIN ( SELECT * FROM "Warehose_info" ) AS "Warehose_info" + * ON "Warehose_info"."id" = "Location_info"."warehouse_id" + * + * main 分支已对 PostgreSQL 回退为 SELECT *(isMSQL 门控),子查询不缺列,PG 不再报错。 + * 本用例断言该行为(回归保护)。 + */ + @Test + public void headJoinCountPostgreSQLFallsBackToSelectStar() throws Exception { + String sql = buildHeadJoinSQL(); + + System.out.println("Generated SQL:\n" + sql + "\n"); + + // 断言:LEFT JOIN 子查询结构存在 + int leftJoin1 = sql.indexOf("LEFT JOIN ( SELECT"); + assertTrue("应为 LEFT JOIN 子查询结构: " + sql, leftJoin1 >= 0); + + // 副表1子查询部分(PG 应回退 SELECT *,天然包含 code + warehouse_id) + String sub1 = extractSubquery(sql, "Location_info"); + System.out.println("Subquery 1: " + sub1); + assertTrue("PG 下 Location_info 子查询应回退 SELECT *(含所有列,天然覆盖 ON 引用): " + sub1, + sub1.contains("SELECT *")); + + // 副表2子查询部分 + String sub2 = extractSubquery(sql, "Warehose_info"); + System.out.println("Subquery 2: " + sub2); + assertTrue("PG 下 Warehose_info 子查询应回退 SELECT *: " + sub2, + sub2.contains("SELECT *")); + + // ON 条件完整性 + assertTrue("ON 应引用 Location_info.code = Inventory.location_code: " + sql, + sql.contains("\"Location_info\".\"code\" = \"Inventory\".\"location_code\"")); + assertTrue("ON 应引用 Warehose_info.id = Location_info.warehouse_id: " + sql, + sql.contains("\"Warehose_info\".\"id\" = \"Location_info\".\"warehouse_id\"")); + } + + /** + * 回归:MySQL 下副表子查询只含自身关联键 code/id(isMSQL 门控逻辑)。 + * 本用例断言当前行为,暴露 MySQL 多 join 交叉引用 bug(第二个 ON 引用 + * Location_info.warehouse_id,但子查询没有该列 -> Unknown column)。 + */ + @Test + public void headJoinCountMySQLOnlySelectsSelfOnKey() throws Exception { + String sql = buildHeadJoinSQLWithDatabase(SQLConfig.DATABASE_MYSQL); + + System.out.println("MySQL SQL:\n" + sql + "\n"); + + String sub1 = extractSubquery(sql, "Location_info"); + assertTrue("MySQL 下 Location_info 子查询应包含 code: " + sub1, sub1.contains("`code`")); + // 关键:MySQL 下子查询缺 warehouse_id,ON 引用它 → 复现 Unknown column + assertTrue("MySQL 下 Location_info 子查询应包含 warehouse_id(issue #824 反向场景,被 ON 引用的列): " + sub1, + sub1.contains("`warehouse_id`")); + } + + private static String buildHeadJoinSQL() throws Exception { + return buildHeadJoinSQLWithDatabase(DATABASE); + } + + private static String extractSubquery(String sql, String table) { + String marker = "JOIN ( "; + int joinIdx = sql.indexOf(marker); + while (joinIdx >= 0) { + int closeIdx = sql.indexOf(" ) ", joinIdx); + if (closeIdx < 0) { + break; + } + String sub = sql.substring(joinIdx + marker.length(), closeIdx); + if (sub.contains(table)) { + return sub; + } + joinIdx = sql.indexOf(marker, closeIdx); + } + return ""; + } + + private static String buildHeadJoinSQLWithDatabase(String database) throws Exception { + // 主表 + Map mainRequest = new LinkedHashMap<>(); + + // join 副表1:Location_info.code@ = /Inventory/location_code + // 注意:request 中不能包含 @ 引用键(真实解析器把它们剥离进 onList), + // 只把 onList 作为唯一关联信息来源(与 AbstractParser.onJoinParse 一致) + Map joinRequest1 = new LinkedHashMap<>(); + + // join 副表2:Warehose_info.id@ = /Location_info/warehouse_id + Map joinRequest2 = new LinkedHashMap<>(); + + // 组装 joinList(on 定义显式传入,与 AbstractParser.onJoinParse 剥离后的 onList 等价) + List, List>> joinList = new ArrayList<>(); + joinList.add(newJoin(", List> callback = + new AbstractSQLConfig.SimpleCallback, List>() { + @Override + public SQLConfig, List> getSQLConfig(RequestMethod method, + String database, String datasource, String namespace, String catalog, String schema, + String table) { + return new AbstractSQLConfig, List>(method, table) { + @Override + public String gainDBVersion() { + return "16.0"; + } + + @Override + public String gainDBUri() { + return "jdbc:postgresql://localhost/test"; + } + + @Override + public String gainDBAccount() { + return "test"; + } + + @Override + public String gainDBPassword() { + return "test"; + } + }; + } + }; + + SQLConfig, List> config = + AbstractSQLConfig.newSQLConfig(RequestMethod.HEAD, MAIN_TABLE, null, mainRequest, null, false, callback); + config.setDatabase(database); + + config = AbstractSQLConfig.parseJoin(RequestMethod.HEAD, config, joinList, callback); + + return config.gainSQL(false); + } + + /**手工构建 Join(模拟 AbstractParser.onJoinParse 剥离 @ 引用键后的产物) + * @param path join 路径 , List> newJoin(String path, String table, + String originKey, String targetPath) throws Exception { + Join, List> j = new Join<>(); + j.setPath(path); + j.setJoinType("<"); + j.setTable(table); + j.setRequest(new LinkedHashMap<>()); // 真实解析器剥离 @ 引用键,request 为空 + + List onList = new ArrayList<>(); + Join.On on = new Join.On(); + // originKey = "code@" -> key = "code"(setKeyAndType 会剥掉 @) + on.setKeyAndType("<", table, originKey); + on.setOriginKey(originKey); + on.setOriginValue(targetPath); + + // 解析 /TargetTable/targetKey + String path0 = targetPath.startsWith("/") ? targetPath.substring(1) : targetPath; + int idx = path0.lastIndexOf("/"); + String targetKey = idx < 0 ? path0 : path0.substring(idx + 1); + String targetTableKey = idx < 0 ? "" : path0.substring(0, idx); + // targetTableKey 可能带别名 TargetTable:alias + int cIdx = targetTableKey.indexOf(":"); + String targetTable = cIdx < 0 ? targetTableKey : targetTableKey.substring(0, cIdx); + String targetAlias = cIdx < 0 ? null : targetTableKey.substring(cIdx + 1); + + on.setTargetTableKey(targetTableKey); + on.setTargetTable(targetTable); + on.setTargetAlias(targetAlias); + on.setTargetKey(targetKey); + + onList.add(on); + j.setOnList(onList); + + return j; + } +}