From 58dd304cf2fe63aa1686acb50cd8e9be2cdc38a9 Mon Sep 17 00:00:00 2001 From: Kirill Kurdyukov Date: Fri, 24 Jul 2026 11:02:06 +0300 Subject: [PATCH] feat(hibernate): table/column-aware use_index query hint Extend use_index with use_index::([,...]) so the same table joined multiple times can be pinned to different secondary indexes via VIEW. Match by alias columns in ON/WHERE; most specific full match wins. --- hibernate-dialect/CHANGELOG.md | 1 + hibernate-dialect/README.md | 50 ++ .../ydb/hibernate/dialect/YdbDialect.java | 21 +- .../dialect/hint/IndexQueryHintHandler.java | 268 ++++++++- .../hint/SqlTopLevelClauseScanner.java | 166 ++++++ .../hint/IndexQueryHintHandlerTest.java | 512 ++++++++++++++++++ .../tech/ydb/hibernate/index/BankAccount.java | 70 +++ .../ydb/hibernate/index/BankDocument.java | 76 +++ .../index/BankDocumentIndexHintTest.java | 182 +++++++ .../student/StudentsRepositoryTest.java | 119 ++++ .../ydb/hibernate/student/entity/Student.java | 3 +- 11 files changed, 1443 insertions(+), 25 deletions(-) create mode 100644 hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/hint/SqlTopLevelClauseScanner.java create mode 100644 hibernate-dialect/src/test/java/tech/ydb/hibernate/hint/IndexQueryHintHandlerTest.java create mode 100644 hibernate-dialect/src/test/java/tech/ydb/hibernate/index/BankAccount.java create mode 100644 hibernate-dialect/src/test/java/tech/ydb/hibernate/index/BankDocument.java create mode 100644 hibernate-dialect/src/test/java/tech/ydb/hibernate/index/BankDocumentIndexHintTest.java diff --git a/hibernate-dialect/CHANGELOG.md b/hibernate-dialect/CHANGELOG.md index 2bef9d40..b3838b35 100644 --- a/hibernate-dialect/CHANGELOG.md +++ b/hibernate-dialect/CHANGELOG.md @@ -4,6 +4,7 @@ - Support Hibernate 7 - Support OFFSET without LIMIT - Dropped `current time` in HQL +- Extended `use_index:` hint with table-and-column-aware format `use_index::([,...])` to pin a secondary index per `FROM`/`JOIN` when the same table is joined multiple times under different aliases ## 1.6.0 ## diff --git a/hibernate-dialect/README.md b/hibernate-dialect/README.md index 7e42adf5..dba58767 100644 --- a/hibernate-dialect/README.md +++ b/hibernate-dialect/README.md @@ -74,6 +74,56 @@ Use this custom dialect just like any other Hibernate dialect. Map your entity classes to database tables and use Hibernate's session factory to perform database operations. +## Query hints + +The dialect parses the JPA `HibernateHints.HINT_COMMENT` value (or the +`org.hibernate.query.Query#addQueryHint` value) and rewrites the generated SQL +before it is sent to YDB. Multiple hints can be combined in one comment by +separating them with `;`. + +| Hint | Effect | +| --- | --- | +| `use_index:` | Rewrites a simple `select … from
… where …` so that the FROM table uses the given secondary index (`VIEW `). | +| `use_index::([,…])` | Table- and column-aware hint. For every `FROM`/`JOIN` occurrence of ``, the dialect rewrites the segment to ` view ` **only if every column listed in the hint is referenced by that alias** inside the relevant scope (the `ON` clause for a `JOIN`, the `WHERE` clause for the `FROM` table). When several hints fully match the same table reference the most specific one (largest number of listed columns) wins. | +| `use_scan` | Wraps the query in `scan`. | +| `add_pragma:` | Prepends `PRAGMA ;` to the query. | + +### Why the table/column form? + +YDB does not always pick a secondary index automatically when the same table +is joined several times under different aliases (e.g. fetching parent and +child accounts in one query). The auto-picker does not currently consider +JOIN conditions, so heavy queries can fall back to a full scan. The +table/column form lets you pin a specific index per JOIN without rewriting +the JPQL query: + +```java +@QueryHints({ + @QueryHint(name = HibernateHints.HINT_COMMENT, + value = "use_index:bank_account_code_idx:bank_account(code);" + + "use_index:bank_account_parent_idx:bank_account(parent)") +}) +Optional findById(Long id); +``` + +For a Hibernate-generated query like + +```sql +left join bank_account a1_0 on a1_0.code=source.code +left join bank_account a3_0 on a3_0.parent=source.parent +``` + +the dialect rewrites the joins to + +```sql +left join bank_account view bank_account_code_idx a1_0 on a1_0.code=source.code +left join bank_account view bank_account_parent_idx a3_0 on a3_0.parent=source.parent +``` + +Multiple columns can be listed inside the parentheses (`bank_account(code,parent)`) +to apply the same index hint to whichever of those columns appears in the +join condition. + ## Integration with Spring Data JPA Configure Spring Data JPA with Hibernate to use custom YDB dialect diff --git a/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/YdbDialect.java b/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/YdbDialect.java index d10b34fc..b106ad1c 100644 --- a/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/YdbDialect.java +++ b/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/YdbDialect.java @@ -4,6 +4,7 @@ import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.Arrays; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import org.hibernate.boot.model.FunctionContributions; @@ -274,17 +275,19 @@ public String addSqlHintOrComment(String sql, QueryOptions queryOptions, boolean } if (queryOptions.getComment() != null) { - boolean commentIsHint = false; - - var hints = queryOptions.getComment().split(";"); + List comments = Arrays.stream(queryOptions.getComment().split(";")) + .map(String::trim) + .filter(comment -> !comment.isEmpty()) + .toList(); + boolean commentIsHint = false; for (var queryHintHandler : QUERY_HINT_HANDLERS) { - for (var hint : hints) { - hint = hint.trim(); - if (queryHintHandler.commentIsHint(hint)) { - commentIsHint = true; - sql = queryHintHandler.addQueryHints(sql, List.of(hint)); - } + List handlerHints = comments.stream() + .filter(queryHintHandler::commentIsHint) + .toList(); + if (!handlerHints.isEmpty()) { + commentIsHint = true; + sql = queryHintHandler.addQueryHints(sql, handlerHints); } } diff --git a/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/hint/IndexQueryHintHandler.java b/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/hint/IndexQueryHintHandler.java index 75e9eb14..03923693 100644 --- a/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/hint/IndexQueryHintHandler.java +++ b/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/hint/IndexQueryHintHandler.java @@ -1,7 +1,10 @@ package tech.ydb.hibernate.dialect.hint; import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -11,11 +14,25 @@ public class IndexQueryHintHandler implements QueryHintHandler { public static final IndexQueryHintHandler INSTANCE = new IndexQueryHintHandler(); - private static final Pattern SELECT_FROM_WHERE_QUERY_PATTERN = Pattern - .compile("^\\s*(select.+?from\\s+\\w+)(.+where.+)$", Pattern.CASE_INSENSITIVE); - private static final String HINT_USE_INDEX = "use_index:"; + + private static final String IDENT = "(?:\\w+|`[^`]+`|\"[^\"]+\")"; + + + private static final Pattern TYPED_HINT_BODY = Pattern.compile("^([^:]+):([^(]+)\\(([^)]+)\\)$"); + + + private static final Pattern TABLE_ALIAS = Pattern.compile("\\s*(" + IDENT + ")\\s+(\\w+)"); + + + private static final Pattern SELECT_FROM_WHERE = Pattern + .compile("^\\s*(select.+?from\\s+" + IDENT + ")(.+where.+)$", Pattern.CASE_INSENSITIVE); + + + private static final Pattern FROM_HAS_VIEW = Pattern + .compile("\\bfrom\\s+" + IDENT + "\\s+view\\b", Pattern.CASE_INSENSITIVE); + private IndexQueryHintHandler() { } @@ -26,23 +43,244 @@ public boolean commentIsHint(String comment) { @Override public String addQueryHints(String query, List hints) { - var useIndexes = new ArrayList(); - hints.forEach(hint -> { - if (hint.startsWith(HINT_USE_INDEX)) { - useIndexes.add(hint.substring(HINT_USE_INDEX.length())); + ParsedHints parsed = parseHints(hints); + if (parsed.empty()) { + return query; + } + + String result = query; + if (!parsed.typed.isEmpty()) { + result = applyTypedHints(result, parsed.typed); + } + if (!parsed.simple.isEmpty() && !FROM_HAS_VIEW.matcher(result).find()) { + result = applyShortHints(result, parsed.simple); + } + return result; + } + + private record ParsedHints(List simple, Map> typed) { + boolean empty() { + return simple.isEmpty() && typed.isEmpty(); + } + } + + private record IndexHint(String indexName, List columns) { + } + + private static ParsedHints parseHints(List hints) { + List simple = new ArrayList<>(); + Map> typed = new LinkedHashMap<>(); + for (String hint : hints) { + if (!hint.startsWith(HINT_USE_INDEX)) { + continue; + } + String body = hint.substring(HINT_USE_INDEX.length()).trim(); + Matcher m = TYPED_HINT_BODY.matcher(body); + if (m.matches()) { + String indexName = m.group(1).trim(); + String tableName = unquote(m.group(2).trim()); + List columns = splitColumns(m.group(3)); + if (!indexName.isEmpty() && !tableName.isEmpty() && !columns.isEmpty()) { + typed.computeIfAbsent(tableName, key -> new ArrayList<>()) + .add(new IndexHint(indexName, columns)); + } + } else if (!body.isEmpty()) { + simple.add(body); + } + } + return new ParsedHints(simple, typed); + } + + private static List splitColumns(String raw) { + return Arrays.stream(raw.split(",")) + .map(String::trim) + .map(IndexQueryHintHandler::unquote) + .filter(s -> !s.isEmpty()) + .toList(); + } + + private record TableRef(String table, String alias, int tableEnd, int aliasStart, String columnScope) { + } + + private record Replacement(int start, int end, String text) { + } + + private static String applyTypedHints(String query, Map> typed) { + List refs = collectTableRefs(query); + List replacements = new ArrayList<>(); + for (TableRef ref : refs) { + List hintsForTable = typed.get(unquote(ref.table)); + if (hintsForTable == null) { + continue; + } + IndexHint chosen = pickBestIndex(hintsForTable, ref); + if (chosen != null) { + replacements.add(new Replacement(ref.tableEnd, ref.aliasStart, " view " + chosen.indexName + " ")); + } + } + return applyReplacements(query, replacements); + } + + + private static IndexHint pickBestIndex(List hints, TableRef ref) { + IndexHint best = null; + for (IndexHint hint : hints) { + if (!allColumnsReferenced(ref.columnScope, ref.alias, hint.columns)) { + continue; + } + if (best == null || hint.columns.size() > best.columns.size()) { + best = hint; + } + } + return best; + } + + private static boolean allColumnsReferenced(String scope, String alias, List columns) { + for (String column : columns) { + if (!aliasColumnInScope(scope, alias, column)) { + return false; } - }); + } + return true; + } + + + private static boolean aliasColumnInScope(String scope, String alias, String column) { + int from = 0; + while (true) { + int idx = scope.indexOf(alias, from); + if (idx < 0) { + return false; + } + if (isAliasColumnMatchAt(scope, idx, alias, column)) { + return true; + } + from = idx + 1; + } + } - if (!useIndexes.isEmpty()) { - Matcher matcher = SELECT_FROM_WHERE_QUERY_PATTERN.matcher(query); - if (matcher.matches() && matcher.groupCount() > 1) { - String startToken = matcher.group(1); - String endToken = matcher.group(2); + private static boolean isAliasColumnMatchAt(String scope, int aliasIdx, String alias, String column) { + if (aliasIdx > 0 && isIdentChar(scope.charAt(aliasIdx - 1))) { + return false; + } + int after = aliasIdx + alias.length(); + if (after >= scope.length() || scope.charAt(after) != '.') { + return false; + } + int columnPos = after + 1; + while (columnPos < scope.length() && Character.isWhitespace(scope.charAt(columnPos))) { + columnPos++; + } + if (columnPos >= scope.length()) { + return false; + } + char first = scope.charAt(columnPos); + if (first == '`' || first == '"') { + int colStart = columnPos + 1; + int colEnd = colStart + column.length(); + return colEnd < scope.length() + && scope.regionMatches(colStart, column, 0, column.length()) + && scope.charAt(colEnd) == first; + } + int colEnd = columnPos + column.length(); + if (colEnd > scope.length() || !scope.regionMatches(columnPos, column, 0, column.length())) { + return false; + } + return colEnd == scope.length() || !isIdentChar(scope.charAt(colEnd)); + } + + private static boolean isIdentChar(char ch) { + return ch == '_' || Character.isLetterOrDigit(ch); + } + + + private static List collectTableRefs(String query) { + List clauses = SqlTopLevelClauseScanner.scan(query); + String whereScope = whereClauseScope(query, clauses); + + List refs = new ArrayList<>(); + for (int i = 0; i < clauses.size(); i++) { + SqlTopLevelClauseScanner.Clause clause = clauses.get(i); + if (!"from".equals(clause.keyword()) && !"join".equals(clause.keyword())) { + continue; + } + int segmentEnd = nextClauseStart(clauses, i, query.length()); + TableRef ref = parseTableAliasAt(query, clause, segmentEnd, whereScope); + if (ref != null) { + refs.add(ref); + } + } + return refs; + } - return startToken + " view " + String.join(", ", useIndexes) + endToken; + private static String whereClauseScope(String query, List clauses) { + for (int i = 0; i < clauses.size(); i++) { + if (!"where".equals(clauses.get(i).keyword())) { + continue; } + int start = clauses.get(i).end(); + int end = nextClauseStart(clauses, i, query.length()); + return query.substring(start, end); + } + return ""; + } + + private static int nextClauseStart(List clauses, int currentIndex, int defaultEnd) { + return (currentIndex + 1 < clauses.size()) ? clauses.get(currentIndex + 1).start() : defaultEnd; + } + + private static TableRef parseTableAliasAt( + String query, + SqlTopLevelClauseScanner.Clause clause, + int segmentEnd, + String whereScope + ) { + Matcher m = TABLE_ALIAS.matcher(query.substring(clause.end(), segmentEnd)); + if (!m.lookingAt()) { + return null; } + String alias = m.group(2); + if ("view".equalsIgnoreCase(alias)) { + return null; + } + int tableEnd = clause.end() + m.end(1); + int aliasStart = clause.end() + m.start(2); + int aliasEnd = clause.end() + m.end(2); + String scope = "join".equals(clause.keyword()) + ? query.substring(aliasEnd, segmentEnd) + : whereScope; + return new TableRef(m.group(1), alias, tableEnd, aliasStart, scope); + } - return query; + private static String applyShortHints(String query, List indexNames) { + Matcher m = SELECT_FROM_WHERE.matcher(query); + if (!m.matches() || m.groupCount() < 2) { + return query; + } + return m.group(1) + " view " + String.join(", ", indexNames) + m.group(2); + } + + private static String unquote(String identifier) { + if (identifier.length() < 2) { + return identifier; + } + char first = identifier.charAt(0); + char last = identifier.charAt(identifier.length() - 1); + if ((first == '`' && last == '`') || (first == '"' && last == '"')) { + return identifier.substring(1, identifier.length() - 1); + } + return identifier; + } + + private static String applyReplacements(String query, List replacements) { + if (replacements.isEmpty()) { + return query; + } + replacements.sort((a, b) -> Integer.compare(b.start, a.start)); + StringBuilder sb = new StringBuilder(query); + for (Replacement r : replacements) { + sb.replace(r.start, r.end, r.text); + } + return sb.toString(); } } diff --git a/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/hint/SqlTopLevelClauseScanner.java b/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/hint/SqlTopLevelClauseScanner.java new file mode 100644 index 00000000..96529bb0 --- /dev/null +++ b/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/hint/SqlTopLevelClauseScanner.java @@ -0,0 +1,166 @@ +package tech.ydb.hibernate.dialect.hint; + +import java.util.ArrayList; +import java.util.List; + +final class SqlTopLevelClauseScanner { + + record Clause(int start, int end, String keyword) { + } + + private SqlTopLevelClauseScanner() { + } + + static List scan(String query) { + List clauses = new ArrayList<>(); + char[] chars = query.toCharArray(); + int parenLevel = 0; + int keywordStart = -1; + + for (int i = 0; i < chars.length; ++i) { + char ch = chars[i]; + boolean isInsideKeyword = false; + int keywordEnd = i; + + switch (ch) { + case '\'': + i = parseSingleQuotes(chars, i); + break; + case '"': + i = parseDoubleQuotes(chars, i); + break; + case '`': + i = parseBacktickQuotes(chars, i); + break; + case '-': + i = parseLineComment(chars, i); + break; + case '/': + i = parseBlockComment(chars, i); + break; + default: + if (keywordStart >= 0) { + isInsideKeyword = Character.isJavaIdentifierPart(ch); + break; + } + isInsideKeyword = Character.isJavaIdentifierStart(ch); + if (isInsideKeyword && parenLevel == 0) { + keywordStart = i; + } + break; + } + + if (keywordStart >= 0 && (!isInsideKeyword || i == chars.length - 1)) { + int keywordLength = (isInsideKeyword ? i + 1 : keywordEnd) - keywordStart; + String keyword = matchClauseKeyword(chars, keywordStart, keywordLength); + if (keyword != null) { + clauses.add(new Clause(keywordStart, keywordStart + keywordLength, keyword)); + } + keywordStart = -1; + } + + if (ch == '(') { + parenLevel++; + } else if (ch == ')' && parenLevel > 0) { + parenLevel--; + } + } + return clauses; + } + + private static String matchClauseKeyword(char[] query, int offset, int length) { + return switch (length) { + case 4 -> matches(query, offset, "from") ? "from" + : matches(query, offset, "join") ? "join" + : null; + case 5 -> matches(query, offset, "where") ? "where" + : matches(query, offset, "order") ? "order" + : matches(query, offset, "group") ? "group" + : matches(query, offset, "limit") ? "limit" + : matches(query, offset, "union") ? "union" + : null; + case 6 -> matches(query, offset, "having") ? "having" + : matches(query, offset, "except") ? "except" + : null; + case 9 -> matches(query, offset, "intersect") ? "intersect" + : null; + default -> null; + }; + } + + private static boolean matches(char[] query, int offset, String keyword) { + if (offset + keyword.length() > query.length) { + return false; + } + for (int i = 0; i < keyword.length(); i++) { + if ((query[offset + i] | 32) != keyword.charAt(i)) { + return false; + } + } + return true; + } + + private static int parseSingleQuotes(final char[] query, int offset) { + while (++offset < query.length) { + if (query[offset] == '\\') { + ++offset; + } else if (query[offset] == '\'') { + return offset; + } + } + return query.length - 1; + } + + private static int parseDoubleQuotes(final char[] query, int offset) { + while (++offset < query.length && query[offset] != '"') { + } + return offset; + } + + private static int parseBacktickQuotes(final char[] query, int offset) { + while (++offset < query.length && query[offset] != '`') { + } + return offset; + } + + private static int parseLineComment(final char[] query, int offset) { + if (offset + 1 < query.length && query[offset + 1] == '-') { + while (offset + 1 < query.length) { + offset++; + if (query[offset] == '\r' || query[offset] == '\n') { + break; + } + } + } + return offset; + } + + private static int parseBlockComment(final char[] query, int offset) { + if (offset + 1 < query.length && query[offset + 1] == '*') { + int level = 1; + for (offset += 2; offset < query.length; ++offset) { + switch (query[offset - 1]) { + case '*': + if (query[offset] == '/') { + --level; + ++offset; + } + break; + case '/': + if (query[offset] == '*') { + ++level; + ++offset; + } + break; + default: + break; + } + if (level == 0) { + --offset; + break; + } + } + } + return offset; + } +} diff --git a/hibernate-dialect/src/test/java/tech/ydb/hibernate/hint/IndexQueryHintHandlerTest.java b/hibernate-dialect/src/test/java/tech/ydb/hibernate/hint/IndexQueryHintHandlerTest.java new file mode 100644 index 00000000..8ef075a0 --- /dev/null +++ b/hibernate-dialect/src/test/java/tech/ydb/hibernate/hint/IndexQueryHintHandlerTest.java @@ -0,0 +1,512 @@ +package tech.ydb.hibernate.hint; + +import java.util.List; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import tech.ydb.hibernate.dialect.hint.IndexQueryHintHandler; + +public class IndexQueryHintHandlerTest { + + private final IndexQueryHintHandler handler = IndexQueryHintHandler.INSTANCE; + + @Nested + class CommentRecognition { + + @Test + void recognisesShortAndTypedHints() { + assertTrue(handler.commentIsHint("use_index:foo")); + assertTrue(handler.commentIsHint("use_index:foo:bar(baz)")); + assertFalse(handler.commentIsHint("use_scan")); + assertFalse(handler.commentIsHint("add_pragma:Foo")); + } + } + + @Nested + class ShortForm { + + @Test + void rewritesFromTable() { + String query = "select g1_0.GroupId from Groups g1_0 where g1_0.GroupName='M3439'"; + String result = handler.addQueryHints(query, List.of("use_index:group_name_index")); + + assertEquals( + "select g1_0.GroupId from Groups view group_name_index g1_0 where g1_0.GroupName='M3439'", + result + ); + } + + @Test + void multipleShortHintsAreJoinedWithComma() { + String query = "select g1_0.GroupId from Groups g1_0 where g1_0.GroupName='M3439'"; + String result = handler.addQueryHints(query, List.of( + "use_index:idx_one", + "use_index:idx_two" + )); + + assertEquals( + "select g1_0.GroupId from Groups view idx_one, idx_two g1_0 where g1_0.GroupName='M3439'", + result + ); + } + + @Test + void noWhereClauseLeavesQueryAlone() { + String query = "select g1_0.GroupId from Groups g1_0"; + String result = handler.addQueryHints(query, List.of("use_index:group_name_index")); + + assertEquals(query, result); + } + + @Test + void hintsAreIgnoredWhenPrefixDoesNotMatch() { + String query = "select g1_0.GroupId from Groups g1_0 where g1_0.GroupName='M3439'"; + String result = handler.addQueryHints(query, List.of("use_scan", "add_pragma:Foo")); + + assertEquals(query, result); + } + } + + @Nested + class TypedFormSingleColumn { + + @Test + void rewritesSingleJoin() { + String query = "select * from orders o1_0 " + + "left join customers c1_0 on c1_0.code=o1_0.acc_dt_code " + + "where o1_0.id='X'"; + String result = handler.addQueryHints( + query, + List.of("use_index:customers_code_idx:customers(code)") + ); + + assertEquals( + "select * from orders o1_0 " + + "left join customers view customers_code_idx c1_0 on c1_0.code=o1_0.acc_dt_code " + + "where o1_0.id='X'", + result + ); + } + + @Test + void rewritesTwoJoinsOfSameTableOnDifferentColumns() { + String query = "select * from orders o1_0 " + + "left join customers c1_0 on c1_0.code=source.code " + + "left join customers c3_0 on c3_0.parent=source.parent " + + "where o1_0.id='X'"; + String result = handler.addQueryHints( + query, + List.of( + "use_index:customers_code_idx:customers(code)", + "use_index:customers_parent_idx:customers(parent)" + ) + ); + + assertEquals( + "select * from orders o1_0 " + + "left join customers view customers_code_idx c1_0 on c1_0.code=source.code " + + "left join customers view customers_parent_idx c3_0 on c3_0.parent=source.parent " + + "where o1_0.id='X'", + result + ); + } + + @Test + void skipsJoinsWhereColumnIsAbsent() { + String query = "select * from orders o1_0 " + + "left join customers c1_0 on c1_0.id=o1_0.acc_dt_id " + + "where o1_0.id='X'"; + String result = handler.addQueryHints( + query, + List.of("use_index:customers_code_idx:customers(code)") + ); + + assertEquals(query, result); + } + + @Test + void leavesUnrelatedTablesUntouched() { + String query = "select * from orders o1_0 " + + "left join customers c1_0 on c1_0.code=source.code " + + "left join regions r1_0 on r1_0.code=source.code " + + "where o1_0.id='X'"; + String result = handler.addQueryHints( + query, + List.of("use_index:customers_code_idx:customers(code)") + ); + + assertEquals( + "select * from orders o1_0 " + + "left join customers view customers_code_idx c1_0 on c1_0.code=source.code " + + "left join regions r1_0 on r1_0.code=source.code " + + "where o1_0.id='X'", + result + ); + } + + @Test + void rewritesFromTableWhenColumnAppearsInWhere() { + String query = "select g1_0.GroupId from Groups g1_0 where g1_0.GroupName='M3439'"; + String result = handler.addQueryHints( + query, + List.of("use_index:group_name_index:Groups(GroupName)") + ); + + assertEquals( + "select g1_0.GroupId from Groups view group_name_index g1_0 where g1_0.GroupName='M3439'", + result + ); + } + + @Test + void leavesFromTableAloneWhenColumnNotInWhere() { + String query = "select g1_0.GroupId from Groups g1_0 where g1_0.GroupId=1"; + String result = handler.addQueryHints( + query, + List.of("use_index:group_name_index:Groups(GroupName)") + ); + + assertEquals(query, result); + } + } + + @Nested + class TypedFormCompositeColumns { + + + @Test + void fullColumnMatch_appliesIndex() { + String query = "select * from orders o1_0 " + + "left join customers c1_0 on c1_0.code=source.code and c1_0.parent=source.parent " + + "where o1_0.id='X'"; + String result = handler.addQueryHints( + query, + List.of("use_index:customers_combo_idx:customers(code,parent)") + ); + + assertEquals( + "select * from orders o1_0 " + + "left join customers view customers_combo_idx c1_0 on c1_0.code=source.code and c1_0.parent=source.parent " + + "where o1_0.id='X'", + result + ); + } + + + @Test + void partialColumnMatch_doesNotApply() { + String query = "select * from orders o1_0 " + + "left join customers c1_0 on c1_0.code=source.code " + + "left join customers c3_0 on c3_0.parent=source.parent " + + "where o1_0.id='X'"; + String result = handler.addQueryHints( + query, + List.of("use_index:customers_combo_idx:customers(code,parent)") + ); + + assertEquals(query, result); + } + + + @Test + void bestMatchWins_mostSpecificIndexPicked() { + String query = "select * from orders o1_0 " + + "left join customers c1_0 on c1_0.code=source.code and c1_0.parent=source.parent " + + "where o1_0.id='X'"; + String result = handler.addQueryHints( + query, + List.of( + "use_index:customers_code_idx:customers(code)", + "use_index:customers_combo_idx:customers(code,parent)" + ) + ); + + assertEquals( + "select * from orders o1_0 " + + "left join customers view customers_combo_idx c1_0 on c1_0.code=source.code and c1_0.parent=source.parent " + + "where o1_0.id='X'", + result + ); + } + + + @Test + void partialCompositeFallsBackToFullSingleColumn() { + String query = "select * from orders o1_0 " + + "left join customers c1_0 on c1_0.code=source.code " + + "where o1_0.id='X'"; + String result = handler.addQueryHints( + query, + List.of( + "use_index:customers_combo_idx:customers(code,parent)", + "use_index:customers_code_idx:customers(code)" + ) + ); + + assertEquals( + "select * from orders o1_0 " + + "left join customers view customers_code_idx c1_0 on c1_0.code=source.code " + + "where o1_0.id='X'", + result + ); + } + } + + @Nested + class IdentifierBoundaries { + + + @Test + void columnNameIsNotASubstringMatch() { + String query = "select * from orders o1_0 " + + "left join customers c1_0 on c1_0.acc_dt_code=source.x " + + "left join customers c2_0 on c2_0.code1=source.y " + + "where o1_0.id='X'"; + String result = handler.addQueryHints( + query, + List.of("use_index:customers_code_idx:customers(code)") + ); + + assertEquals(query, result); + } + + + @Test + void aliasIsNotASubstringMatch() { + String query = "select * from orders o1_0 " + + "left join customers c1_0 on xo1_0.code=source.code " + + "where o1_0.id='X'"; + String result = handler.addQueryHints( + query, + List.of("use_index:customers_code_idx:customers(code)") + ); + + assertEquals(query, result); + } + } + + @Nested + class QuotedIdentifiers { + + @Test + void backtickQuotedTableNameInSqlIsRewritten() { + String query = "select g1_0.GroupId from `Groups` g1_0 where g1_0.GroupName='M3439'"; + String result = handler.addQueryHints( + query, + List.of("use_index:group_name_index:Groups(GroupName)") + ); + + assertEquals( + "select g1_0.GroupId from `Groups` view group_name_index g1_0 where g1_0.GroupName='M3439'", + result + ); + } + + @Test + void backtickQuotedTableNameInHintIsAccepted() { + String query = "select g1_0.GroupId from Groups g1_0 where g1_0.GroupName='M3439'"; + String result = handler.addQueryHints( + query, + List.of("use_index:group_name_index:`Groups`(GroupName)") + ); + + assertEquals( + "select g1_0.GroupId from Groups view group_name_index g1_0 where g1_0.GroupName='M3439'", + result + ); + } + + @Test + void backtickQuotedColumnInOnClauseIsRecognised() { + String query = "select * from orders o1_0 " + + "left join customers c1_0 on c1_0.`code`=source.code " + + "where o1_0.id='X'"; + String result = handler.addQueryHints( + query, + List.of("use_index:customers_code_idx:customers(code)") + ); + + assertEquals( + "select * from orders o1_0 " + + "left join customers view customers_code_idx c1_0 on c1_0.`code`=source.code " + + "where o1_0.id='X'", + result + ); + } + } + + @Nested + class Interactions { + + @Test + void alreadyAppliedViewIsNotInjectedAgain() { + String alreadyHinted = "select g1_0.GroupId from Groups view group_name_index g1_0 " + + "where g1_0.GroupName='M3439'"; + String result = handler.addQueryHints( + alreadyHinted, + List.of("use_index:group_name_index:Groups(GroupName)") + ); + + assertEquals(alreadyHinted, result); + } + + @Test + void shortFormSkippedWhenTypedAlreadyAddedView() { + String query = "select g1_0.GroupId from Groups g1_0 where g1_0.GroupName='M3439'"; + String result = handler.addQueryHints( + query, + List.of( + "use_index:group_name_index:Groups(GroupName)", + "use_index:other_index" + ) + ); + + assertEquals( + "select g1_0.GroupId from Groups view group_name_index g1_0 where g1_0.GroupName='M3439'", + result + ); + } + + @Test + void largeRealisticQueryWithMultipleAliasesOfSameTable() { + String query = "select o1_0.id,c1_0.id,c3_0.id,r1_0.id " + + "from orders o1_0 " + + "left join customers c1_0 on c1_0.id=o1_0.acc_dt_id " + + "left join customers c3_0 on c3_0.id=o1_0.acc_kt_id " + + "left join regions r1_0 on r1_0.id=o1_0.branch_id " + + "where o1_0.id='abc'"; + String result = handler.addQueryHints( + query, + List.of( + "use_index:customers_id_idx:customers(id)", + "use_index:regions_id_idx:regions(id)" + ) + ); + + assertEquals( + "select o1_0.id,c1_0.id,c3_0.id,r1_0.id " + + "from orders o1_0 " + + "left join customers view customers_id_idx c1_0 on c1_0.id=o1_0.acc_dt_id " + + "left join customers view customers_id_idx c3_0 on c3_0.id=o1_0.acc_kt_id " + + "left join regions view regions_id_idx r1_0 on r1_0.id=o1_0.branch_id " + + "where o1_0.id='abc'", + result + ); + } + + @Test + void malformedTypedHintIsIgnored() { + String query = "select g1_0.GroupId from Groups g1_0 where g1_0.GroupName='M3439'"; + String result = handler.addQueryHints( + query, + List.of("use_index:group_name_index:Groups(GroupName") + ); + + assertTrue(result.contains("from Groups")); + } + } + + + @Nested + class ShortFormQuotedTable { + + @Test + void rewritesBacktickQuotedFromTable() { + String query = "select g1_0.GroupId from `Groups` g1_0 where g1_0.GroupName='M3439'"; + String result = handler.addQueryHints(query, List.of("use_index:group_name_index")); + + assertEquals( + "select g1_0.GroupId from `Groups` view group_name_index g1_0 where g1_0.GroupName='M3439'", + result + ); + } + + @Test + void rewritesDoubleQuotedFromTable() { + String query = "select g1_0.GroupId from \"Groups\" g1_0 where g1_0.GroupName='M3439'"; + String result = handler.addQueryHints(query, List.of("use_index:group_name_index")); + + assertEquals( + "select g1_0.GroupId from \"Groups\" view group_name_index g1_0 where g1_0.GroupName='M3439'", + result + ); + } + + @Test + void shortFormNotInjectedTwiceForQuotedTableThatAlreadyHasView() { + String alreadyHinted = + "select g1_0.GroupId from `Groups` view group_name_index g1_0 where g1_0.GroupName='M3439'"; + String result = handler.addQueryHints(alreadyHinted, List.of("use_index:group_name_index")); + + assertEquals(alreadyHinted, result); + } + } + + + @Nested + class TopLevelClauseBoundaries { + + @Test + void joinKeywordInsideStringLiteral_doesNotCreateSpuriousJoin() { + String query = "select * from orders o1_0 " + + "left join customers c1_0 on c1_0.code=o1_0.x " + + "where o1_0.note='text with left join customers fake on fake.code=x'"; + String result = handler.addQueryHints( + query, + List.of("use_index:customers_code_idx:customers(code)") + ); + + assertEquals( + "select * from orders o1_0 " + + "left join customers view customers_code_idx c1_0 on c1_0.code=o1_0.x " + + "where o1_0.note='text with left join customers fake on fake.code=x'", + result + ); + } + + @Test + void fromInsideSubquery_isNotRewritten() { + String query = "select * from orders o1_0 " + + "left join (select x.id from customers x where x.parent='p') c1_0 on c1_0.id=o1_0.id " + + "left join customers c2_0 on c2_0.code=o1_0.code " + + "where o1_0.id='X'"; + String result = handler.addQueryHints( + query, + List.of( + "use_index:customers_code_idx:customers(code)", + "use_index:customers_parent_idx:customers(parent)" + ) + ); + + assertTrue(result.contains("left join customers view customers_code_idx c2_0 on c2_0.code")); + assertFalse(result.contains("customers view customers_code_idx c1_0")); + assertFalse(result.contains("customers view customers_parent_idx")); + assertFalse(result.contains("from customers view")); + } + + @Test + void joinInsideBlockComment_doesNotSplitSegments() { + String query = "select * from orders o1_0 /* join decoy */ " + + "left join customers c1_0 on c1_0.code=o1_0.x where o1_0.id='X'"; + String result = handler.addQueryHints( + query, + List.of("use_index:customers_code_idx:customers(code)") + ); + + assertEquals( + "select * from orders o1_0 /* join decoy */ " + + "left join customers view customers_code_idx c1_0 on c1_0.code=o1_0.x where o1_0.id='X'", + result + ); + } + + assertEquals( + "select * from orders o1_0 " + + "left join customers view customers_code_idx c1_0 on c1_0.code=o1_0.x where o1_0.id='X'", + result + ); + } + } +} diff --git a/hibernate-dialect/src/test/java/tech/ydb/hibernate/index/BankAccount.java b/hibernate-dialect/src/test/java/tech/ydb/hibernate/index/BankAccount.java new file mode 100644 index 00000000..8437d8a6 --- /dev/null +++ b/hibernate-dialect/src/test/java/tech/ydb/hibernate/index/BankAccount.java @@ -0,0 +1,70 @@ +package tech.ydb.hibernate.index; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +/** + * Account table that is joined several times under different aliases and different columns, + * reproducing the real "one table, many joins" scenario the index hints are built for. + *

+ * Three secondary indexes let a test pin a different {@code VIEW} per join. + */ +@Entity +@Table( + name = "bank_account", + indexes = { + @Index(name = "bank_account_code_idx", columnList = "code"), + @Index(name = "bank_account_parent_idx", columnList = "parent"), + @Index(name = "bank_account_combo_idx", columnList = "code, parent") + } +) +public class BankAccount { + + @Id + @Column(name = "id") + private int id; + + @Column(name = "code") + private String code; + + @Column(name = "parent") + private String parent; + + @Column(name = "name") + private String name; + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getParent() { + return parent; + } + + public void setParent(String parent) { + this.parent = parent; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/hibernate-dialect/src/test/java/tech/ydb/hibernate/index/BankDocument.java b/hibernate-dialect/src/test/java/tech/ydb/hibernate/index/BankDocument.java new file mode 100644 index 00000000..8853a4a6 --- /dev/null +++ b/hibernate-dialect/src/test/java/tech/ydb/hibernate/index/BankDocument.java @@ -0,0 +1,76 @@ +package tech.ydb.hibernate.index; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinColumns; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +/** + * A document that references {@link BankAccount} three times, each association joining the + * same table on different column(s). Hibernate generates joins such as: + *

+ *   join bank_account a1_0 on a1_0.code=d1_0.acc_dt_code                              (accDt)
+ *   join bank_account a2_0 on a2_0.parent=d1_0.acc_kt_parent                          (accKt)
+ *   join bank_account a3_0 on a3_0.code=d1_0.acc_combo_code and a3_0.parent=...        (accCombo)
+ * 
+ * which is exactly what the column-aware {@code use_index} hint needs to distinguish. + */ +@Entity +@Table(name = "bank_document") +public class BankDocument { + + @Id + @Column(name = "id") + private int id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "acc_dt_code", referencedColumnName = "code") + private BankAccount accDt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "acc_kt_parent", referencedColumnName = "parent") + private BankAccount accKt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumns({ + @JoinColumn(name = "acc_combo_code", referencedColumnName = "code"), + @JoinColumn(name = "acc_combo_parent", referencedColumnName = "parent") + }) + private BankAccount accCombo; + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public BankAccount getAccDt() { + return accDt; + } + + public void setAccDt(BankAccount accDt) { + this.accDt = accDt; + } + + public BankAccount getAccKt() { + return accKt; + } + + public void setAccKt(BankAccount accKt) { + this.accKt = accKt; + } + + public BankAccount getAccCombo() { + return accCombo; + } + + public void setAccCombo(BankAccount accCombo) { + this.accCombo = accCombo; + } +} diff --git a/hibernate-dialect/src/test/java/tech/ydb/hibernate/index/BankDocumentIndexHintTest.java b/hibernate-dialect/src/test/java/tech/ydb/hibernate/index/BankDocumentIndexHintTest.java new file mode 100644 index 00000000..499fcb69 --- /dev/null +++ b/hibernate-dialect/src/test/java/tech/ydb/hibernate/index/BankDocumentIndexHintTest.java @@ -0,0 +1,182 @@ +package tech.ydb.hibernate.index; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +import org.hibernate.SessionFactory; +import org.hibernate.cfg.AvailableSettings; +import org.hibernate.cfg.Configuration; +import org.hibernate.jpa.HibernateHints; +import org.hibernate.query.Query; +import org.hibernate.resource.jdbc.spi.StatementInspector; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import tech.ydb.hibernate.TestUtils; +import tech.ydb.test.junit5.YdbHelperExtension; + +/** + * @author Kirill Kurdyukov + */ +public class BankDocumentIndexHintTest { + + @RegisterExtension + private static final YdbHelperExtension ydb = new YdbHelperExtension(); + + private static SessionFactory sessionFactory; + + @BeforeAll + static void beforeAll() { + Configuration configuration = TestUtils.basedConfiguration() + .setProperty(AvailableSettings.URL, TestUtils.jdbcUrl(ydb)) + .setProperty(AvailableSettings.STATEMENT_INSPECTOR, CapturingStatementInspector.class.getName()) + .addAnnotatedClass(BankAccount.class) + .addAnnotatedClass(BankDocument.class); + + sessionFactory = configuration.buildSessionFactory(); + + sessionFactory.inTransaction(session -> { + BankAccount a1 = newAccount(1, "ACC-CODE-1", "PARENT-1", "first"); + BankAccount a2 = newAccount(2, "ACC-CODE-2", "PARENT-2", "second"); + session.persist(a1); + session.persist(a2); + + BankDocument document = new BankDocument(); + document.setId(1); + document.setAccDt(a1); + document.setAccKt(a2); + document.setAccCombo(a1); + session.persist(document); + }); + } + + @BeforeEach + void clearCapturedSql() { + CapturingStatementInspector.clear(); + } + + + @Test + void sameTableJoinedTwice_byDifferentColumns_getsTwoDifferentIndexes() { + String joins = "join fetch d.accDt join fetch d.accKt"; + String hintCode = "use_index:bank_account_code_idx:bank_account(code)"; + String hintParent = "use_index:bank_account_parent_idx:bank_account(parent)"; + + runDocumentQuery(joins, query -> query.addQueryHint(hintCode).addQueryHint(hintParent)); + assertDocumentSql(sql -> { + assertTrue(sql.contains("bank_account view bank_account_code_idx"), + () -> "expected code index on the code join, got:\n" + sql); + assertTrue(sql.contains("bank_account view bank_account_parent_idx"), + () -> "expected parent index on the parent join, got:\n" + sql); + }); + + clearCapturedSql(); + runDocumentQuery(joins, query -> query.setHint(HibernateHints.HINT_COMMENT, hintCode + ";" + hintParent)); + assertDocumentSql(sql -> { + assertTrue(sql.contains("bank_account view bank_account_code_idx"), + () -> "expected code index via HINT_COMMENT, got:\n" + sql); + assertTrue(sql.contains("bank_account view bank_account_parent_idx"), + () -> "expected parent index via HINT_COMMENT, got:\n" + sql); + }); + } + + + @Test + void compositeJoin_picksIndexWithMostColumnMatches() { + String joins = "join fetch d.accCombo"; + String hintCode = "use_index:bank_account_code_idx:bank_account(code)"; + String hintCombo = "use_index:bank_account_combo_idx:bank_account(code,parent)"; + + runDocumentQuery(joins, query -> query.addQueryHint(hintCode).addQueryHint(hintCombo)); + assertDocumentSql(sql -> { + assertTrue(sql.contains("bank_account view bank_account_combo_idx"), + () -> "expected the composite index to win, got:\n" + sql); + assertFalse(sql.contains("bank_account view bank_account_code_idx"), + () -> "single-column index must not win over the composite one, got:\n" + sql); + }); + + clearCapturedSql(); + runDocumentQuery(joins, query -> query.setHint(HibernateHints.HINT_COMMENT, hintCode + ";" + hintCombo)); + assertDocumentSql(sql -> { + assertTrue(sql.contains("bank_account view bank_account_combo_idx"), + () -> "expected the composite index to win via HINT_COMMENT, got:\n" + sql); + assertFalse(sql.contains("bank_account view bank_account_code_idx"), + () -> "single-column index must not win via HINT_COMMENT, got:\n" + sql); + }); + } + + + @Test + void compositeHint_withOneColumnMissing_isNotApplied() { + String joins = "join fetch d.accDt"; + String hintCombo = "use_index:bank_account_combo_idx:bank_account(code,parent)"; + + runDocumentQuery(joins, query -> query.addQueryHint(hintCombo)); + assertDocumentSql(sql -> assertFalse(sql.contains("bank_account view"), + () -> "partial composite hint must not inject a view, got:\n" + sql)); + + clearCapturedSql(); + runDocumentQuery(joins, query -> query.setHint(HibernateHints.HINT_COMMENT, hintCombo)); + assertDocumentSql(sql -> assertFalse(sql.contains("bank_account view"), + () -> "partial composite hint must not inject a view via HINT_COMMENT, got:\n" + sql)); + } + + private void runDocumentQuery(String joinClause, Consumer> configure) { + sessionFactory.inTransaction(session -> { + Query query = session.createQuery( + "select d from BankDocument d " + joinClause + " where d.id = 1", + BankDocument.class + ); + configure.accept(query); + BankDocument document = query.getSingleResult(); + assertEquals(1, document.getId()); + }); + } + + private void assertDocumentSql(Consumer assertion) { + Optional sql = CapturingStatementInspector.lastSqlContaining("from bank_document"); + assertTrue(sql.isPresent(), "no SELECT against bank_document was captured"); + assertion.accept(sql.get()); + } + + private static BankAccount newAccount(int id, String code, String parent, String name) { + BankAccount account = new BankAccount(); + account.setId(id); + account.setCode(code); + account.setParent(parent); + account.setName(name); + return account; + } + + + public static final class CapturingStatementInspector implements StatementInspector { + + private static final List CAPTURED = new ArrayList<>(); + + public static synchronized void clear() { + CAPTURED.clear(); + } + + static synchronized Optional lastSqlContaining(String needle) { + for (int i = CAPTURED.size() - 1; i >= 0; i--) { + if (CAPTURED.get(i).contains(needle)) { + return Optional.of(CAPTURED.get(i)); + } + } + return Optional.empty(); + } + + @Override + public synchronized String inspect(String sql) { + CAPTURED.add(sql); + return sql; + } + } +} diff --git a/hibernate-dialect/src/test/java/tech/ydb/hibernate/student/StudentsRepositoryTest.java b/hibernate-dialect/src/test/java/tech/ydb/hibernate/student/StudentsRepositoryTest.java index c5524280..3312af3a 100644 --- a/hibernate-dialect/src/test/java/tech/ydb/hibernate/student/StudentsRepositoryTest.java +++ b/hibernate-dialect/src/test/java/tech/ydb/hibernate/student/StudentsRepositoryTest.java @@ -205,6 +205,125 @@ void groupByGroupName_ViewIndex() { ); } + @Test + void studentsByGroupName_TypedViewIndex_JoinTable() { + /* + select + g1_0.GroupId, + g1_0.GroupName, + s1_0.GroupId, + s1_0.StudentId, + s1_0.StudentName + from + Groups view group_name_index g1_0 + join + Students view students_group_index s1_0 + on g1_0.GroupId=s1_0.GroupId + where + g1_0.GroupName='M3439' + */ + inTransaction( + session -> { + List students = session + .createQuery("FROM Group g JOIN FETCH g.students WHERE g.name = 'M3439'", Group.class) + .addQueryHint("use_index:group_name_index:Groups(GroupName)") + .addQueryHint("use_index:students_group_index:Students(GroupId)") + .getSingleResult().getStudents(); + + assertEquals(2, students.size()); + assertEquals("Петров П.П.", students.get(0).getName()); + assertEquals("Сидоров С.С.", students.get(1).getName()); + } + ); + + inTransaction( + session -> { + List students = session + .createQuery("FROM Group g JOIN FETCH g.students WHERE g.name = 'M3439'", Group.class) + .setHint( + HibernateHints.HINT_COMMENT, + "use_index:group_name_index:Groups(GroupName);" + + "use_index:students_group_index:Students(GroupId)" + ) + .getSingleResult().getStudents(); + + assertEquals(2, students.size()); + assertEquals("Петров П.П.", students.get(0).getName()); + assertEquals("Сидоров С.С.", students.get(1).getName()); + } + ); + } + + @Test + void typedViewIndex_columnDoesNotMatch_queryStillWorks() { + // Hint targets Students.StudentName, but the JOIN's ON clause references only GroupId. + // The handler must NOT inject a view in this case; the query should still execute. + inTransaction( + session -> { + List students = session + .createQuery("FROM Group g JOIN FETCH g.students WHERE g.name = 'M3439'", Group.class) + .addQueryHint("use_index:students_group_index:Students(StudentName)") + .getSingleResult().getStudents(); + + assertEquals(2, students.size()); + assertEquals("Петров П.П.", students.get(0).getName()); + assertEquals("Сидоров С.С.", students.get(1).getName()); + } + ); + } + + @Test + void typedViewIndex_partialCompositeMatch_doesNotApply() { + // The composite hint requires BOTH StudentName and GroupId in the JOIN's ON clause; + // only GroupId is referenced, so the index must NOT be applied — the query still runs. + inTransaction( + session -> { + List students = session + .createQuery("FROM Group g JOIN FETCH g.students WHERE g.name = 'M3439'", Group.class) + .addQueryHint("use_index:students_group_index:Students(GroupId,StudentName)") + .getSingleResult().getStudents(); + + assertEquals(2, students.size()); + assertEquals("Петров П.П.", students.get(0).getName()); + assertEquals("Сидоров С.С.", students.get(1).getName()); + } + ); + } + + @Test + void groupByGroupName_TypedViewIndex_TableColumn() { + /* + select + g1_0.GroupId, + g1_0.GroupName + from + Groups view group_name_index g1_0 + where + g1_0.GroupName='M3439' + */ + inTransaction( + session -> { + Group group = session + .createQuery("FROM Group g WHERE g.name = 'M3439'", Group.class) + .addQueryHint("use_index:group_name_index:Groups(GroupName)") // Hibernate + .getSingleResult(); + + assertEquals("M3439", group.getName()); + } + ); + + inTransaction( + session -> { + Group group = session + .createQuery("FROM Group g WHERE g.name = 'M3439'", Group.class) + .setHint(HibernateHints.HINT_COMMENT, "use_index:group_name_index:Groups(GroupName)") // JPA + .getSingleResult(); + + assertEquals("M3439", group.getName()); + } + ); + } + @Test void studentsByGroupName_Eager_OneToManyTest() { inTransaction( diff --git a/hibernate-dialect/src/test/java/tech/ydb/hibernate/student/entity/Student.java b/hibernate-dialect/src/test/java/tech/ydb/hibernate/student/entity/Student.java index 6b65bf09..77c96ab6 100644 --- a/hibernate-dialect/src/test/java/tech/ydb/hibernate/student/entity/Student.java +++ b/hibernate-dialect/src/test/java/tech/ydb/hibernate/student/entity/Student.java @@ -4,6 +4,7 @@ import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToMany; import jakarta.persistence.ManyToOne; @@ -17,7 +18,7 @@ */ @Data @Entity -@Table(name = "Students") +@Table(name = "Students", indexes = @Index(name = "students_group_index", columnList = "GroupId")) public class Student { @Id