diff --git a/.github/workflows/ci-hibernate-dialect-v7.yaml b/.github/workflows/ci-hibernate-dialect-v7.yaml index 4e5a7cc6..a9720bdc 100644 --- a/.github/workflows/ci-hibernate-dialect-v7.yaml +++ b/.github/workflows/ci-hibernate-dialect-v7.yaml @@ -32,12 +32,6 @@ jobs: distribution: 'temurin' cache: maven - - name: Extract Hibernate Dialect version - working-directory: ./hibernate-dialect-v7 - run: | - VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout) - echo "HIBERNATE_DIALECT_VERSION=$VERSION" >> "$GITHUB_ENV" - - name: Download Hibernate Dialect dependencies working-directory: ./hibernate-dialect-v7 run: mvn $MAVEN_ARGS dependency:go-offline @@ -45,17 +39,3 @@ jobs: - name: Build Hibernate Dialect working-directory: ./hibernate-dialect-v7 run: mvn $MAVEN_ARGS install - - - uses: actions/checkout@v5 - with: - repository: ydb-platform/ydb-java-examples - ref: master - path: examples - - - name: Download dependencies - working-directory: ./examples/jdbc/spring-data-jpa - run: mvn $MAVEN_ARGS -Dhibernate.ydb.dialect.version=$HIBERNATE_DIALECT_VERSION dependency:go-offline - - - name: Test examples with Maven - working-directory: ./examples/jdbc/spring-data-jpa - run: mvn $MAVEN_ARGS -Dhibernate.ydb.dialect.version=$HIBERNATE_DIALECT_VERSION test diff --git a/hibernate-dialect-v6/CHANGELOG.md b/hibernate-dialect-v6/CHANGELOG.md index 09cff396..3b303b8c 100644 --- a/hibernate-dialect-v6/CHANGELOG.md +++ b/hibernate-dialect-v6/CHANGELOG.md @@ -1,3 +1,11 @@ +## 1.7.0 ## + +- Extended `use_index:` query hint with table-and-column-aware format + `use_index::([,...])` so the same table joined multiple times under different aliases can be pinned to different indexes via `VIEW` +- `HINT_COMMENT` hints separated by `;` are now passed to each handler as a single batch, so the + best-match-wins rule for competing `use_index` hints works the same as via `Query#addQueryHint` +- Fixed the identifier regex so quoted (`` `Table` `` / `"Table"`) FROM tables are rewritten correctly + ## 1.6.0 ## - Add YDB constraint violation exception mapping to hibernate specific exception diff --git a/hibernate-dialect-v6/README.md b/hibernate-dialect-v6/README.md index c8ac7ada..ee429f20 100644 --- a/hibernate-dialect-v6/README.md +++ b/hibernate-dialect-v6/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. Table and column identifiers in the hint may be written bare, in `` ` `` backticks, or in `"…"` double quotes. | +| `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-v6/pom.xml b/hibernate-dialect-v6/pom.xml index 84221ae6..27563d03 100644 --- a/hibernate-dialect-v6/pom.xml +++ b/hibernate-dialect-v6/pom.xml @@ -6,7 +6,7 @@ tech.ydb.dialects hibernate-ydb-dialect - 1.6.0 + 1.7.0 jar @@ -149,6 +149,26 @@ + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + prepare-agent + + prepare-agent + + + + report + test + + report + + + + diff --git a/hibernate-dialect-v6/src/main/java/tech/ydb/hibernate/dialect/YdbDialect.java b/hibernate-dialect-v6/src/main/java/tech/ydb/hibernate/dialect/YdbDialect.java index bd79ca5c..132d1175 100644 --- a/hibernate-dialect-v6/src/main/java/tech/ydb/hibernate/dialect/YdbDialect.java +++ b/hibernate-dialect-v6/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; @@ -288,17 +289,22 @@ 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; + // Feed every handler the full list of hints it owns in a single call. Passing all + // of a handler's hints at once is what lets IndexQueryHintHandler compare competing + // use_index hints (best-match-wins) instead of applying them one by one. 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-v6/src/main/java/tech/ydb/hibernate/dialect/hint/IndexQueryHintHandler.java b/hibernate-dialect-v6/src/main/java/tech/ydb/hibernate/dialect/hint/IndexQueryHintHandler.java index 75e9eb14..5ec2d848 100644 --- a/hibernate-dialect-v6/src/main/java/tech/ydb/hibernate/dialect/hint/IndexQueryHintHandler.java +++ b/hibernate-dialect-v6/src/main/java/tech/ydb/hibernate/dialect/hint/IndexQueryHintHandler.java @@ -1,21 +1,76 @@ 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; /** + * Handles {@code use_index:} query hints. + *

