From c80e004a807c102657f8b0acc3cc28e52b0e195b Mon Sep 17 00:00:00 2001 From: gimdonghyeon Date: Sat, 9 May 2026 03:14:25 +0900 Subject: [PATCH 1/2] fix(core): unquote backtick/double-quoted table tokens in alias resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hibernate emits backtick- or double-quoted table identifiers under hibernate.globally_quoted_identifiers=true (and for reserved-word table names). MissingIndexDetector.resolveAliases used FROM_ALIAS / JOIN_ALIAS regexes that only matched bare \w+ segments, so for SQL like `SELECT m1_0.id FROM \`messages\` m1_0 WHERE m1_0.user_id = ?` the alias-to-table map ended up empty. resolveTable then hit its Hibernate-pattern fallback (m1_0 matches [a-z]{1,3}\d+_\d+) and returned null — silently skipping legitimate missing-index, redundant-filter, composite-index, and for-update findings. The same issue applies whenever any FROM/JOIN segment is quoted. Allow quoted segments in both regexes and unquote them in registerAlias before lowercasing, so the canonical map key matches the bare name that the JSqlParser-driven WHERE-column extractor already produces. The "Hibernate alias smells like a table" guard at line 709 stays in place as the safety net for genuinely unparseable cases. Refs #82. --- .../core/detector/MissingIndexDetector.java | 37 +++++-- .../QuotedTableAliasResolutionTest.java | 99 +++++++++++++++++++ 2 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 query-audit-core/src/test/java/io/queryaudit/core/detector/QuotedTableAliasResolutionTest.java diff --git a/query-audit-core/src/main/java/io/queryaudit/core/detector/MissingIndexDetector.java b/query-audit-core/src/main/java/io/queryaudit/core/detector/MissingIndexDetector.java index 706c152..671be30 100644 --- a/query-audit-core/src/main/java/io/queryaudit/core/detector/MissingIndexDetector.java +++ b/query-audit-core/src/main/java/io/queryaudit/core/detector/MissingIndexDetector.java @@ -30,18 +30,25 @@ */ public class MissingIndexDetector implements DetectionRule { + // A single identifier segment: bare \w+, backtick-quoted, or double-quoted. + // Hibernate emits backtick- or double-quoted identifiers under + // hibernate.globally_quoted_identifiers=true or for reserved-word table names. + private static final String IDENT_SEGMENT = "(?:\\w+|`[^`]+`|\"[^\"]+\")"; + // Matches "FROM " with optional schema/database prefixes (e.g. "myschema.users", - // "db.schema.users") and an optional alias. + // "db.schema.users", "`messages`", "\"users\"") and an optional bare alias. private static final Pattern FROM_ALIAS = Pattern.compile( - "\\bFROM\\s+((?:\\w+\\.){0,2}\\w+)(?:\\s+(?:AS\\s+)?(\\w+))?", + "\\bFROM\\s+((?:" + IDENT_SEGMENT + "\\.){0,2}" + IDENT_SEGMENT + ")(?:\\s+(?:AS\\s+)?(\\w+))?", Pattern.CASE_INSENSITIVE); private static final Pattern JOIN_ALIAS = Pattern.compile( - "\\bJOIN\\s+((?:\\w+\\.){0,2}\\w+)(?:\\s+(?:AS\\s+)?(\\w+))?", + "\\bJOIN\\s+((?:" + IDENT_SEGMENT + "\\.){0,2}" + IDENT_SEGMENT + ")(?:\\s+(?:AS\\s+)?(\\w+))?", Pattern.CASE_INSENSITIVE); + private static final Pattern QUOTED_IDENT = Pattern.compile("`([^`]+)`|\"([^\"]+)\""); + // ── Improvement 1: Low cardinality column name patterns ────────── private static final Set LOW_CARDINALITY_EXACT_NAMES = Set.of( @@ -675,17 +682,19 @@ private static void registerAlias( if (tableToken == null) { return; } + // Strip backticks / double-quotes from each segment so the canonical key matches the + // bare names produced by the WHERE-column extractor (JSqlParser already unquotes). + String normalized = unquoteSegments(tableToken).toLowerCase(); // The token may be schema-qualified ("myschema.users", "db.schema.users"). Drop the prefix so // detectors look up metadata under the canonical bare name; preserve the qualified form too so // `WHERE myschema.users.col` resolves through the same map. - String unqualified = stripSchemaPrefix(tableToken).toLowerCase(); + String unqualified = stripSchemaPrefix(normalized); if (isKeyword(unqualified)) { return; } aliasToTable.put(unqualified, unqualified); - String qualified = tableToken.toLowerCase(); - if (!qualified.equals(unqualified)) { - aliasToTable.put(qualified, unqualified); + if (!normalized.equals(unqualified)) { + aliasToTable.put(normalized, unqualified); } if (aliasToken != null && !isKeyword(aliasToken)) { aliasToTable.put(aliasToken.toLowerCase(), unqualified); @@ -697,6 +706,20 @@ private static String stripSchemaPrefix(String token) { return lastDot < 0 ? token : token.substring(lastDot + 1); } + private static String unquoteSegments(String token) { + if (token == null || token.indexOf('`') < 0 && token.indexOf('"') < 0) { + return token; + } + Matcher m = QUOTED_IDENT.matcher(token); + StringBuilder out = new StringBuilder(); + while (m.find()) { + String inner = m.group(1) != null ? m.group(1) : m.group(2); + m.appendReplacement(out, Matcher.quoteReplacement(inner)); + } + m.appendTail(out); + return out.toString(); + } + /** * Resolve an alias/table reference to the actual table name. If tableOrAlias is null, try to * infer from the first table in the alias map. diff --git a/query-audit-core/src/test/java/io/queryaudit/core/detector/QuotedTableAliasResolutionTest.java b/query-audit-core/src/test/java/io/queryaudit/core/detector/QuotedTableAliasResolutionTest.java new file mode 100644 index 0000000..19e7517 --- /dev/null +++ b/query-audit-core/src/test/java/io/queryaudit/core/detector/QuotedTableAliasResolutionTest.java @@ -0,0 +1,99 @@ +package io.queryaudit.core.detector; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.queryaudit.core.model.IndexInfo; +import io.queryaudit.core.model.IndexMetadata; +import io.queryaudit.core.model.Issue; +import io.queryaudit.core.model.IssueType; +import io.queryaudit.core.model.QueryRecord; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Regression for #82: when Hibernate emits SQL whose FROM/JOIN clause uses backtick- or + * double-quoted identifiers, {@code MissingIndexDetector.resolveAliases} (regex-based) cannot + * register the alias. The {@code resolveTable} fallback then drops Hibernate-pattern aliases on + * the floor (returns {@code null}), silently skipping legitimate missing-index issues. + * + *

