diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/completion/resolver/MysqlSqlCompletionRelationScopeResolver.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/completion/resolver/MysqlSqlCompletionRelationScopeResolver.java index f99c95f2c4..808cb89246 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/completion/resolver/MysqlSqlCompletionRelationScopeResolver.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/main/java/ai/chat2db/plugin/mysql/completion/resolver/MysqlSqlCompletionRelationScopeResolver.java @@ -53,6 +53,16 @@ public final class MysqlSqlCompletionRelationScopeResolver { MySqlLexer.IGNORE, MySqlLexer.FORCE); + private static final Set FROM_CLAUSE_TAIL_TOKENS = Set.of( + MySqlLexer.WHERE, + MySqlLexer.GROUP, + MySqlLexer.HAVING, + MySqlLexer.WINDOW, + MySqlLexer.ORDER, + MySqlLexer.LIMIT, + MySqlLexer.UNION, + MySqlLexer.INTO); + private MysqlSqlCompletionRelationScopeResolver() { } @@ -81,7 +91,8 @@ public static MysqlSqlCompletionRelationScope resolve(SqlCompletionStatementWind relations.addAll(MysqlSqlCompletionLocalResultSetResolver.resolveDerivedTables(tokens, relationScanCursor)); } MysqlSqlCompletionLocalResultSetResolver.resolveCurrentProjection(tokens, cursor).ifPresent(relations::add); - for (int index = 0; index < tokens.size(); index++) { + boolean[] fromRelationSeparators = fromRelationSeparators(tokens); + for (int index = statementStartIndex(tokens, cursor); index < tokens.size(); index++) { Token token = tokens.get(index); if (activeSelect != null && token.getStartIndex() >= activeSelect.endOffset()) { break; @@ -98,7 +109,7 @@ public static MysqlSqlCompletionRelationScope resolve(SqlCompletionStatementWind } continue; } - if (!isRelationIntroducer(tokens, index)) { + if (!isRelationIntroducer(tokens, index, fromRelationSeparators)) { continue; } RelationCandidate relationCandidate = relationAfter(tokens, index, true); @@ -290,23 +301,97 @@ private static int skipIndexHintType(List tokens, int index) { return index; } - private static boolean isRelationIntroducer(List tokens, int index) { + private static boolean isRelationIntroducer(List tokens, + int index, + boolean[] fromRelationSeparators) { Token token = tokens.get(index); return switch (token.getType()) { case MySqlLexer.FROM, MySqlLexer.JOIN, MySqlLexer.STRAIGHT_JOIN, MySqlLexer.INTO -> true; - case MySqlLexer.UPDATE -> !isOnDuplicateKeyUpdate(tokens, index); + case MySqlLexer.COMMA -> fromRelationSeparators[index]; + case MySqlLexer.UPDATE -> !isOnDuplicateKeyUpdate(tokens, index) + && !isLockingClauseUpdate(tokens, index); default -> false; }; } + private static boolean[] fromRelationSeparators(List tokens) { + boolean[] result = new boolean[tokens.size()]; + boolean[] insideFromClause = new boolean[tokens.size() + 1]; + int depth = 0; + for (int index = 0; index < tokens.size(); index++) { + int tokenType = tokens.get(index).getType(); + if (tokenType == MySqlLexer.RR_BRACKET) { + insideFromClause[depth] = false; + depth = Math.max(0, depth - 1); + continue; + } + if (tokenType == MySqlLexer.SELECT || tokenType == MySqlLexer.SEMI) { + insideFromClause[depth] = false; + } else if (tokenType == MySqlLexer.FROM) { + insideFromClause[depth] = true; + } else if (insideFromClause[depth] + && (FROM_CLAUSE_TAIL_TOKENS.contains(tokenType) + || isOnDuplicateKeyUpdateStart(tokens, index) + || isLockingClauseStart(tokens, index))) { + insideFromClause[depth] = false; + } else if (tokenType == MySqlLexer.COMMA) { + result[index] = insideFromClause[depth]; + } + if (tokenType == MySqlLexer.LR_BRACKET) { + depth++; + insideFromClause[depth] = false; + } + } + return result; + } + private static boolean isOnDuplicateKeyUpdate(List tokens, int index) { - int previousIndex = MysqlSqlCompletionTokenUtil.previousDefaultIndex(tokens, index - 1); - return previousIndex >= 0 && tokens.get(previousIndex).getType() == MySqlLexer.KEY - && MysqlSqlCompletionTokenUtil.hasTokenBefore(tokens, tokens.get(index).getStartIndex(), - MySqlLexer.DUPLICATE); + return hasTokenTypes(tokens, index - 3, + MySqlLexer.ON, MySqlLexer.DUPLICATE, MySqlLexer.KEY, MySqlLexer.UPDATE); + } + + private static boolean isOnDuplicateKeyUpdateStart(List tokens, int index) { + return hasTokenTypes(tokens, index, + MySqlLexer.ON, MySqlLexer.DUPLICATE, MySqlLexer.KEY, MySqlLexer.UPDATE); + } + + private static boolean isLockingClauseStart(List tokens, int index) { + return hasTokenTypes(tokens, index, MySqlLexer.FOR, MySqlLexer.UPDATE) + || hasTokenTypes(tokens, index, MySqlLexer.FOR, MySqlLexer.SHARE) + || hasTokenTypes(tokens, index, MySqlLexer.LOCK, MySqlLexer.IN, MySqlLexer.SHARE); + } + + private static boolean isLockingClauseUpdate(List tokens, int index) { + return hasTokenTypes(tokens, index - 1, MySqlLexer.FOR, MySqlLexer.UPDATE); + } + + private static boolean hasTokenTypes(List tokens, int start, int... tokenTypes) { + if (start < 0 || start + tokenTypes.length > tokens.size()) { + return false; + } + for (int offset = 0; offset < tokenTypes.length; offset++) { + if (tokens.get(start + offset).getType() != tokenTypes[offset]) { + return false; + } + } + return true; + } + + private static int statementStartIndex(List tokens, int cursor) { + int result = 0; + for (int index = 0; index < tokens.size(); index++) { + Token token = tokens.get(index); + if (token.getStartIndex() >= cursor) { + break; + } + if (token.getType() == MySqlLexer.SEMI) { + result = index + 1; + } + } + return result; } private static MysqlSqlCompletionRelationScope resolveDdlFocusedScope(List tokens, int cursor) { diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/test/java/ai/chat2db/plugin/mysql/completion/MysqlSqlCompletionProviderTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/test/java/ai/chat2db/plugin/mysql/completion/MysqlSqlCompletionProviderTest.java index e45cdb9adb..8bb08dc24e 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/test/java/ai/chat2db/plugin/mysql/completion/MysqlSqlCompletionProviderTest.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/test/java/ai/chat2db/plugin/mysql/completion/MysqlSqlCompletionProviderTest.java @@ -629,6 +629,26 @@ void completesColumnsAfterAliasDotUsingResolvedTableScope() { Assertions.assertEquals("u", candidate.getTableAlias()); } + @Test + void completesOnlySecondJoinRelationColumnsAfterAliasDot() { + String sql = "select * from users a join orders b on a.id = b.id where b."; + + SqlCompletionResponse result = complete(sql, sql.length()); + + assertSuccessReplacement(result, sql.length(), sql.length()); + assertOnlyOrdersAliasBColumns(result); + } + + @Test + void completesOnlySecondCommaSeparatedRelationColumnsAfterAliasDot() { + String sql = "select * from users a, orders b where b."; + + SqlCompletionResponse result = complete(sql, sql.length()); + + assertSuccessReplacement(result, sql.length(), sql.length()); + assertOnlyOrdersAliasBColumns(result); + } + @Test void completesRelationAliasBeforeDotInWherePredicate() { String sql = """ @@ -2455,6 +2475,20 @@ private void assertNoCandidate(SqlCompletionResponse result, () -> "unexpected candidate " + type + ":" + label); } + private void assertOnlyOrdersAliasBColumns(SqlCompletionResponse result) { + Assertions.assertEquals(SqlCompletionCandidateTypeEnum.COLUMN.name(), metadataProvider.lastRequest.type()); + Assertions.assertEquals("orders", metadataProvider.lastRequest.scope().table()); + Assertions.assertEquals("", metadataProvider.lastRequest.prefix()); + Assertions.assertTrue(result.getCandidates().stream() + .allMatch(candidate -> candidate.getType() == SqlCompletionCandidateTypeEnum.COLUMN + && "orders".equals(candidate.getTableName()) + && "b".equals(candidate.getTableAlias()))); + Assertions.assertTrue(result.getCandidates().stream() + .anyMatch(candidate -> "amount".equals(candidate.getLabel()))); + Assertions.assertFalse(result.getCandidates().stream() + .anyMatch(candidate -> "name".equals(candidate.getLabel()))); + } + private void assertFunctionCandidate(String sql, String label, String detail, String dataType) { SqlCompletionResponse result = complete(sql, sql.length()); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/test/java/ai/chat2db/plugin/mysql/completion/resolver/MysqlSqlCompletionRelationScopeResolverTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/test/java/ai/chat2db/plugin/mysql/completion/resolver/MysqlSqlCompletionRelationScopeResolverTest.java index c512573796..61dc91452a 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/test/java/ai/chat2db/plugin/mysql/completion/resolver/MysqlSqlCompletionRelationScopeResolverTest.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-mysql/src/test/java/ai/chat2db/plugin/mysql/completion/resolver/MysqlSqlCompletionRelationScopeResolverTest.java @@ -45,6 +45,87 @@ void keepsJoinAliasesSeparated() { relation(null, null, "orders", "o")); } + @Test + void resolvesCommaRelationAfterJoinPredicate() { + MysqlSqlCompletionRelationScope scope = resolveAtCaret( + "select * from users u join orders o on u.id = o.user_id, payments p where p.{caret}"); + + assertRelations(scope, + relation(null, null, "users", "u"), + relation(null, null, "orders", "o"), + relation(null, null, "payments", "p")); + } + + @Test + void doesNotReuseFromFromSiblingSubqueryForProjectionComma() { + MysqlSqlCompletionRelationScope scope = resolveAtCaret( + "select (select id from users u), (select x, y, z{caret} from orders o)"); + + assertRelations(scope, relation(null, null, "orders", "o")); + Assertions.assertTrue(scope.resolveOwner("y").isEmpty()); + } + + @Test + void doesNotReuseFromFromPreviousCteBodyForProjectionComma() { + MysqlSqlCompletionRelationScope scope = resolveAtCaret( + "with first_cte as (select id from users u), " + + "second_cte as (select x, y, z{caret} from orders o) select * from second_cte"); + + assertRelations(scope, relation(null, null, "orders", "o")); + Assertions.assertTrue(scope.resolveOwner("y").isEmpty()); + } + + @Test + void doesNotReuseFromFromPreviousStatementForProjectionComma() { + MysqlSqlCompletionRelationScope scope = resolveAtCaret( + "select id from users u; select x, y, z{caret} from orders o"); + + assertRelations(scope, relation(null, null, "orders", "o")); + Assertions.assertTrue(scope.resolveOwner("y").isEmpty()); + } + + @Test + void doesNotTreatOnDuplicateKeyUpdateCommaAsRelation() { + MysqlSqlCompletionRelationScope scope = resolveAtCaret( + "insert into dst (id, v) select s.id, s.v from src s " + + "on duplicate key update id = values(id), v = s.{caret}"); + + assertRelations(scope, + relation(null, null, "dst", null), + relation(null, null, "src", "s")); + Assertions.assertTrue(scope.resolveOwner("v").isEmpty()); + } + + @Test + void doesNotTreatForUpdateCommaAsRelation() { + MysqlSqlCompletionRelationScope scope = resolveAtCaret( + "select * from users u join orders o on u.id = o.user_id for update of u, o.{caret}"); + + assertRelations(scope, + relation(null, null, "users", "u"), + relation(null, null, "orders", "o")); + } + + @Test + void doesNotTreatForShareCommaAsRelation() { + MysqlSqlCompletionRelationScope scope = resolveAtCaret( + "select * from users u join orders o on u.id = o.user_id for share of u, o.{caret}"); + + assertRelations(scope, + relation(null, null, "users", "u"), + relation(null, null, "orders", "o")); + } + + @Test + void doesNotTreatNamedWindowAsRelation() { + MysqlSqlCompletionRelationScope scope = resolveAtCaret( + "select * from users u window w1 as (order by u.id), w2 as (order by u.id) " + + "order by u.{caret}"); + + assertRelations(scope, relation(null, null, "users", "u")); + Assertions.assertTrue(scope.resolveOwner("w2").isEmpty()); + } + @Test void doesNotTreatIndexHintAsAlias() { MysqlSqlCompletionRelationScope scope = resolveAtCaret(