diff --git a/hibernate-dialect/CHANGELOG.md b/hibernate-dialect/CHANGELOG.md index f0305739..2d268b74 100644 --- a/hibernate-dialect/CHANGELOG.md +++ b/hibernate-dialect/CHANGELOG.md @@ -1,6 +1,7 @@ ## 1.7.0 ## -- Fixed `HINT_COMMENT` handling: hints separated by `;` are now passed to each handler as a single batch, so multiple short-form `use_index` hints become `view a, b` instead of `view a view b` +- Fixed `HINT_COMMENT` handling: multiple short-form `use_index` hints in one comment become `view a, b` instead of `view a view b` +- Extended `use_index:` hint with table-and-column-aware format `use_index::([,...])` to pin a secondary index per `JOIN` when the same table is joined more than once in one query - Renamed `hibernate-dialect-v6` to `hibernate-dialect`, removed `hibernate-dialect-v7` - Support Hibernate 7 - Support Java 11 for Hibernate 6 profile diff --git a/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/hint/IndexHintApplier.java b/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/hint/IndexHintApplier.java new file mode 100644 index 00000000..1586fa89 --- /dev/null +++ b/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/hint/IndexHintApplier.java @@ -0,0 +1,324 @@ +package tech.ydb.hibernate.dialect.hint; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +class IndexHintApplier { + private final char[] sql; + private final Map> typedHints; + private final List shortIndexes; + private final StringBuilder out; + + private int pos; + private int emitted; + private int parenLevel; + private boolean changed; + + private IndexHintApplier(String query, Map> typedHints, List shortIndexes) { + this.sql = query.toCharArray(); + this.typedHints = typedHints; + this.shortIndexes = shortIndexes; + this.out = new StringBuilder(); + } + + static String apply(String query, Map> typedHints, List shortIndexes) { + return new IndexHintApplier(query, typedHints, shortIndexes).apply(); + } + + private String apply() { + while (pos < sql.length) { + if (parenLevel != 0 || !isIdentifierStart(sql[pos])) { + consumeChar(); + continue; + } + + String keyword = readIdentifier(); + if (!isClauseKeyword(keyword)) { + continue; + } + + if (keyword.equalsIgnoreCase("from")) { + if (!shortIndexes.isEmpty()) { + TableRef fromTable = parseTableRef(); + if (fromTable != null) { + emitView(fromTable, String.join(", ", shortIndexes)); + } + } + } else if (keyword.equalsIgnoreCase("join")) { + TableRef joinTable = parseTableRef(); + if (joinTable != null && joinTable.alias != null) { + String index = bestIndex(joinTable.table, collectAliasColumnsUntilNextClause(joinTable.alias)); + if (index != null) { + emitView(joinTable, index); + } + } + } + } + + if (!changed) { + return new String(sql); + } + out.append(sql, emitted, sql.length - emitted); + return out.toString(); + } + + private void emitView(TableRef table, String indexes) { + out.append(sql, emitted, table.afterTable - emitted); + out.append(" view ").append(indexes).append(' '); + emitted = table.aliasStart; + changed = true; + } + + private TableRef parseTableRef() { + skipWhitespace(); + String table = readIdentifier(); + if (table == null) { + return null; + } + + int afterTable = pos; + skipWhitespace(); + String next = peekIdentifier(); + if (next != null && next.equalsIgnoreCase("view")) { + return null; + } + if (next == null || isClauseKeyword(next) || next.equalsIgnoreCase("on")) { + return new TableRef(table, null, afterTable, pos); + } + + int aliasStart = pos; + String alias = readIdentifier(); + if (alias != null && alias.equalsIgnoreCase("as")) { + skipWhitespace(); + alias = readIdentifier(); + } + + return new TableRef(table, alias, afterTable, aliasStart); + } + + private Set collectAliasColumnsUntilNextClause(String alias) { + Set columns = new HashSet<>(); + while (pos < sql.length) { + if (parenLevel == 0 && startsClauseKeyword()) { + break; + } + if (readAliasColumnInto(alias, columns)) { + continue; + } + consumeChar(); + } + return columns; + } + + private boolean readAliasColumnInto(String alias, Set columns) { + if (pos >= sql.length || !isIdentifierStart(sql[pos])) { + return false; + } + + int saved = pos; + String ident = readIdentifier(); + if (ident == null) { + pos = saved; + return false; + } + if (parenLevel == 0 && isClauseKeyword(ident)) { + pos = saved; + return false; + } + + if (ident.equals(alias) && pos < sql.length && sql[pos] == '.') { + pos++; + skipWhitespace(); + String column = readIdentifier(); + if (column != null) { + columns.add(column); + } + } + return true; + } + + private boolean startsClauseKeyword() { + if (pos >= sql.length || !isIdentifierStart(sql[pos])) { + return false; + } + int saved = pos; + boolean clause = isClauseKeyword(readIdentifier()); + pos = saved; + return clause; + } + + private String bestIndex(String table, Set referencedColumns) { + List hints = typedHints.get(table); + if (hints == null) { + return null; + } + IndexTypedHint best = null; + for (IndexTypedHint hint : hints) { + if (!referencedColumns.containsAll(hint.columns)) { + continue; + } + if (best == null || hint.columns.size() > best.columns.size()) { + best = hint; + } + } + return best == null ? null : best.indexName; + } + + private String readIdentifier() { + if (pos >= sql.length || !isIdentifierStart(sql[pos])) { + return null; + } + int start = pos++; + while (pos < sql.length && isIdentifierPart(sql[pos])) { + pos++; + } + return new String(sql, start, pos - start); + } + + private String peekIdentifier() { + int saved = pos; + String ident = readIdentifier(); + pos = saved; + return ident; + } + + private void skipWhitespace() { + while (pos < sql.length && Character.isWhitespace(sql[pos])) { + pos++; + } + } + + private void consumeChar() { + switch (sql[pos]) { + case '\'': + pos = skipSingleQuotes(pos); + break; + case '"': + pos = skipDoubleQuotes(pos); + break; + case '`': + pos = skipBacktickQuotes(pos); + break; + case '-': + pos = skipLineComment(pos); + break; + case '/': + pos = skipBlockComment(pos); + break; + case '(': + parenLevel++; + pos++; + break; + case ')': + if (parenLevel > 0) { + parenLevel--; + } + pos++; + break; + default: + pos++; + break; + } + } + + private static boolean isClauseKeyword(String ident) { + if (ident == null) { + return false; + } + + switch (ident.length()) { + case 4: + return ident.equalsIgnoreCase("from") || ident.equalsIgnoreCase("join"); + case 5: + return ident.equalsIgnoreCase("where") + || ident.equalsIgnoreCase("order") + || ident.equalsIgnoreCase("group") + || ident.equalsIgnoreCase("limit") + || ident.equalsIgnoreCase("union"); + case 6: + return ident.equalsIgnoreCase("having") || ident.equalsIgnoreCase("except"); + case 9: + return ident.equalsIgnoreCase("intersect"); + default: + return false; + } + } + + private static boolean isIdentifierStart(char ch) { + return Character.isJavaIdentifierStart(ch); + } + + private static boolean isIdentifierPart(char ch) { + return Character.isJavaIdentifierPart(ch); + } + + private int skipSingleQuotes(int offset) { + while (++offset < sql.length) { + if (sql[offset] == '\\') { + ++offset; + } else if (sql[offset] == '\'') { + return offset + 1; + } + } + return sql.length; + } + + private int skipDoubleQuotes(int offset) { + while (++offset < sql.length && sql[offset] != '"') { + } + return Math.min(offset + 1, sql.length); + } + + private int skipBacktickQuotes(int offset) { + while (++offset < sql.length && sql[offset] != '`') { + } + return Math.min(offset + 1, sql.length); + } + + private int skipLineComment(int offset) { + if (offset + 1 < sql.length && sql[offset + 1] == '-') { + offset += 2; + while (offset < sql.length && sql[offset] != '\r' && sql[offset] != '\n') { + offset++; + } + return offset; + } + return offset + 1; + } + + private int skipBlockComment(int offset) { + if (offset + 1 >= sql.length || sql[offset + 1] != '*') { + return offset + 1; + } + int level = 1; + for (offset += 2; offset < sql.length; ++offset) { + if (sql[offset - 1] == '*' && sql[offset] == '/') { + --level; + ++offset; + } else if (sql[offset - 1] == '/' && sql[offset] == '*') { + ++level; + ++offset; + } + if (level == 0) { + return offset; + } + } + return sql.length; + } + + private static final class TableRef { + final String table; + final String alias; + final int afterTable; + final int aliasStart; + + TableRef(String table, String alias, int afterTable, int aliasStart) { + this.table = table; + this.alias = alias; + this.afterTable = afterTable; + this.aliasStart = aliasStart; + } + } +} diff --git a/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/hint/IndexTypedHint.java b/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/hint/IndexTypedHint.java new file mode 100644 index 00000000..2b7c0fe5 --- /dev/null +++ b/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/hint/IndexTypedHint.java @@ -0,0 +1,13 @@ +package tech.ydb.hibernate.dialect.hint; + +import java.util.List; + +class IndexTypedHint { + final String indexName; + final List columns; + + IndexTypedHint(String indexName, List columns) { + this.indexName = indexName; + this.columns = columns; + } +} diff --git a/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/hint/QueryHints.java b/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/hint/QueryHints.java index c1f436eb..c44ba701 100644 --- a/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/hint/QueryHints.java +++ b/hibernate-dialect/src/main/java/tech/ydb/hibernate/dialect/hint/QueryHints.java @@ -1,25 +1,55 @@ package tech.ydb.hibernate.dialect.hint; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import java.util.Map; /** * @author Kirill Kurdyukov */ public final class QueryHints { - private static final Pattern SELECT_FROM_WHERE = Pattern - .compile("^\\s*(select.+?from\\s+\\w+)(.+where.+)$", Pattern.CASE_INSENSITIVE); - private QueryHints() { } - public static String addViewIndexesToQuery(String query, List indexNames) { - Matcher matcher = SELECT_FROM_WHERE.matcher(query); - if (!matcher.matches() || matcher.groupCount() < 2) { + public static String addViewIndexesToQuery(String query, List indexHints) { + List shortIndexes = new ArrayList<>(); + Map> typedHints = new LinkedHashMap<>(); + + for (String body : indexHints) { + int tableColon = body.indexOf(':'); + int columnsStart = body.indexOf('(', tableColon + 1); + int columnsEnd = body.lastIndexOf(')'); + + if (tableColon > 0 && columnsStart > tableColon && columnsEnd > columnsStart) { + String indexName = body.substring(0, tableColon).trim(); + String tableName = body.substring(tableColon + 1, columnsStart).trim(); + List columns = splitColumns(body.substring(columnsStart + 1, columnsEnd)); + if (!indexName.isEmpty() && !tableName.isEmpty() && !columns.isEmpty()) { + List hintsForTable = typedHints + .computeIfAbsent(tableName, k -> new ArrayList<>()); + hintsForTable.add(new IndexTypedHint(indexName, columns)); + continue; + } + } + shortIndexes.add(body); + } + + if (typedHints.isEmpty() && shortIndexes.isEmpty()) { return query; } - return matcher.group(1) + " view " + String.join(", ", indexNames) + matcher.group(2); + return IndexHintApplier.apply(query, typedHints, shortIndexes); + } + + private static List splitColumns(String raw) { + List columns = new ArrayList<>(); + for (String part : raw.split(",")) { + String column = part.trim(); + if (!column.isEmpty()) { + columns.add(column); + } + } + return columns; } public static String addScanToQuery(String query) { diff --git a/hibernate-dialect/src/test/java/tech/ydb/hibernate/hint/IndexQueryHintHandlerTest.java b/hibernate-dialect/src/test/java/tech/ydb/hibernate/hint/IndexQueryHintHandlerTest.java new file mode 100644 index 00000000..7cd0a424 --- /dev/null +++ b/hibernate-dialect/src/test/java/tech/ydb/hibernate/hint/IndexQueryHintHandlerTest.java @@ -0,0 +1,347 @@ +package tech.ydb.hibernate.hint; + +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.QueryHints; + +public class IndexQueryHintHandlerTest { + + private static String apply(String query, String... hints) { + java.util.List bodies = new java.util.ArrayList<>(); + for (String hint : hints) { + if (hint.startsWith("use_index:")) { + bodies.add(hint.substring("use_index:".length())); + } + } + return QueryHints.addViewIndexesToQuery(query, bodies); + } + + + @Nested + class ShortForm { + + @Test + void rewritesFromTable() { + String query = "select g1_0.GroupId from Groups g1_0 where g1_0.GroupName='M3439'"; + String result = apply(query, "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 = apply(query, "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 noWhereClauseStillAppliesShortHint() { + String query = "select g1_0.GroupId from Groups g1_0"; + String result = apply(query, "use_index:group_name_index"); + + assertEquals( + "select g1_0.GroupId from Groups view group_name_index g1_0", + result + ); + } + + @Test + void shortHintAppliesWithoutAlias() { + String query = "select GroupId from Groups where GroupName='M3439'"; + String result = apply(query, "use_index:group_name_index"); + + assertEquals( + "select GroupId from Groups view group_name_index where GroupName='M3439'", + result + ); + } + + @Test + void hintsAreIgnoredWhenPrefixDoesNotMatch() { + String query = "select g1_0.GroupId from Groups g1_0 where g1_0.GroupName='M3439'"; + String result = apply(query, "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 = apply(query, "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 = apply(query, "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 = apply(query, "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 = apply(query, "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 typedFromHintDoesNotRewriteFromTable() { + String query = "select g1_0.GroupId from Groups g1_0 where g1_0.GroupName='M3439'"; + String result = apply(query, "use_index:group_name_index:Groups(GroupName)"); + + assertEquals(query, result); + } + } + + @Nested + class TypedFormCompositeColumns { + + + @Test + void fullColumnMatch_appliesIndex() { + String query = "select * from orders o1_0 " + + "left join customers c1_0 on c1_0.code=source.code and c1_0.parent=source.parent " + + "where o1_0.id='X'"; + String result = apply(query, "use_index:customers_combo_idx:customers(code,parent)"); + + assertEquals( + "select * from orders o1_0 " + + "left join customers view customers_combo_idx c1_0 on c1_0.code=source.code and c1_0.parent=source.parent " + + "where o1_0.id='X'", + result + ); + } + + + @Test + void partialColumnMatch_doesNotApply() { + String query = "select * from orders o1_0 " + + "left join customers c1_0 on c1_0.code=source.code " + + "left join customers c3_0 on c3_0.parent=source.parent " + + "where o1_0.id='X'"; + String result = apply(query, "use_index:customers_combo_idx:customers(code,parent)"); + + assertEquals(query, result); + } + + + @Test + void bestMatchWins_mostSpecificIndexPicked() { + String query = "select * from orders o1_0 " + + "left join customers c1_0 on c1_0.code=source.code and c1_0.parent=source.parent " + + "where o1_0.id='X'"; + String result = apply(query, "use_index:customers_code_idx:customers(code)", "use_index:customers_combo_idx:customers(code,parent)"); + + assertEquals( + "select * from orders o1_0 " + + "left join customers view customers_combo_idx c1_0 on c1_0.code=source.code and c1_0.parent=source.parent " + + "where o1_0.id='X'", + result + ); + } + + + @Test + void partialCompositeFallsBackToFullSingleColumn() { + String query = "select * from orders o1_0 " + + "left join customers c1_0 on c1_0.code=source.code " + + "where o1_0.id='X'"; + String result = apply(query, "use_index:customers_combo_idx:customers(code,parent)", "use_index:customers_code_idx:customers(code)"); + + assertEquals( + "select * from orders o1_0 " + + "left join customers view customers_code_idx c1_0 on c1_0.code=source.code " + + "where o1_0.id='X'", + result + ); + } + } + + @Nested + class IdentifierBoundaries { + + + @Test + void columnNameIsNotASubstringMatch() { + String query = "select * from orders o1_0 " + + "left join customers c1_0 on c1_0.acc_dt_code=source.x " + + "left join customers c2_0 on c2_0.code1=source.y " + + "where o1_0.id='X'"; + String result = apply(query, "use_index:customers_code_idx:customers(code)"); + + assertEquals(query, result); + } + + + @Test + void aliasIsNotASubstringMatch() { + String query = "select * from orders o1_0 " + + "left join customers c1_0 on xo1_0.code=source.code " + + "where o1_0.id='X'"; + String result = apply(query, "use_index:customers_code_idx:customers(code)"); + + assertEquals(query, result); + } + } + + @Nested + class Interactions { + + @Test + void typedFromHintDoesNotBlockShortForm() { + String query = "select g1_0.GroupId from Groups g1_0 where g1_0.GroupName='M3439'"; + String result = apply(query, "use_index:group_name_index:Groups(GroupName)", "use_index:other_index"); + + assertEquals( + "select g1_0.GroupId from Groups view other_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 = apply(query, "use_index:customers_id_idx:customers(id)", "use_index:regions_id_idx:regions(id)"); + + assertEquals( + "select o1_0.id,c1_0.id,c3_0.id,r1_0.id " + + "from orders o1_0 " + + "left join customers view customers_id_idx c1_0 on c1_0.id=o1_0.acc_dt_id " + + "left join customers view customers_id_idx c3_0 on c3_0.id=o1_0.acc_kt_id " + + "left join regions view regions_id_idx r1_0 on r1_0.id=o1_0.branch_id " + + "where o1_0.id='abc'", + result + ); + } + + @Test + void malformedTypedHintIsIgnored() { + String query = "select g1_0.GroupId from Groups g1_0 where g1_0.GroupName='M3439'"; + String result = apply(query, "use_index:group_name_index:Groups(GroupName"); + + assertTrue(result.contains("from Groups")); + } + } + + + + @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 = apply(query, "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 = apply(query, "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 = apply(query, "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 + ); + } + + @Test + void viewIsInsertedBeforeAsAlias() { + String query = "select a1.b as a1_b, a2.b as a2_b " + + "from a as a1 join a as a2 on a1.d=a2.c where a1.d=1"; + String result = apply(query, + "use_index:Index1", + "use_index:Index2:a(c)"); + + assertEquals( + "select a1.b as a1_b, a2.b as a2_b " + + "from a view Index1 as a1 join a view Index2 as a2 on a1.d=a2.c where a1.d=1", + result + ); + } + } +} diff --git a/hibernate-dialect/src/test/java/tech/ydb/hibernate/index/BankAccount.java b/hibernate-dialect/src/test/java/tech/ydb/hibernate/index/BankAccount.java new file mode 100644 index 00000000..8437d8a6 --- /dev/null +++ b/hibernate-dialect/src/test/java/tech/ydb/hibernate/index/BankAccount.java @@ -0,0 +1,70 @@ +package tech.ydb.hibernate.index; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +/** + * Account table that is joined several times under different aliases and different columns, + * reproducing the real "one table, many joins" scenario the index hints are built for. + *

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

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