From bba67abd815c9377d7f1974c8727f4b3871261a7 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:44:32 -0700 Subject: [PATCH 1/3] fix: address self-review findings --- ...sqlSqlCompletionRelationScopeResolver.java | 14 +++++++++ .../MysqlSqlCompletionProviderTest.java | 31 +++++++++++++++++++ ...qlCompletionRelationScopeResolverTest.java | 21 +++++++++++++ 3 files changed, 66 insertions(+) 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..41c10d729f 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 @@ -297,11 +297,25 @@ private static boolean isRelationIntroducer(List tokens, int index) { MySqlLexer.JOIN, MySqlLexer.STRAIGHT_JOIN, MySqlLexer.INTO -> true; + case MySqlLexer.COMMA -> isFromRelationSeparator(tokens, index); case MySqlLexer.UPDATE -> !isOnDuplicateKeyUpdate(tokens, index); default -> false; }; } + private static boolean isFromRelationSeparator(List tokens, int commaIndex) { + int depth = MysqlSqlCompletionTokenUtil.depthAtOffset(tokens, tokens.get(commaIndex).getStartIndex()); + int fromIndex = MysqlSqlCompletionTokenUtil.lastDefaultIndexBeforeAtDepth(tokens, + tokens.get(commaIndex).getStartIndex(), depth, MySqlLexer.FROM); + if (fromIndex < 0) { + return false; + } + int boundaryIndex = MysqlSqlCompletionTokenUtil.lastDefaultIndexBeforeAtDepth(tokens, + tokens.get(commaIndex).getStartIndex(), depth, MySqlLexer.WHERE, MySqlLexer.GROUP, + MySqlLexer.HAVING, MySqlLexer.WINDOW, MySqlLexer.ORDER, MySqlLexer.LIMIT, MySqlLexer.UNION); + return boundaryIndex < fromIndex; + } + private static boolean isOnDuplicateKeyUpdate(List tokens, int index) { int previousIndex = MysqlSqlCompletionTokenUtil.previousDefaultIndex(tokens, index - 1); return previousIndex >= 0 && tokens.get(previousIndex).getType() == MySqlLexer.KEY 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 54bf509791..af43b540dc 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 = """ @@ -2426,6 +2446,17 @@ private void assertNoCandidate(SqlCompletionResponse result, () -> "unexpected candidate " + type + ":" + label); } + private void assertOnlyOrdersAliasBColumns(SqlCompletionResponse result) { + 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..f7e7a5a5e8 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,27 @@ 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 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( From 05aff801a0ee4bfd9a989ed6e5fb2bb79ce2f2bc Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:51:00 -0700 Subject: [PATCH 2/3] fix: address round-2 residual (surgical round) --- .../resolver/MysqlSqlCompletionRelationScopeResolver.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 41c10d729f..b3d2142cee 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 @@ -312,7 +312,8 @@ private static boolean isFromRelationSeparator(List tokens, int commaInde } int boundaryIndex = MysqlSqlCompletionTokenUtil.lastDefaultIndexBeforeAtDepth(tokens, tokens.get(commaIndex).getStartIndex(), depth, MySqlLexer.WHERE, MySqlLexer.GROUP, - MySqlLexer.HAVING, MySqlLexer.WINDOW, MySqlLexer.ORDER, MySqlLexer.LIMIT, MySqlLexer.UNION); + MySqlLexer.HAVING, MySqlLexer.WINDOW, MySqlLexer.ORDER, MySqlLexer.LIMIT, MySqlLexer.UNION, + MySqlLexer.INTO); return boundaryIndex < fromIndex; } From 705e5430215625cae09c3449d74259f0f337b8d4 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:34:05 -0700 Subject: [PATCH 3/3] fix(mysql): make relation-separator recognition query-block-aware Addresses review feedback on #2088: - Replace the backward same-depth FROM search with a single forward state scan per query block, so sibling subqueries and sibling CTE bodies no longer reuse an earlier block's FROM. This also removes the O(n^2) comma classification: separators are precomputed once, then read in O(1). - Recognize ON DUPLICATE KEY UPDATE, FOR UPDATE/SHARE and LOCK IN SHARE MODE contextually rather than treating raw ON/FOR/UPDATE as boundaries. - Scope resolution to the statement containing the cursor. - Add negative regression tests for sibling subqueries, the second CTE body, multiple statements, ON DUPLICATE KEY UPDATE and FOR UPDATE/SHARE. - Assert the captured metadata request is COLUMN, scoped to orders/b, with an empty prefix. Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- ...sqlSqlCompletionRelationScopeResolver.java | 110 ++++++++++++++---- .../MysqlSqlCompletionProviderTest.java | 3 + ...qlCompletionRelationScopeResolverTest.java | 60 ++++++++++ 3 files changed, 153 insertions(+), 20 deletions(-) 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 b3d2142cee..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,38 +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.COMMA -> isFromRelationSeparator(tokens, index); - 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 isFromRelationSeparator(List tokens, int commaIndex) { - int depth = MysqlSqlCompletionTokenUtil.depthAtOffset(tokens, tokens.get(commaIndex).getStartIndex()); - int fromIndex = MysqlSqlCompletionTokenUtil.lastDefaultIndexBeforeAtDepth(tokens, - tokens.get(commaIndex).getStartIndex(), depth, MySqlLexer.FROM); - if (fromIndex < 0) { - return 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; + } } - int boundaryIndex = MysqlSqlCompletionTokenUtil.lastDefaultIndexBeforeAtDepth(tokens, - tokens.get(commaIndex).getStartIndex(), depth, MySqlLexer.WHERE, MySqlLexer.GROUP, - MySqlLexer.HAVING, MySqlLexer.WINDOW, MySqlLexer.ORDER, MySqlLexer.LIMIT, MySqlLexer.UNION, - MySqlLexer.INTO); - return boundaryIndex < fromIndex; + 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 af43b540dc..d723f43f8d 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 @@ -2447,6 +2447,9 @@ private void assertNoCandidate(SqlCompletionResponse result, } 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()) 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 f7e7a5a5e8..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 @@ -56,6 +56,66 @@ void resolvesCommaRelationAfterJoinPredicate() { 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(