The detectors that share {@code MissingIndexDetector.resolveAliases} are: + * MissingIndexDetector, CompositeIndexDetector, RedundantFilterDetector, ForUpdateWithoutIndexDetector. + */ +class QuotedTableAliasResolutionTest { + + private static QueryRecord record(String sql) { + return new QueryRecord(sql, 0L, System.currentTimeMillis(), ""); + } + + private static IndexInfo pk(String table, String column) { + return new IndexInfo(table, "PRIMARY", column, 1, false, 1000); + } + + private static IndexMetadata metadata(IndexInfo... infos) { + Map> map = new HashMap<>(); + for (IndexInfo info : infos) { + map.computeIfAbsent(info.tableName(), k -> new ArrayList<>()).add(info); + } + return new IndexMetadata(map); + } + + @Test + @DisplayName("Backtick-quoted FROM table with Hibernate alias: missing-index still reported") + void backtickQuotedTable_hibernateAlias_reportsMissingIndex() { + // messages has a PK on id but no index on user_id. + IndexMetadata meta = metadata(pk("messages", "id")); + + // Hibernate with hibernate.globally_quoted_identifiers=true (or with reserved-word + // table names) emits backtick-quoted table identifiers. + String sql = "SELECT m1_0.id FROM `messages` m1_0 WHERE m1_0.user_id = ?"; + + List issues = new MissingIndexDetector().evaluate(List.of(record(sql)), meta); + + assertThat(issues) + .as("missing index on messages.user_id should be reported even when FROM is quoted") + .anyMatch( + i -> + i.type() == IssueType.MISSING_WHERE_INDEX + && "messages".equalsIgnoreCase(i.table()) + && "user_id".equalsIgnoreCase(i.column())); + } + + @Test + @DisplayName("Double-quoted FROM table with Hibernate alias: missing-index still reported") + void doubleQuotedTable_hibernateAlias_reportsMissingIndex() { + IndexMetadata meta = metadata(pk("messages", "id")); + + String sql = "SELECT m1_0.id FROM \"messages\" m1_0 WHERE m1_0.user_id = ?"; + + List issues = new MissingIndexDetector().evaluate(List.of(record(sql)), meta); + + assertThat(issues) + .anyMatch( + i -> + i.type() == IssueType.MISSING_WHERE_INDEX + && "messages".equalsIgnoreCase(i.table()) + && "user_id".equalsIgnoreCase(i.column())); + } + + @Test + @DisplayName("Backtick-quoted JOIN table with Hibernate alias: redundant filter still reported") + void backtickQuotedJoin_hibernateAlias_reportsRedundantFilter() { + // True redundancy on the joined table — should be reported under the joined table's name. + String sql = + "SELECT m1_0.id FROM messages m1_0 " + + "JOIN `rooms` r1_0 ON m1_0.room_id = r1_0.id " + + "WHERE r1_0.status = 'ACTIVE' AND r1_0.status = 'ACTIVE'"; + + RedundantFilterDetector detector = new RedundantFilterDetector(); + List issues = detector.evaluate(List.of(record(sql)), new IndexMetadata(Map.of())); + + assertThat(issues) + .anyMatch( + i -> + "rooms".equalsIgnoreCase(i.table()) && "status".equalsIgnoreCase(i.column())); + } +} From 885fd535f0ad12f182d7bc87831761a45978ab02 Mon Sep 17 00:00:00 2001 From: Haroya <128161745+haroya01@users.noreply.github.com> Date: Sat, 16 May 2026 10:44:10 +0900 Subject: [PATCH 2/2] refactor(core): consolidate alias resolution onto MissingIndexDetector (#144) --- .../ForUpdateNonUniqueIndexDetector.java | 36 +--------- .../NonDeterministicPaginationDetector.java | 38 +--------- .../OrderByLimitWithoutIndexDetector.java | 6 -- .../core/detector/RangeLockDetector.java | 36 +--------- .../QuotedTableAliasResolutionTest.java | 71 ++++++++++++++++++- 5 files changed, 72 insertions(+), 115 deletions(-) diff --git a/query-audit-core/src/main/java/io/queryaudit/core/detector/ForUpdateNonUniqueIndexDetector.java b/query-audit-core/src/main/java/io/queryaudit/core/detector/ForUpdateNonUniqueIndexDetector.java index 760cfd8..4dfd4ce 100644 --- a/query-audit-core/src/main/java/io/queryaudit/core/detector/ForUpdateNonUniqueIndexDetector.java +++ b/query-audit-core/src/main/java/io/queryaudit/core/detector/ForUpdateNonUniqueIndexDetector.java @@ -9,12 +9,10 @@ import io.queryaudit.core.parser.ColumnReference; import io.queryaudit.core.parser.EnhancedSqlParser; import java.util.ArrayList; -import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.regex.Matcher; import java.util.regex.Pattern; /** @@ -35,12 +33,6 @@ public class ForUpdateNonUniqueIndexDetector implements DetectionRule { private static final Pattern FOR_UPDATE_OR_SHARE = Pattern.compile("\\bFOR\\s+(?:UPDATE|SHARE)\\b", Pattern.CASE_INSENSITIVE); - private static final Pattern FROM_ALIAS = - Pattern.compile("\\bFROM\\s+(\\w+)(?:\\s+(?:AS\\s+)?(\\w+))?", Pattern.CASE_INSENSITIVE); - - private static final Pattern JOIN_ALIAS = - Pattern.compile("\\bJOIN\\s+(\\w+)(?:\\s+(?:AS\\s+)?(\\w+))?", Pattern.CASE_INSENSITIVE); - @Override public List evaluate(List queries, IndexMetadata indexMetadata) { List issues = new ArrayList<>(); @@ -72,7 +64,7 @@ public List evaluate(List queries, IndexMetadata indexMetada continue; } - Map aliasToTable = resolveAliases(sql); + Map aliasToTable = MissingIndexDetector.resolveAliases(sql); for (ColumnReference col : whereColumns) { String table = resolveTable(col.tableOrAlias(), aliasToTable); @@ -128,32 +120,6 @@ private boolean isOnlyNonUniqueIndexed(IndexMetadata indexMetadata, String table return hasIndex; } - private Map resolveAliases(String sql) { - Map aliasToTable = new HashMap<>(); - - Matcher fromMatcher = FROM_ALIAS.matcher(sql); - while (fromMatcher.find()) { - String table = fromMatcher.group(1); - String alias = fromMatcher.group(2); - aliasToTable.put(table.toLowerCase(), table.toLowerCase()); - if (alias != null) { - aliasToTable.put(alias.toLowerCase(), table.toLowerCase()); - } - } - - Matcher joinMatcher = JOIN_ALIAS.matcher(sql); - while (joinMatcher.find()) { - String table = joinMatcher.group(1); - String alias = joinMatcher.group(2); - aliasToTable.put(table.toLowerCase(), table.toLowerCase()); - if (alias != null) { - aliasToTable.put(alias.toLowerCase(), table.toLowerCase()); - } - } - - return aliasToTable; - } - private String resolveTable(String tableOrAlias, Map aliasToTable) { if (tableOrAlias != null) { String resolved = aliasToTable.get(tableOrAlias.toLowerCase()); diff --git a/query-audit-core/src/main/java/io/queryaudit/core/detector/NonDeterministicPaginationDetector.java b/query-audit-core/src/main/java/io/queryaudit/core/detector/NonDeterministicPaginationDetector.java index ad31372..106acba 100644 --- a/query-audit-core/src/main/java/io/queryaudit/core/detector/NonDeterministicPaginationDetector.java +++ b/query-audit-core/src/main/java/io/queryaudit/core/detector/NonDeterministicPaginationDetector.java @@ -10,12 +10,10 @@ import io.queryaudit.core.parser.EnhancedSqlParser; import io.queryaudit.core.parser.SqlParser; import java.util.ArrayList; -import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.regex.Matcher; import java.util.regex.Pattern; /** @@ -38,14 +36,6 @@ public class NonDeterministicPaginationDetector implements DetectionRule { private static final Pattern LIMIT_PATTERN = Pattern.compile("\\bLIMIT\\b", Pattern.CASE_INSENSITIVE); - private static final Pattern FROM_ALIAS = - Pattern.compile( - "\\bFROM\\s+`?(\\w+)`?(?:\\s+(?:AS\\s+)?`?(\\w+)`?)?", Pattern.CASE_INSENSITIVE); - - private static final Pattern JOIN_ALIAS = - Pattern.compile( - "\\bJOIN\\s+`?(\\w+)`?(?:\\s+(?:AS\\s+)?`?(\\w+)`?)?", Pattern.CASE_INSENSITIVE); - @Override public List evaluate(List queries, IndexMetadata indexMetadata) { List issues = new ArrayList<>(); @@ -80,7 +70,7 @@ public List evaluate(List queries, IndexMetadata indexMetada continue; } - Map aliasToTable = resolveAliases(sql); + Map aliasToTable = MissingIndexDetector.resolveAliases(sql); // Check if any ORDER BY column has a unique index boolean hasUniqueTiebreaker = false; @@ -142,32 +132,6 @@ private boolean hasUniqueIndex(IndexMetadata indexMetadata, String table, String return false; } - private Map resolveAliases(String sql) { - Map aliasToTable = new HashMap<>(); - - Matcher fromMatcher = FROM_ALIAS.matcher(sql); - while (fromMatcher.find()) { - String table = fromMatcher.group(1); - String alias = fromMatcher.group(2); - aliasToTable.put(table.toLowerCase(), table.toLowerCase()); - if (alias != null) { - aliasToTable.put(alias.toLowerCase(), table.toLowerCase()); - } - } - - Matcher joinMatcher = JOIN_ALIAS.matcher(sql); - while (joinMatcher.find()) { - String table = joinMatcher.group(1); - String alias = joinMatcher.group(2); - aliasToTable.put(table.toLowerCase(), table.toLowerCase()); - if (alias != null) { - aliasToTable.put(alias.toLowerCase(), table.toLowerCase()); - } - } - - return aliasToTable; - } - private String resolveTable(String tableOrAlias, Map aliasToTable) { if (tableOrAlias != null) { String resolved = aliasToTable.get(tableOrAlias.toLowerCase()); diff --git a/query-audit-core/src/main/java/io/queryaudit/core/detector/OrderByLimitWithoutIndexDetector.java b/query-audit-core/src/main/java/io/queryaudit/core/detector/OrderByLimitWithoutIndexDetector.java index ac72e77..c7ddd6b 100644 --- a/query-audit-core/src/main/java/io/queryaudit/core/detector/OrderByLimitWithoutIndexDetector.java +++ b/query-audit-core/src/main/java/io/queryaudit/core/detector/OrderByLimitWithoutIndexDetector.java @@ -34,12 +34,6 @@ public class OrderByLimitWithoutIndexDetector implements DetectionRule { private static final Pattern ORDER_BY_PATTERN = Pattern.compile("\\bORDER\\s+BY\\b", Pattern.CASE_INSENSITIVE); - private static final Pattern FROM_ALIAS = - Pattern.compile("\\bFROM\\s+(\\w+)(?:\\s+(?:AS\\s+)?(\\w+))?", Pattern.CASE_INSENSITIVE); - - private static final Pattern JOIN_ALIAS = - Pattern.compile("\\bJOIN\\s+(\\w+)(?:\\s+(?:AS\\s+)?(\\w+))?", Pattern.CASE_INSENSITIVE); - @Override public List evaluate(List queries, IndexMetadata indexMetadata) { List issues = new ArrayList<>(); diff --git a/query-audit-core/src/main/java/io/queryaudit/core/detector/RangeLockDetector.java b/query-audit-core/src/main/java/io/queryaudit/core/detector/RangeLockDetector.java index 22955da..a6bc32b 100644 --- a/query-audit-core/src/main/java/io/queryaudit/core/detector/RangeLockDetector.java +++ b/query-audit-core/src/main/java/io/queryaudit/core/detector/RangeLockDetector.java @@ -8,12 +8,10 @@ import io.queryaudit.core.parser.EnhancedSqlParser; import io.queryaudit.core.parser.WhereColumnReference; import java.util.ArrayList; -import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.regex.Matcher; import java.util.regex.Pattern; /** @@ -38,12 +36,6 @@ public class RangeLockDetector implements DetectionRule { /** Range operators that cause gap locks in InnoDB. */ private static final Set RANGE_OPERATORS = Set.of(">", "<", ">=", "<=", "BETWEEN"); - private static final Pattern FROM_ALIAS = - Pattern.compile("\\bFROM\\s+(\\w+)(?:\\s+(?:AS\\s+)?(\\w+))?", Pattern.CASE_INSENSITIVE); - - private static final Pattern JOIN_ALIAS = - Pattern.compile("\\bJOIN\\s+(\\w+)(?:\\s+(?:AS\\s+)?(\\w+))?", Pattern.CASE_INSENSITIVE); - @Override public List evaluate(List queries, IndexMetadata indexMetadata) { List issues = new ArrayList<>(); @@ -78,7 +70,7 @@ public List evaluate(List queries, IndexMetadata indexMetada continue; // No WHERE clause -- ForUpdateWithoutIndexDetector handles this } - Map aliasToTable = resolveAliases(sql); + Map aliasToTable = MissingIndexDetector.resolveAliases(sql); for (WhereColumnReference col : whereColumns) { String operator = col.operator() != null ? col.operator().trim().toUpperCase() : ""; @@ -124,32 +116,6 @@ public List evaluate(List queries, IndexMetadata indexMetada return issues; } - private Map resolveAliases(String sql) { - Map aliasToTable = new HashMap<>(); - - Matcher fromMatcher = FROM_ALIAS.matcher(sql); - while (fromMatcher.find()) { - String table = fromMatcher.group(1); - String alias = fromMatcher.group(2); - aliasToTable.put(table.toLowerCase(), table.toLowerCase()); - if (alias != null) { - aliasToTable.put(alias.toLowerCase(), table.toLowerCase()); - } - } - - Matcher joinMatcher = JOIN_ALIAS.matcher(sql); - while (joinMatcher.find()) { - String table = joinMatcher.group(1); - String alias = joinMatcher.group(2); - aliasToTable.put(table.toLowerCase(), table.toLowerCase()); - if (alias != null) { - aliasToTable.put(alias.toLowerCase(), table.toLowerCase()); - } - } - - return aliasToTable; - } - private String resolveTable(String tableOrAlias, Map aliasToTable) { if (tableOrAlias != null) { String resolved = aliasToTable.get(tableOrAlias.toLowerCase()); diff --git a/query-audit-core/src/test/java/io/queryaudit/core/detector/QuotedTableAliasResolutionTest.java b/query-audit-core/src/test/java/io/queryaudit/core/detector/QuotedTableAliasResolutionTest.java index 19e7517..8a0fb73 100644 --- a/query-audit-core/src/test/java/io/queryaudit/core/detector/QuotedTableAliasResolutionTest.java +++ b/query-audit-core/src/test/java/io/queryaudit/core/detector/QuotedTableAliasResolutionTest.java @@ -20,8 +20,10 @@ * register the alias. The {@code resolveTable} fallback then drops Hibernate-pattern aliases on * the floor (returns {@code null}), silently skipping legitimate missing-index issues. * - *

The detectors that share {@code MissingIndexDetector.resolveAliases} are: - * MissingIndexDetector, CompositeIndexDetector, RedundantFilterDetector, ForUpdateWithoutIndexDetector. + *

Detectors covered here: MissingIndexDetector, RedundantFilterDetector, + * NonDeterministicPaginationDetector, RangeLockDetector, ForUpdateNonUniqueIndexDetector. All + * five route through {@code MissingIndexDetector.resolveAliases} after the consolidation + * follow-up to PR #143. */ class QuotedTableAliasResolutionTest { @@ -33,6 +35,10 @@ private static IndexInfo pk(String table, String column) { return new IndexInfo(table, "PRIMARY", column, 1, false, 1000); } + private static IndexInfo nonUniqueIdx(String table, String name, String column) { + return new IndexInfo(table, name, column, 1, true, 100); + } + private static IndexMetadata metadata(IndexInfo... infos) { Map> map = new HashMap<>(); for (IndexInfo info : infos) { @@ -96,4 +102,65 @@ void backtickQuotedJoin_hibernateAlias_reportsRedundantFilter() { i -> "rooms".equalsIgnoreCase(i.table()) && "status".equalsIgnoreCase(i.column())); } + + @Test + @DisplayName("Double-quoted FROM with Hibernate alias: NonDeterministicPagination still reported") + void doubleQuoted_nonDeterministicPagination_reported() { + // messages: PK(id), no unique index on created_at — ORDER BY created_at LIMIT N + // is non-deterministic. Pre-fix the detector's local FROM_ALIAS regex matched only + // bare and backtick-quoted segments (no double-quote support), so the alias map + // was empty for this SQL, resolveTable returned "m1_0", hasTable("m1_0") was false, + // the detector treated this as "can't determine — skip". + IndexMetadata meta = metadata(pk("messages", "id")); + + String sql = "SELECT m1_0.id FROM \"messages\" m1_0 ORDER BY m1_0.created_at LIMIT 10"; + + List issues = + new NonDeterministicPaginationDetector().evaluate(List.of(record(sql)), meta); + + assertThat(issues) + .anyMatch(i -> i.type() == IssueType.NON_DETERMINISTIC_PAGINATION); + } + + @Test + @DisplayName("Backtick-quoted FROM with Hibernate alias: RangeLockDetector still reported") + void backtickQuoted_rangeLock_reported() { + // orders: PK(id), no index on created_at. FOR UPDATE with range condition on + // unindexed column → gap-lock risk. Pre-fix the detector silently skipped because + // resolveTable returned "o1_0" which is not in metadata. + IndexMetadata meta = metadata(pk("orders", "id")); + + String sql = + "SELECT o1_0.id FROM `orders` o1_0 WHERE o1_0.created_at > ? FOR UPDATE"; + + List issues = new RangeLockDetector().evaluate(List.of(record(sql)), meta); + + assertThat(issues) + .anyMatch( + i -> + i.type() == IssueType.RANGE_LOCK_RISK + && "orders".equalsIgnoreCase(i.table()) + && "created_at".equalsIgnoreCase(i.column())); + } + + @Test + @DisplayName("Backtick-quoted FROM with Hibernate alias: ForUpdateNonUniqueIndex still reported") + void backtickQuoted_forUpdateNonUnique_reported() { + // orders: PK(id), non-unique idx on user_id. FOR UPDATE WHERE user_id = ? takes a + // next-key lock on a non-unique index. Pre-fix the detector silently skipped. + IndexMetadata meta = metadata(pk("orders", "id"), nonUniqueIdx("orders", "idx_user", "user_id")); + + String sql = + "SELECT o1_0.id FROM `orders` o1_0 WHERE o1_0.user_id = ? FOR UPDATE"; + + List issues = + new ForUpdateNonUniqueIndexDetector().evaluate(List.of(record(sql)), meta); + + assertThat(issues) + .anyMatch( + i -> + i.type() == IssueType.FOR_UPDATE_NON_UNIQUE + && "orders".equalsIgnoreCase(i.table()) + && "user_id".equalsIgnoreCase(i.column())); + } }