diff --git a/springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/DataAuthorizationContext.java b/springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/DataAuthorizationContext.java index 1e50aee7..b4058253 100644 --- a/springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/DataAuthorizationContext.java +++ b/springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/DataAuthorizationContext.java @@ -63,8 +63,9 @@ public void clearDataAuthorizationFilters() { */ public T columnAuthorization(SQLExecuteState interceptState, String tableName, String columnName, T value) { if (interceptState != null && interceptState.hasIntercept()) { - String realTableName = interceptState.getTableName(tableName); - String realColumnName = interceptState.getColumnName(tableName,columnName); + String[] resolved = interceptState.resolveTableNameAndColumn(tableName, columnName); + String realTableName = resolved[0]; + String realColumnName = resolved[1]; for (DataAuthorizationFilter filter : filters) { if (filter.supportColumnAuthorization(realTableName, realColumnName, value)) { diff --git a/springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/enhancer/DerivedTableProjection.java b/springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/enhancer/DerivedTableProjection.java new file mode 100644 index 00000000..08b7445b --- /dev/null +++ b/springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/enhancer/DerivedTableProjection.java @@ -0,0 +1,113 @@ +package com.codingapi.springboot.authorization.enhancer; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * 派生表(FROM/JOIN 子查询)输出列到物理表字段的投影映射。 + *

+ * 解决 Issue #212:分页包装 SQL(SELECT 裸列 FROM (...) AS __base__)执行后, + * 结果集元数据中的表名是派生表别名(或空串),无法直接映射回物理表字段。 + * 通过记录派生表每一列的来源(显式列引用 / 唯一 * 展开),实现结果列归因。 + */ +class DerivedTableProjection { + + /** + * 显式投影列:key = 输出列名(小写),value = [物理表名, 物理字段名] + */ + private final Map explicitColumns = new HashMap<>(); + + /** + * 表达式/函数 AS 别名等非物理列派生出的输出列(小写),不参与 * 归因 + */ + private final Set expressionColumns = new HashSet<>(); + + /** + * 唯一 * 来源的物理表名;为 null 且 starAmbiguous 为 false 时表示无 * 投影 + */ + private String starTable; + + /** + * * 来源是否歧义(多个 * 或多表 SELECT *),歧义时不做 * 归因 + */ + private boolean starAmbiguous; + + /** + * 添加显式投影列 + * + * @param columnKey 输出列名(小写) + * @param tableName 物理表名 + * @param columnName 物理字段名 + */ + void putExplicit(String columnKey, String tableName, String columnName) { + explicitColumns.put(columnKey, new String[]{tableName, columnName}); + } + + /** + * 标记非物理列(表达式 AS 别名) + * + * @param columnKey 输出列名(小写) + */ + void markExpression(String columnKey) { + expressionColumns.add(columnKey); + } + + /** + * 添加 * 展开来源的物理表;出现多个来源时视为歧义 + * + * @param tableName 物理表名,null 表示来源不可解析 + */ + void addStarTable(String tableName) { + if (tableName == null || starTable != null) { + starTable = null; + starAmbiguous = true; + } else { + starTable = tableName; + } + } + + /** + * 标记 * 来源歧义 + */ + void markStarAmbiguous() { + starTable = null; + starAmbiguous = true; + } + + /** + * * 展开来源的物理表名(链式解析 t.* 来源时使用) + */ + String getStarTable() { + return starTable; + } + + /** + * * 来源是否歧义(链式解析 t.* 来源时使用) + */ + boolean isStarAmbiguous() { + return starAmbiguous; + } + + /** + * 将输出列解析为物理表字段 + * + * @param columnKey 输出列名(小写) + * @param columnName 输出列名(原样,用于 * 展开归因) + * @return [物理表名, 物理字段名],无法归因时返回 null + */ + String[] find(String columnKey, String columnName) { + String[] reference = explicitColumns.get(columnKey); + if (reference != null) { + return reference; + } + if (expressionColumns.contains(columnKey)) { + return null; + } + if (starTable != null) { + return new String[]{starTable, columnName}; + } + return null; + } +} diff --git a/springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/enhancer/TableColumnAliasContext.java b/springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/enhancer/TableColumnAliasContext.java index f557e2ef..345e604c 100644 --- a/springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/enhancer/TableColumnAliasContext.java +++ b/springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/enhancer/TableColumnAliasContext.java @@ -18,10 +18,16 @@ public class TableColumnAliasContext { private final Map columnAliasMap; + /** + * 派生表投影:key = 派生表别名(小写) + */ + private final Map derivedTables; + protected TableColumnAliasContext() { this.columnAliases = new ArrayList<>(); this.tableAlias = new HashMap<>(); this.columnAliasMap = new HashMap<>(); + this.derivedTables = new HashMap<>(); } /** @@ -33,6 +39,106 @@ protected void addTable(String tableAlias, String tableName) { this.tableAlias.put(tableAlias, tableName); } + /** + * 注册派生表投影 + * @param alias 派生表别名 + * @param projection 投影映射 + */ + protected void addDerivedTable(String alias, DerivedTableProjection projection) { + if (alias != null && projection != null) { + derivedTables.put(alias.toLowerCase(), projection); + } + } + + /** + * 按表引用名(表名或别名,忽略大小写)获取物理表名 + * @param tableRef 表引用名 + * @return 物理表名,未找到返回 null + */ + protected String resolvePhysicalTable(String tableRef) { + if (tableRef == null || tableRef.isEmpty()) { + return null; + } + for (Map.Entry entry : tableAlias.entrySet()) { + if (entry.getKey().equalsIgnoreCase(tableRef)) { + return entry.getValue(); + } + } + for (String tableName : tableAlias.values()) { + if (tableName.equalsIgnoreCase(tableRef)) { + return tableName; + } + } + return null; + } + + /** + * 获取派生表投影(忽略大小写) + * @param tableRef 派生表别名 + * @return 投影映射,未找到返回 null + */ + protected DerivedTableProjection getDerivedTable(String tableRef) { + if (tableRef == null || tableRef.isEmpty()) { + return null; + } + return derivedTables.get(tableRef.toLowerCase()); + } + + /** + * 联合解析结果集列的(表名, 字段名)到物理表字段。 + *

+ * 元数据表名命中派生表别名、或为空/未知(部分数据库对派生表列上报空表名, + * 见 Issue #212)时,沿派生表投影归因;否则回退到既有解析行为。 + * + * @param tableName 元数据表名(或别名) + * @param columnName 元数据字段名(或别名) + * @return [物理表名, 物理字段名] + */ + public String[] resolveTableNameAndColumn(String tableName, String columnName) { + String[] derived = resolveDerivedColumn(tableName, columnName); + if (derived != null) { + return derived; + } + return new String[]{getTableName(tableName), getColumnName(tableName, columnName)}; + } + + private String[] resolveDerivedColumn(String tableName, String columnName) { + if (derivedTables.isEmpty() || columnName == null || columnName.isEmpty()) { + return null; + } + String columnKey = columnName.toLowerCase(); + String tableKey = tableName == null ? "" : tableName.toLowerCase(); + if (!tableKey.isEmpty()) { + DerivedTableProjection projection = derivedTables.get(tableKey); + if (projection != null) { + return projection.find(columnKey, columnName); + } + // 已知物理表/别名:维持原有解析,不做派生归因,避免误匹配 + for (String alias : tableAlias.keySet()) { + if (alias.equalsIgnoreCase(tableKey)) { + return null; + } + } + for (String physicalTable : tableAlias.values()) { + if (physicalTable.equalsIgnoreCase(tableKey)) { + return null; + } + } + } + // 表名为空或未知:在所有派生表投影中做唯一匹配(匹配到多个不同物理表时不归因) + String[] matched = null; + for (DerivedTableProjection projection : derivedTables.values()) { + String[] reference = projection.find(columnKey, columnName); + if (reference != null) { + if (matched != null && !matched[0].equalsIgnoreCase(reference[0])) { + return null; + } + matched = reference; + } + } + return matched; + } + /** * 添加字段别名 * @param parent 父级(上级别名) diff --git a/springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/enhancer/TableColumnAliasHolder.java b/springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/enhancer/TableColumnAliasHolder.java index d3aaeab7..b804686d 100644 --- a/springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/enhancer/TableColumnAliasHolder.java +++ b/springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/enhancer/TableColumnAliasHolder.java @@ -2,6 +2,7 @@ import lombok.Getter; import net.sf.jsqlparser.expression.Alias; +import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.schema.Column; import net.sf.jsqlparser.schema.Table; import net.sf.jsqlparser.statement.Statement; @@ -66,6 +67,8 @@ private void searchSubSelect(String parent, PlainSelect plainSelect) { this.appendColumnAlias(parent, null, plainSelect.getSelectItems()); parent = fromItem.getAlias()!=null?fromItem.getAlias().getName():null; this.searchSubSelect(parent, subPlainSelect); + // 递归完成后子查询内的表/派生表均已注册,可构建其投影映射 + this.buildProjection(parent, subPlainSelect); } // 处理JOIN或关联子查询 @@ -77,6 +80,7 @@ private void searchSubSelect(String parent, PlainSelect plainSelect) { this.appendColumnAlias(parent, null, plainSelect.getSelectItems()); parent = currentItem.getAlias()!=null?currentItem.getAlias().getName():null; this.searchSubSelect(parent, subPlainSelect); + this.buildProjection(parent, subPlainSelect); } if (join.getRightItem() instanceof Table) { FromItem currentItem = join.getRightItem(); @@ -89,6 +93,125 @@ private void searchSubSelect(String parent, PlainSelect plainSelect) { } + /** + * 构建派生表(子查询)输出列到物理表字段的投影映射(Issue #212)。 + * 需在递归处理子查询之后调用,保证其引用的表/派生表均已注册。 + * + * @param alias 派生表别名 + * @param subPlainSelect 子查询 + */ + private void buildProjection(String alias, PlainSelect subPlainSelect) { + if (alias == null || subPlainSelect == null) { + return; + } + DerivedTableProjection projection = new DerivedTableProjection(); + List> selectItems = subPlainSelect.getSelectItems(); + if (selectItems != null) { + for (SelectItem selectItem : selectItems) { + Expression expression = selectItem.getExpression(); + String aliasName = selectItem.getAlias() != null ? selectItem.getAlias().getName() : null; + if (expression instanceof AllTableColumns allTableColumns) { + // 注意:AllTableColumns(emp.*) 是 AllColumns(*) 的子类,必须先判断 + projection.addStarTable(this.starTableOf(allTableColumns.getTable())); + } else if (expression instanceof AllColumns) { + // SELECT * :仅当子查询只有一个来源时可归因 + FromItem singleSource = this.singleSource(subPlainSelect); + projection.addStarTable(singleSource != null ? this.starTableOf(singleSource) : null); + } else if (expression instanceof Column column) { + this.putProjectionColumn(projection, subPlainSelect, column, aliasName); + } else if (aliasName != null) { + // 表达式/函数 AS 别名:非物理列,不参与归因 + projection.markExpression(aliasName.toLowerCase()); + } + } + } + aliasContext.addDerivedTable(alias, projection); + } + + private void putProjectionColumn(DerivedTableProjection projection, PlainSelect subPlainSelect, + Column column, String aliasName) { + String tableRef = column.getTable() != null ? column.getTable().getName() : ""; + if (tableRef.isEmpty()) { + // 裸列:仅当子查询只有一个来源时可归因 + FromItem singleSource = this.singleSource(subPlainSelect); + tableRef = singleSource != null ? this.sourceName(singleSource) : null; + } + String[] reference = this.resolveColumnReference(tableRef, column.getColumnName()); + if (reference != null) { + String outName = aliasName != null ? aliasName : column.getColumnName(); + projection.putExplicit(outName.toLowerCase(), reference[0], reference[1]); + } else if (aliasName != null) { + projection.markExpression(aliasName.toLowerCase()); + } + } + + /** + * 将 tableRef.column 解析到物理表字段(支持表别名、物理表名、派生表链式解析) + */ + private String[] resolveColumnReference(String tableRef, String columnName) { + if (tableRef == null || tableRef.isEmpty()) { + return null; + } + String physicalTable = aliasContext.resolvePhysicalTable(tableRef); + if (physicalTable != null) { + return new String[]{physicalTable, columnName}; + } + DerivedTableProjection derived = aliasContext.getDerivedTable(tableRef); + if (derived != null) { + return derived.find(columnName.toLowerCase(), columnName); + } + return null; + } + + /** + * 获取 * 展开来源的物理表名(Table 直接来源或派生表链式来源),歧义/未知返回 null + */ + private String starTableOf(FromItem fromItem) { + if (fromItem == null) { + return null; + } + String refName = this.sourceName(fromItem); + if (refName == null) { + return null; + } + String physicalTable = aliasContext.resolvePhysicalTable(refName); + if (physicalTable != null) { + return physicalTable; + } + DerivedTableProjection derived = aliasContext.getDerivedTable(refName); + if (derived != null && !derived.isStarAmbiguous()) { + return derived.getStarTable(); + } + return null; + } + + /** + * 子查询的唯一来源(无 JOIN 时的 FROM 项) + */ + private FromItem singleSource(PlainSelect plainSelect) { + if (plainSelect.getJoins() != null && !plainSelect.getJoins().isEmpty()) { + return null; + } + return plainSelect.getFromItem(); + } + + /** + * 来源的引用名(优先别名,其次表名;子查询取别名) + */ + private String sourceName(FromItem fromItem) { + if (fromItem instanceof Table table) { + if (table.getAlias() != null) { + return table.getAlias().getName(); + } + return table.getName(); + } + if (fromItem.getAlias() != null) { + return fromItem.getAlias().getName(); + } + return null; + } + + /** * 添加表别名 * diff --git a/springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/interceptor/SQLExecuteState.java b/springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/interceptor/SQLExecuteState.java index 96900c82..b5413d94 100644 --- a/springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/interceptor/SQLExecuteState.java +++ b/springboot-starter-data-authorization/src/main/java/com/codingapi/springboot/authorization/interceptor/SQLExecuteState.java @@ -56,6 +56,17 @@ public String getColumnName(String tableName, String columnName) { return aliasContext.getColumnName(tableName, columnName); } + /** + * 联合解析结果集列的(表名, 字段名)到物理表字段(含派生表归因,见 Issue #212) + * + * @param tableName 元数据表名(或别名) + * @param columnName 元数据字段名(或别名) + * @return [物理表名, 物理字段名] + */ + public String[] resolveTableNameAndColumn(String tableName, String columnName) { + return aliasContext.resolveTableNameAndColumn(tableName, columnName); + } + public String getSql() { if (intercept) { return newSql; diff --git a/springboot-starter-data-authorization/src/test/java/com/codingapi/springboot/authorization/analyzer/Issue212ResultSetColumnTest.java b/springboot-starter-data-authorization/src/test/java/com/codingapi/springboot/authorization/analyzer/Issue212ResultSetColumnTest.java new file mode 100644 index 00000000..341e01a4 --- /dev/null +++ b/springboot-starter-data-authorization/src/test/java/com/codingapi/springboot/authorization/analyzer/Issue212ResultSetColumnTest.java @@ -0,0 +1,174 @@ +package com.codingapi.springboot.authorization.analyzer; + +import com.codingapi.springboot.authorization.DataAuthorizationContext; +import com.codingapi.springboot.authorization.enhancer.DataPermissionSQLEnhancer; +import com.codingapi.springboot.authorization.filter.DataAuthorizationFilter; +import com.codingapi.springboot.authorization.handler.Condition; +import com.codingapi.springboot.authorization.interceptor.SQLExecuteState; +import com.codingapi.springboot.authorization.jdbc.proxy.ResultSetProxy; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * GitHub Issue #212 复现测试(ResultSet 列元数据 → 物理表/字段解析)。 + *

+ * 场景:查询引擎将业务 SQL 包装为分页 SQL + *

SELECT ... FROM ( ... ) AS __base__ WHERE 1 = 1 LIMIT ? OFFSET ?
+ * 此时候选结果集列的 ResultSetMetaData.getTableName() 不再是物理表名 + * (为空串或派生表别名),列又没有表限定符,导致框架无法把结果列 + * (如 CARD_NO)映射回物理表 T_DYNAMIC_BIZ_PBM_EMP_BASIC_INFO.card_no, + * DataAuthorizationFilter.supportColumnAuthorization 收到 tableName="", + * 列权限/脱敏失效(见 issue 截图调试现场)。 + */ +class Issue212ResultSetColumnTest { + + private static final String EMP_TABLE = "T_DYNAMIC_BIZ_PBM_EMP_BASIC_INFO"; + private static final String ID_CARD = "522101196612113210"; + + /** + * 与 issue #212 相同结构的分页包装 SQL(列做了裁剪,结构与问题一致: + * 外层裸列名投影 + FROM 派生表 AS __base__ + LIMIT ? OFFSET ?) + */ + private static final String WRAPPER_SQL = """ + SELECT + id, + unit_name, + card_no + FROM + ( + SELECT + emp.*, + org.SYSTEM_CODE AS orgCode + FROM + T_DYNAMIC_BIZ_PBM_EMP_BASIC_INFO emp + LEFT JOIN BIZ_PBM_ORGANIZATION org ON org.id = emp.department_id + WHERE + emp.sys_deleted = 0 + ) AS __base__ + WHERE + 1 = 1 + LIMIT + ? + OFFSET + ? + """; + + /** 记录框架回调 DataAuthorizationFilter 时传入的表名/列名 */ + static class RecordingFilter implements DataAuthorizationFilter { + + final List invocations = new ArrayList<>(); + + @Override + public T columnAuthorization(String tableName, String columnName, T value) { + return value; + } + + @Override + public Condition rowAuthorization(String tableName, String tableAlias) { + return null; + } + + @Override + public boolean supportColumnAuthorization(String tableName, String columnName, Object value) { + invocations.add(new String[]{tableName, columnName}); + return false; + } + + @Override + public boolean supportRowAuthorization(String tableName, String tableAlias) { + return false; + } + } + + private Connection connection; + + @BeforeEach + void setUp() throws SQLException { + connection = DriverManager.getConnection("jdbc:h2:mem:issue212_rs;DB_CLOSE_DELAY=-1"); + try (Statement statement = connection.createStatement()) { + statement.execute("DROP ALL OBJECTS"); + statement.execute("CREATE TABLE " + EMP_TABLE + " (" + + "id BIGINT PRIMARY KEY," + + "unit_name VARCHAR(64)," + + "card_no VARCHAR(32)," + + "department_id BIGINT," + + "sys_deleted INT)"); + statement.execute("CREATE TABLE BIZ_PBM_ORGANIZATION (" + + "id BIGINT PRIMARY KEY," + + "SYSTEM_CODE VARCHAR(32)," + + "tree_sort VARCHAR(64))"); + try (PreparedStatement ps = connection.prepareStatement( + "INSERT INTO " + EMP_TABLE + " VALUES (1, '单位A', ?, 10, 0)")) { + ps.setString(1, ID_CARD); + ps.executeUpdate(); + } + try (PreparedStatement ps = connection.prepareStatement( + "INSERT INTO BIZ_PBM_ORGANIZATION VALUES (10, 'ORG001', '1')")) { + ps.executeUpdate(); + } + } + } + + @AfterAll + static void tearDownAll() throws SQLException { + try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:issue212_rs;DB_CLOSE_DELAY=-1"); + Statement st = conn.createStatement()) { + st.execute("DROP ALL OBJECTS"); + } + } + + @Test + void shouldResolvePhysicalTableAndColumnForPagingWrappedSQL() throws Exception { + // 1. 走框架真实链路:增强器提取表/字段别名 → SQLExecuteState → ResultSetProxy + DataPermissionSQLEnhancer enhancer = + new DataPermissionSQLEnhancer(WRAPPER_SQL, (subSql, tableName, tableAlias) -> null); + String newSql = enhancer.getNewSQL(); + SQLExecuteState executeState = SQLExecuteState.intercept(WRAPPER_SQL, newSql, enhancer.getTableAlias()); + + RecordingFilter filter = new RecordingFilter(); + DataAuthorizationContext context = DataAuthorizationContext.getInstance(); + context.addDataAuthorizationFilter(filter); + try { + try (PreparedStatement ps = connection.prepareStatement(executeState.getSql())) { + ps.setInt(1, 10); + ps.setInt(2, 0); + try (ResultSet rs = ps.executeQuery()) { + ResultSetProxy proxy = new ResultSetProxy(rs, executeState); + assertTrue(proxy.next(), "应查询到测试数据"); + + // 读取 CARD_NO 列(触发列权限回调) + String value = proxy.getString("card_no"); + assertEquals(ID_CARD, value); + } + } + } finally { + context.clearDataAuthorizationFilters(); + } + + assertFalse(filter.invocations.isEmpty(), "列权限回调未触发"); + String[] cardNoInvocation = filter.invocations.stream() + .filter(inv -> inv[1] != null && inv[1].equalsIgnoreCase("card_no")) + .findFirst() + .orElseThrow(() -> new AssertionError("未捕获到 card_no 列回调: " + + filter.invocations.stream().map(inv -> inv[0] + "." + inv[1]).toList())); + + // 2. 期望:框架应将派生表 __base__ 的裸列解析回物理表/字段 + // (issue 现场为 tableName="",列脱敏/权限因此失效) + assertEquals(EMP_TABLE, cardNoInvocation[0], + "Issue #212: 分页包装 SQL 的结果列未能解析出物理表名"); + assertTrue("card_no".equalsIgnoreCase(cardNoInvocation[1]), + "Issue #212: 分页包装 SQL 的结果列未能解析出物理字段名, 实际: " + cardNoInvocation[1]); + } +} diff --git a/springboot-starter-data-authorization/src/test/java/com/codingapi/springboot/authorization/analyzer/Issue212SQLTest.java b/springboot-starter-data-authorization/src/test/java/com/codingapi/springboot/authorization/analyzer/Issue212SQLTest.java new file mode 100644 index 00000000..1f3ede6b --- /dev/null +++ b/springboot-starter-data-authorization/src/test/java/com/codingapi/springboot/authorization/analyzer/Issue212SQLTest.java @@ -0,0 +1,293 @@ +package com.codingapi.springboot.authorization.analyzer; + +import com.codingapi.springboot.authorization.enhancer.DataPermissionSQLEnhancer; +import com.codingapi.springboot.authorization.enhancer.TableColumnAliasContext; +import com.codingapi.springboot.authorization.handler.Condition; +import com.codingapi.springboot.authorization.handler.RowHandler; +import org.junit.jupiter.api.Test; + +import java.sql.SQLException; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * GitHub Issue #212 复现测试 + *

+ * 如下SQL无法正常的提取到表名和字段数据信息: + * 外层为分页包装的子查询(FROM ( ... ) AS __base__),内层为多表 LEFT JOIN, + * 且 JOIN 对象包含 ROW_NUMBER() OVER (PARTITION BY ...) 窗口函数派生表、 + * CAST、CASE WHEN、ORDER BY ... NULLS LAST 等复杂表达式。 + */ +class Issue212SQLTest { + + private static final String SQL = """ + SELECT + id, + unit_name, + department_name, + employee_name, + sex, + former_name, + nationality_code, + person_nation_code, + card_type_code, + card_no, + card_birth_date, + present_address, + bron_place_desc, + native_place_desc, + household_type_code, + household_local, + political_affiliation_code, + join_date, + join_org_name, + introducer, + health_status_code, + marital_status_code, + whether_disabled, + staff_speciality, + sap_number, + remarks, + telephone, + email, + emergency_contact, + emergency_contact_relation_code, + emergency_contact_phone, + archive_birth_date, + orig_policy_retire_age_code, + is_use_new_policy_retire_age, + new_policy_retire_age, + start_work_date, + industry_entry_date, + entry_date, + company_entry_date, + industry_entry_type_code, + company_entry_type_code, + archival_custodian_code, + unit_center_record, + labor_type_code, + employment_form_code, + eoh_person_category_code, + whether_register, + contract_sign_unit, + full_time_diploma_code, + full_time_degree_code, + on_job_diploma_code, + on_job_degree_code, + highest_technology_name, + highest_technology_level_code, + highest_skill_name, + highest_skill_level_code, + employee_code, + person_code, + person_status_code, + post_type_code, + position_hierarchy_code + FROM + ( + SELECT + emp.*, + org.SYSTEM_CODE AS orgCode, + edu.highest_diploma_code, + edu.highest_degree_code, + edu.full_time_diploma_code, + edu.full_time_degree_code, + edu.on_job_diploma_code, + edu.on_job_degree_code, + tech.highest_technology_name, + tech.highest_technology_level_code, + skill.highest_skill_name, + skill.highest_skill_level_code, + post.post_type_code AS post_type_code, + curpos.position_hierarchy_code AS position_hierarchy_code + FROM + T_DYNAMIC_BIZ_PBM_EMP_BASIC_INFO emp + LEFT JOIN BIZ_PBM_ORGANIZATION org ON org.id = emp.department_id + LEFT JOIN ( + SELECT + e.employee_id, + e.highest_diploma_code, + e.highest_degree_code, + e.full_time_diploma_code, + e.full_time_degree_code, + e.on_job_diploma_code, + e.on_job_degree_code, + ROW_NUMBER() OVER ( + PARTITION BY + e.employee_id + ORDER BY + e.id DESC + ) AS rn + FROM + T_DYNAMIC_BIZ_PBM_EMP_EDUCATION_HIGHEST e + ) edu ON edu.employee_id = emp.id + AND edu.rn = 1 + LEFT JOIN ( + SELECT + t.employee_id, + t.highest_technology_name, + t.highest_technology_level_code, + ROW_NUMBER() OVER ( + PARTITION BY + t.employee_id + ORDER BY + t.id DESC + ) AS rn + FROM + T_DYNAMIC_BIZ_PBM_EMP_TECHNOLOGY_HIGHEST t + ) tech ON tech.employee_id = emp.id + AND tech.rn = 1 + LEFT JOIN ( + SELECT + s.employee_id, + s.highest_skill_name, + s.highest_skill_level_code, + ROW_NUMBER() OVER ( + PARTITION BY + s.employee_id + ORDER BY + s.id DESC + ) AS rn + FROM + T_DYNAMIC_BIZ_PBM_EMP_SKILL_HIGHEST s + ) skill ON skill.employee_id = emp.id + AND skill.rn = 1 + LEFT JOIN BIZ_PBM_POST post ON post.id = CAST(emp.post_id AS BIGINT) + LEFT JOIN ( + SELECT + c.employee_id, + c.position_hierarchy_code, + c.current_hierarchy_date + FROM + ( + SELECT + p.employee_id, + p.position_hierarchy_code, + p.current_hierarchy_date, + ROW_NUMBER() OVER ( + PARTITION BY + p.employee_id + ORDER BY + p.current_position_date DESC NULLS LAST, + p.id DESC + ) AS rn + FROM + T_DYNAMIC_BIZ_PBM_EMP_POSITION p + WHERE + p.whether_current = 1 + ) c + WHERE + c.rn = 1 + ) curpos ON curpos.employee_id = emp.id + WHERE + emp.sys_deleted = 0 + ORDER BY + CASE + WHEN org.SYSTEM_CODE IS NULL THEN 1 + ELSE 0 + END, + org.tree_sort, + CASE + WHEN curpos.position_hierarchy_code IS NULL THEN 1 + ELSE 0 + END, + CASE + WHEN curpos.position_hierarchy_code LIKE 'dict_u_w_p_layer%' THEN CAST( + REPLACE ( + curpos.position_hierarchy_code, + 'dict_u_w_p_layer_', + '' + ) AS INT + ) + ELSE 999 + END, + CASE + WHEN curpos.current_hierarchy_date IS NULL THEN 1 + ELSE 0 + END, + curpos.current_hierarchy_date, + emp.id + ) AS __base__ + WHERE + 1 = 1 + LIMIT + ? + OFFSET + ? + """; + + /** + * 对员工基础信息表注入行权限条件:%s.sys_deleted = 0 之外再追加 %s.unit_id IN (...) + */ + private static RowHandler rowHandler() { + return (subSql, tableName, tableAlias) -> { + if (tableName.equalsIgnoreCase("T_DYNAMIC_BIZ_PBM_EMP_BASIC_INFO")) { + return Condition.formatCondition("%s.unit_id = 1000", tableAlias); + } + return null; + }; + } + + @Test + void complexSQLShouldBeParsed() throws SQLException { + DataPermissionSQLEnhancer builder = new DataPermissionSQLEnhancer(SQL, rowHandler()); + assertNotNull(builder.getTableAlias(), "表别名上下文不应为空"); + } + + @Test + void shouldExtractAllPhysicalTables() throws SQLException { + DataPermissionSQLEnhancer builder = new DataPermissionSQLEnhancer(SQL, rowHandler()); + builder.getNewSQL(); + TableColumnAliasContext context = builder.getTableAlias(); + System.out.println("tableAlias = " + context.getTableAlias()); + + // 嵌套 LEFT JOIN 子查询中的物理表都应被提取到 + assertEquals("T_DYNAMIC_BIZ_PBM_EMP_BASIC_INFO", context.getTableName("emp")); + assertEquals("BIZ_PBM_ORGANIZATION", context.getTableName("org")); + assertEquals("BIZ_PBM_POST", context.getTableName("post")); + assertEquals("T_DYNAMIC_BIZ_PBM_EMP_EDUCATION_HIGHEST", context.getTableName("e")); + assertEquals("T_DYNAMIC_BIZ_PBM_EMP_TECHNOLOGY_HIGHEST", context.getTableName("t")); + assertEquals("T_DYNAMIC_BIZ_PBM_EMP_SKILL_HIGHEST", context.getTableName("s")); + assertEquals("T_DYNAMIC_BIZ_PBM_EMP_POSITION", context.getTableName("p")); + } + + @Test + void shouldInjectPermissionConditionIntoNestedTable() throws SQLException { + DataPermissionSQLEnhancer builder = new DataPermissionSQLEnhancer(SQL, rowHandler()); + String newSql = builder.getNewSQL(); + System.out.println("newSql = " + newSql); + + // 权限条件必须被注入到内层 emp 表的 WHERE 中 + assertTrue(newSql.contains("emp.unit_id = 1000"), + "行权限条件未注入到嵌套子查询的 emp 表,提取/增强失败"); + } + + @Test + void shouldResolveDerivedColumnsOnIssueSQL() throws SQLException { + DataPermissionSQLEnhancer builder = new DataPermissionSQLEnhancer(SQL, rowHandler()); + builder.getNewSQL(); + TableColumnAliasContext context = builder.getTableAlias(); + + // 元数据表名为派生别名 __base__(H2 等)或空串(PG,issue 截图现场)时, + // 裸列应能沿多层派生链归因到物理表字段: + + // emp.* 单星展开归因 + assertArrayEquals(new String[]{"T_DYNAMIC_BIZ_PBM_EMP_BASIC_INFO", "card_no"}, + context.resolveTableNameAndColumn("__base__", "card_no")); + // 二级派生链:curpos -> c -> p + assertArrayEquals(new String[]{"T_DYNAMIC_BIZ_PBM_EMP_POSITION", "position_hierarchy_code"}, + context.resolveTableNameAndColumn("", "position_hierarchy_code")); + // 一层派生:skill 子查询 + assertArrayEquals(new String[]{"T_DYNAMIC_BIZ_PBM_EMP_SKILL_HIGHEST", "highest_skill_name"}, + context.resolveTableNameAndColumn("__BASE__", "highest_skill_name")); + // 显式别名投影:org.SYSTEM_CODE AS orgCode + assertArrayEquals(new String[]{"BIZ_PBM_ORGANIZATION", "SYSTEM_CODE"}, + context.resolveTableNameAndColumn("", "orgCode")); + // JOIN 直连表:post.post_type_code + assertArrayEquals(new String[]{"BIZ_PBM_POST", "post_type_code"}, + context.resolveTableNameAndColumn("__base__", "post_type_code")); + // 多义列(多个派生表均含 employee_id 且归属不同物理表):保守回退,不误归因 + assertArrayEquals(new String[]{"", "employee_id"}, + context.resolveTableNameAndColumn("", "employee_id")); + } +}