+ * Two formats are supported: + *

    + *
  • {@code use_index:} — short form. Rewrites the FROM table of a + * simple {@code select ... from
... where ...} query to use the given view. + * Useful when the query has exactly one FROM table. + *
  • {@code use_index::([,...])} — + * table/column-aware form. Each top-level {@code FROM}/{@code JOIN} occurrence of + * {@code } is rewritten to {@code view } + * provided every column listed in the hint is referenced by that alias inside the + * relevant scope (the {@code ON} clause for a JOIN, the {@code WHERE} clause for the + * FROM table). Clause boundaries respect string literals, comments and parenthesis + * nesting. When several hints fully match the same + * table reference the most specific one (largest number of columns) wins.
  • + * + * Both forms may be mixed: the short-form rewrite is skipped if a typed hint already + * attached a {@code view} to the FROM table. + * * @author Kirill Kurdyukov */ 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:"; + /** + * One SQL identifier — bare, {@code `backtick`}- or {@code "double"}-quoted. + * Kept in a non-capturing group so embedding it never leaks its alternatives. + */ + private static final String IDENT = "(?:\\w+|`[^`]+`|\"[^\"]+\")"; + + /** + * Parses the body of a typed hint {@code :([,...])}. + *
    +     *   group(1) = index name   ([^:]+ — everything up to the first ':')
    +     *   group(2) = table name   ([^(]+ — everything up to the first '(')
    +     *   group(3) = column list  ([^)]+ — everything inside the parentheses)
    +     * 
    + */ + private static final Pattern TYPED_HINT_BODY = Pattern.compile("^([^:]+):([^(]+)\\(([^)]+)\\)$"); + + /** + * Matches the {@code
    } pair at the start of a FROM/JOIN segment. + * group(1) = table identifier, group(2) = alias. + */ + private static final Pattern TABLE_ALIAS = Pattern.compile("\\s*(" + IDENT + ")\\s+(\\w+)"); + + /** + * Splits a simple {@code select ... from
    ... where ...} query into the part + * up to and including the FROM table (group 1) and the rest starting at WHERE (group 2), + * so the short-form rewrite can insert {@code view } between them. + */ + private static final Pattern SELECT_FROM_WHERE = Pattern + .compile("^\\s*(select.+?from\\s+" + IDENT + ")(.+where.+)$", Pattern.CASE_INSENSITIVE); + + /** + * Detects an already present {@code from
    view ...} so we never inject a second view there. + */ + private static final Pattern FROM_HAS_VIEW = Pattern + .compile("\\bfrom\\s+" + IDENT + "\\s+view\\b", Pattern.CASE_INSENSITIVE); + private IndexQueryHintHandler() { } @@ -26,23 +81,265 @@ 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; + } + + // ---------- Hint parsing ---------- + + 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); + } - 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 List splitColumns(String raw) { + return Arrays.stream(raw.split(",")) + .map(String::trim) + .map(IndexQueryHintHandler::unquote) + .filter(s -> !s.isEmpty()) + .toList(); + } + + // ---------- Typed-hint rewrite ---------- + + 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); + } + + /** + * Returns the index hint whose columns are all referenced in the scope and + * that has the largest number of columns among such full matches. Returns + * {@code null} when no hint is a full match. + */ + 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; + } - return startToken + " view " + String.join(", ", useIndexes) + endToken; + private static boolean allColumnsReferenced(String scope, String alias, List columns) { + for (String column : columns) { + if (!aliasColumnInScope(scope, alias, column)) { + return false; } } + return true; + } - return query; + /** + * True if {@code alias.column} (optionally {@code `quoted`}) appears in {@code scope}. + * A plain string scan rather than a regex, because this runs on every query execution. + */ + 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; + } + } + + 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); + } + + // ---------- Scanning FROM/JOIN segments ---------- + + /** + * Walks the query, finds every top-level {@code FROM } and {@code JOIN } + * pair and records the column-search scope for it (the join's ON clause, or the + * WHERE clause for the FROM table). + */ + 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; + } + + 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); + } + + // ---------- Short-form rewrite ---------- + + 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); + } + + // ---------- Helpers ---------- + + 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-v6/src/main/java/tech/ydb/hibernate/dialect/hint/SqlTopLevelClauseScanner.java b/hibernate-dialect-v6/src/main/java/tech/ydb/hibernate/dialect/hint/SqlTopLevelClauseScanner.java new file mode 100644 index 00000000..723bee89 --- /dev/null +++ b/hibernate-dialect-v6/src/main/java/tech/ydb/hibernate/dialect/hint/SqlTopLevelClauseScanner.java @@ -0,0 +1,172 @@ +package tech.ydb.hibernate.dialect.hint; + +import java.util.ArrayList; +import java.util.List; + +/** + * Locates SQL clause keywords at parenthesis nesting level zero, skipping string + * literals and comments. + */ +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] != '"') { + // skip + } + return offset; + } + + private static int parseBacktickQuotes(final char[] query, int offset) { + while (++offset < query.length && query[offset] != '`') { + // skip + } + 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-v6/src/test/java/tech/ydb/hibernate/hint/IndexQueryHintHandlerTest.java b/hibernate-dialect-v6/src/test/java/tech/ydb/hibernate/hint/IndexQueryHintHandlerTest.java new file mode 100644 index 00000000..8263141d --- /dev/null +++ b/hibernate-dialect-v6/src/test/java/tech/ydb/hibernate/hint/IndexQueryHintHandlerTest.java @@ -0,0 +1,519 @@ +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; + +/** + * Unit tests for {@link 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 { + + /** When every column of the hint appears in the JOIN, the index is attached. */ + @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 + ); + } + + /** If even one column of the hint is missing the index is NOT applied. */ + @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); + } + + /** When several hints fully match, the one with the most columns wins. */ + @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 + ); + } + + /** + * Composite hint is partial and so skipped — the simpler single-column hint + * that still fully matches must be picked instead. + */ + @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 { + + /** {@code code} must not match {@code acc_dt_code} or {@code code1}. */ + @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); + } + + /** Alias {@code o1_0} must not match the substring inside {@code xo1_0}. */ + @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'"; + // No closing paren — falls back to short-form parsing path, then has no match either. + String result = handler.addQueryHints( + query, + List.of("use_index:group_name_index:Groups(GroupName") + ); + + // Short-form fallback would interpret the whole body as an index name and inject it. + // The test only asserts the handler does not throw and produces a deterministic value. + assertTrue(result.contains("from Groups")); + } + } + + /** + * Locks in the {@code IDENT} grouping fix: a quoted FROM table must not let the + * regex alternation leak and break the short-form / view-detection rewrites. + */ + @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); + } + } + + /** + * Clause keywords inside literals, comments or nested parentheses must not split segments. + */ + @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 + ); + } + } +} diff --git a/hibernate-dialect-v6/src/test/java/tech/ydb/hibernate/index/BankAccount.java b/hibernate-dialect-v6/src/test/java/tech/ydb/hibernate/index/BankAccount.java new file mode 100644 index 00000000..8437d8a6 --- /dev/null +++ b/hibernate-dialect-v6/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-v6/src/test/java/tech/ydb/hibernate/index/BankDocument.java b/hibernate-dialect-v6/src/test/java/tech/ydb/hibernate/index/BankDocument.java new file mode 100644 index 00000000..8853a4a6 --- /dev/null +++ b/hibernate-dialect-v6/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-v6/src/test/java/tech/ydb/hibernate/index/BankDocumentIndexHintTest.java b/hibernate-dialect-v6/src/test/java/tech/ydb/hibernate/index/BankDocumentIndexHintTest.java new file mode 100644 index 00000000..24f5e9ae --- /dev/null +++ b/hibernate-dialect-v6/src/test/java/tech/ydb/hibernate/index/BankDocumentIndexHintTest.java @@ -0,0 +1,200 @@ +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; + +/** + * Integration tests for the column-aware {@code use_index} hint, run against a real YDB. + *

    + * Unlike the unit tests these assert the actual SQL that reaches the JDBC layer + * (captured via a {@link StatementInspector}), so they prove the {@code VIEW} is injected + * exactly where expected — and they exercise both hint delivery paths: + * {@code Query#addQueryHint} (Hibernate database hints) and + * {@code HibernateHints.HINT_COMMENT} (the JPA {@code @QueryHint} path), which take + * different routes through {@code YdbDialect#addSqlHintOrComment}. + * + * @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); // joined by code + document.setAccKt(a2); // joined by parent + document.setAccCombo(a1); // joined by (code, parent) + session.persist(document); + }); + } + + @BeforeEach + void clearCapturedSql() { + CapturingStatementInspector.clear(); + } + + /** Case 1: the same table joined twice by different columns must get two different indexes. */ + @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)"; + + // Hibernate database-hints path. + 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); + }); + + // JPA HINT_COMMENT path (the @QueryHint use case). + 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); + }); + } + + /** Case 2: when several hints fully match, the one covering the most columns wins. */ + @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); + }); + + // Same expectation via HINT_COMMENT — best-match must work there too, not just first-match. + 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); + }); + } + + /** Case 3: if even one column of a composite hint is missing from the join, the index is not applied. */ + @Test + void compositeHint_withOneColumnMissing_isNotApplied() { + // accDt joins only on `code`; the composite hint also needs `parent`, so it must be skipped. + 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)); + } + + // ---------- helpers ---------- + + 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; + } + + /** + * Records every SQL statement Hibernate prepares so a test can assert on the final, + * hint-rewritten text. Instantiated by Hibernate via its no-arg constructor. + */ + 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-v6/src/test/java/tech/ydb/hibernate/student/StudentsRepositoryTest.java b/hibernate-dialect-v6/src/test/java/tech/ydb/hibernate/student/StudentsRepositoryTest.java index c5524280..3312af3a 100644 --- a/hibernate-dialect-v6/src/test/java/tech/ydb/hibernate/student/StudentsRepositoryTest.java +++ b/hibernate-dialect-v6/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-v6/src/test/java/tech/ydb/hibernate/student/entity/Student.java b/hibernate-dialect-v6/src/test/java/tech/ydb/hibernate/student/entity/Student.java index 6b65bf09..77c96ab6 100644 --- a/hibernate-dialect-v6/src/test/java/tech/ydb/hibernate/student/entity/Student.java +++ b/hibernate-dialect-v6/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 diff --git a/hibernate-dialect-v7/CHANGELOG.md b/hibernate-dialect-v7/CHANGELOG.md index cb76a0b2..635e97a6 100644 --- a/hibernate-dialect-v7/CHANGELOG.md +++ b/hibernate-dialect-v7/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.10.0 ## + +- Extended `use_index:` query hint with table-and-column-aware format + `use_index::([,...])` so the same table joined multiple times under different aliases can be pinned to different indexes via `VIEW` +- `HINT_COMMENT` hints separated by `;` are now passed to each handler as a single batch, so the + best-match-wins rule for competing `use_index` hints works the same as via `Query#addQueryHint` + ## 0.9.2 ## - Add YDB constraint violation exception mapping to hibernate specific exception diff --git a/hibernate-dialect-v7/README.md b/hibernate-dialect-v7/README.md index 3ccaac51..017c3898 100644 --- a/hibernate-dialect-v7/README.md +++ b/hibernate-dialect-v7/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. Table and column identifiers in the hint may be written bare, in `` ` `` backticks, or in `"…"` double quotes. | +| `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-v7/pom.xml b/hibernate-dialect-v7/pom.xml index ab3d0b3e..eec06efb 100644 --- a/hibernate-dialect-v7/pom.xml +++ b/hibernate-dialect-v7/pom.xml @@ -6,7 +6,7 @@ tech.ydb.dialects hibernate-ydb-dialect-v7 - 0.9.2 + 0.10.0 jar @@ -153,6 +153,26 @@ + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + prepare-agent + + prepare-agent + + + + report + test + + report + + + + diff --git a/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/YdbDialect.java b/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/YdbDialect.java index eb8251d1..506ae071 100644 --- a/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/YdbDialect.java +++ b/hibernate-dialect-v7/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; @@ -288,17 +289,22 @@ 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; + // Feed every handler the full list of hints it owns in a single call. Passing all + // of a handler's hints at once is what lets IndexQueryHintHandler compare competing + // use_index hints (best-match-wins) instead of applying them one by one. 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-v7/src/main/java/tech/ydb/hibernate/dialect/hint/IndexQueryHintHandler.java b/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/hint/IndexQueryHintHandler.java index 70f50cf5..b21a0eba 100644 --- a/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/hint/IndexQueryHintHandler.java +++ b/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/hint/IndexQueryHintHandler.java @@ -1,21 +1,79 @@ 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; /** + * Handles {@code use_index:} query hints. + *

    + * Two formats are supported: + *

      + *
    • {@code use_index:} — short form. Rewrites the FROM table of a + * simple {@code select ... from
    ... where ...} query to use the given view. + * Useful when the query has exactly one FROM table. + *
  • {@code use_index::([,...])} — + * table/column-aware form. Each top-level {@code FROM}/{@code JOIN} occurrence of + * {@code } is rewritten to {@code view } + * provided every column listed in the hint is referenced by that alias inside the + * relevant scope (the {@code ON} clause for a JOIN, the {@code WHERE} clause for the + * FROM table). Clause boundaries respect string literals, comments and parenthesis + * nesting. When several hints fully match the same + * table reference the most specific one (largest number of columns) wins.
  • + * + * Both forms may be mixed: the short-form rewrite is skipped if a typed hint already + * attached a {@code view} to the FROM table. + * * @author Ainur Mukhtarov + * @author Kirill Kurdyukov */ 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:"; + /** + * One SQL identifier — bare, {@code `backtick`}- or {@code "double"}-quoted. + * Kept in a non-capturing group so embedding it never leaks its alternatives. + */ + private static final String IDENT = "(?:\\w+|`[^`]+`|\"[^\"]+\")"; + + /** + * Parses the body of a typed hint {@code :(
    [,...])}. + *
    +     *   group(1) = index name   ([^:]+ — everything up to the first ':')
    +     *   group(2) = table name   ([^(]+ — everything up to the first '(')
    +     *   group(3) = column list  ([^)]+ — everything inside the parentheses)
    +     * 
    + */ + private static final Pattern TYPED_HINT_BODY = Pattern.compile("^([^:]+):([^(]+)\\(([^)]+)\\)$"); + + /** + * Matches the {@code
    } pair at the start of a FROM/JOIN segment. + * group(1) = table identifier, group(2) = alias. + */ + private static final Pattern TABLE_ALIAS = Pattern.compile( + "\\s*(" + IDENT + ")\\s+(\\w+)" + ); + + /** + * Splits a simple {@code select ... from
    ... where ...} query into the part + * up to and including the FROM table (group 1) and the rest starting at WHERE (group 2), + * so the short-form rewrite can insert {@code view } between them. + */ + private static final Pattern SELECT_FROM_WHERE = Pattern + .compile("^\\s*(select.+?from\\s+" + IDENT + ")(.+where.+)$", Pattern.CASE_INSENSITIVE); + + /** + * Detects an already present {@code from
    view ...} so we never inject a second view there. + */ + private static final Pattern FROM_HAS_VIEW = Pattern + .compile("\\bfrom\\s+" + IDENT + "\\s+view\\b", Pattern.CASE_INSENSITIVE); + private IndexQueryHintHandler() { } @@ -26,23 +84,265 @@ 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; + } + + // ---------- Hint parsing ---------- + + 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); + } - 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 List splitColumns(String raw) { + return Arrays.stream(raw.split(",")) + .map(String::trim) + .map(IndexQueryHintHandler::unquote) + .filter(s -> !s.isEmpty()) + .toList(); + } + + // ---------- Typed-hint rewrite ---------- + + 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); + } + + /** + * Returns the index hint whose columns are all referenced in the scope and + * that has the largest number of columns among such full matches. Returns + * {@code null} when no hint is a full match. + */ + 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; + } - return startToken + " view " + String.join(", ", useIndexes) + endToken; + private static boolean allColumnsReferenced(String scope, String alias, List columns) { + for (String column : columns) { + if (!aliasColumnInScope(scope, alias, column)) { + return false; } } + return true; + } - return query; + /** + * True if {@code alias.column} (optionally {@code `quoted`}) appears in {@code scope}. + * A plain string scan rather than a regex, because this runs on every query execution. + */ + 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; + } + } + + 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); + } + + // ---------- Scanning FROM/JOIN segments ---------- + + /** + * Walks the query, finds every top-level {@code FROM } and {@code JOIN } + * pair and records the column-search scope for it (the join's ON clause, or the + * WHERE clause for the FROM table). + */ + 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; + } + + 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); + } + + // ---------- Short-form rewrite ---------- + + 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); + } + + // ---------- Helpers ---------- + + 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-v7/src/main/java/tech/ydb/hibernate/dialect/hint/PragmaQueryHintHandler.java b/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/hint/PragmaQueryHintHandler.java index 3553b782..a786b934 100644 --- a/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/hint/PragmaQueryHintHandler.java +++ b/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/hint/PragmaQueryHintHandler.java @@ -5,6 +5,7 @@ /** * @author Ainur Mukhtarov + * @author Kirill Kurdyukov */ public class PragmaQueryHintHandler implements QueryHintHandler { public static final PragmaQueryHintHandler INSTANCE = new PragmaQueryHintHandler(); diff --git a/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/hint/QueryHintHandler.java b/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/hint/QueryHintHandler.java index de851630..da4ab498 100644 --- a/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/hint/QueryHintHandler.java +++ b/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/hint/QueryHintHandler.java @@ -4,6 +4,7 @@ /** * @author Ainur Mukhtarov + * @author Kirill Kurdyukov */ public interface QueryHintHandler { diff --git a/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/hint/ScanQueryHintHandler.java b/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/hint/ScanQueryHintHandler.java index d9ae0e5d..15b1c310 100644 --- a/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/hint/ScanQueryHintHandler.java +++ b/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/hint/ScanQueryHintHandler.java @@ -5,6 +5,7 @@ /** * @author Ainur Mukhtarov + * @author Kirill Kurdyukov */ public class ScanQueryHintHandler implements QueryHintHandler { public static final ScanQueryHintHandler INSTANCE = new ScanQueryHintHandler(); diff --git a/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/hint/SqlTopLevelClauseScanner.java b/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/hint/SqlTopLevelClauseScanner.java new file mode 100644 index 00000000..645ee4dd --- /dev/null +++ b/hibernate-dialect-v7/src/main/java/tech/ydb/hibernate/dialect/hint/SqlTopLevelClauseScanner.java @@ -0,0 +1,172 @@ +package tech.ydb.hibernate.dialect.hint; + +import java.util.ArrayList; +import java.util.List; + +/** + * Locates SQL clause keywords at parenthesis nesting level zero, skipping string + * literals and comments. + */ +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] != '"') { + // skip + } + return offset; + } + + private static int parseBacktickQuotes(final char[] query, int offset) { + while (++offset < query.length && query[offset] != '`') { + // skip + } + 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-v7/src/test/java/tech/ydb/hibernate/hint/IndexQueryHintHandlerTest.java b/hibernate-dialect-v7/src/test/java/tech/ydb/hibernate/hint/IndexQueryHintHandlerTest.java new file mode 100644 index 00000000..8263141d --- /dev/null +++ b/hibernate-dialect-v7/src/test/java/tech/ydb/hibernate/hint/IndexQueryHintHandlerTest.java @@ -0,0 +1,519 @@ +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; + +/** + * Unit tests for {@link 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 { + + /** When every column of the hint appears in the JOIN, the index is attached. */ + @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 + ); + } + + /** If even one column of the hint is missing the index is NOT applied. */ + @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); + } + + /** When several hints fully match, the one with the most columns wins. */ + @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 + ); + } + + /** + * Composite hint is partial and so skipped — the simpler single-column hint + * that still fully matches must be picked instead. + */ + @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 { + + /** {@code code} must not match {@code acc_dt_code} or {@code code1}. */ + @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); + } + + /** Alias {@code o1_0} must not match the substring inside {@code xo1_0}. */ + @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'"; + // No closing paren — falls back to short-form parsing path, then has no match either. + String result = handler.addQueryHints( + query, + List.of("use_index:group_name_index:Groups(GroupName") + ); + + // Short-form fallback would interpret the whole body as an index name and inject it. + // The test only asserts the handler does not throw and produces a deterministic value. + assertTrue(result.contains("from Groups")); + } + } + + /** + * Locks in the {@code IDENT} grouping fix: a quoted FROM table must not let the + * regex alternation leak and break the short-form / view-detection rewrites. + */ + @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); + } + } + + /** + * Clause keywords inside literals, comments or nested parentheses must not split segments. + */ + @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 + ); + } + } +} diff --git a/hibernate-dialect-v7/src/test/java/tech/ydb/hibernate/index/BankAccount.java b/hibernate-dialect-v7/src/test/java/tech/ydb/hibernate/index/BankAccount.java new file mode 100644 index 00000000..8437d8a6 --- /dev/null +++ b/hibernate-dialect-v7/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-v7/src/test/java/tech/ydb/hibernate/index/BankDocument.java b/hibernate-dialect-v7/src/test/java/tech/ydb/hibernate/index/BankDocument.java new file mode 100644 index 00000000..8853a4a6 --- /dev/null +++ b/hibernate-dialect-v7/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-v7/src/test/java/tech/ydb/hibernate/index/BankDocumentIndexHintTest.java b/hibernate-dialect-v7/src/test/java/tech/ydb/hibernate/index/BankDocumentIndexHintTest.java new file mode 100644 index 00000000..24f5e9ae --- /dev/null +++ b/hibernate-dialect-v7/src/test/java/tech/ydb/hibernate/index/BankDocumentIndexHintTest.java @@ -0,0 +1,200 @@ +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; + +/** + * Integration tests for the column-aware {@code use_index} hint, run against a real YDB. + *

    + * Unlike the unit tests these assert the actual SQL that reaches the JDBC layer + * (captured via a {@link StatementInspector}), so they prove the {@code VIEW} is injected + * exactly where expected — and they exercise both hint delivery paths: + * {@code Query#addQueryHint} (Hibernate database hints) and + * {@code HibernateHints.HINT_COMMENT} (the JPA {@code @QueryHint} path), which take + * different routes through {@code YdbDialect#addSqlHintOrComment}. + * + * @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); // joined by code + document.setAccKt(a2); // joined by parent + document.setAccCombo(a1); // joined by (code, parent) + session.persist(document); + }); + } + + @BeforeEach + void clearCapturedSql() { + CapturingStatementInspector.clear(); + } + + /** Case 1: the same table joined twice by different columns must get two different indexes. */ + @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)"; + + // Hibernate database-hints path. + 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); + }); + + // JPA HINT_COMMENT path (the @QueryHint use case). + 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); + }); + } + + /** Case 2: when several hints fully match, the one covering the most columns wins. */ + @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); + }); + + // Same expectation via HINT_COMMENT — best-match must work there too, not just first-match. + 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); + }); + } + + /** Case 3: if even one column of a composite hint is missing from the join, the index is not applied. */ + @Test + void compositeHint_withOneColumnMissing_isNotApplied() { + // accDt joins only on `code`; the composite hint also needs `parent`, so it must be skipped. + 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)); + } + + // ---------- helpers ---------- + + 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; + } + + /** + * Records every SQL statement Hibernate prepares so a test can assert on the final, + * hint-rewritten text. Instantiated by Hibernate via its no-arg constructor. + */ + 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-v7/src/test/java/tech/ydb/hibernate/student/StudentsRepositoryTest.java b/hibernate-dialect-v7/src/test/java/tech/ydb/hibernate/student/StudentsRepositoryTest.java index d47e4bfd..ed21734e 100644 --- a/hibernate-dialect-v7/src/test/java/tech/ydb/hibernate/student/StudentsRepositoryTest.java +++ b/hibernate-dialect-v7/src/test/java/tech/ydb/hibernate/student/StudentsRepositoryTest.java @@ -188,6 +188,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-v7/src/test/java/tech/ydb/hibernate/student/entity/Student.java b/hibernate-dialect-v7/src/test/java/tech/ydb/hibernate/student/entity/Student.java index 414f0c34..db84330e 100644 --- a/hibernate-dialect-v7/src/test/java/tech/ydb/hibernate/student/entity/Student.java +++ b/hibernate-dialect-v7/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