From 024a4aedd6a513217c31fe26169a78121810c76e Mon Sep 17 00:00:00 2001 From: vrustx <279631638@qq.com> Date: Fri, 10 Jul 2026 18:10:08 +0800 Subject: [PATCH] fix(editor): resolve JOIN tables for navigation --- apps/desktop/src/lib/sql/semantic/model.ts | 4 +- apps/desktop/src/lib/sql/sqlCompletion.ts | 10 ++- packages/app-tests/sqlCompletion.test.ts | 81 ++++++++++++++++++++++ 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/lib/sql/semantic/model.ts b/apps/desktop/src/lib/sql/semantic/model.ts index 31fdf8f49..7b1e13d3f 100644 --- a/apps/desktop/src/lib/sql/semantic/model.ts +++ b/apps/desktop/src/lib/sql/semantic/model.ts @@ -16,10 +16,10 @@ import type { SqlSemanticToken, } from "@/lib/sql/semantic/types"; -const TABLE_INTRODUCERS = new Set(["from", "join", "update", "into", "using", "apply"]); +const TABLE_INTRODUCERS = new Set(["from", "join", "straight_join", "update", "into", "using", "apply"]); const JOIN_MODIFIERS = new Set(["left", "right", "inner", "outer", "cross", "full", "natural"]); const CLAUSE_BOUNDARIES = new Set(["where", "group", "having", "order", "limit", "offset", "union", "intersect", "except", "on", "set", "values", "returning"]); -const ALIAS_BLACKLIST = new Set([...CLAUSE_BOUNDARIES, "join", "left", "right", "inner", "outer", "cross", "full", "natural", "as", "select", "from"]); +const ALIAS_BLACKLIST = new Set([...CLAUSE_BOUNDARIES, "join", "straight_join", "left", "right", "inner", "outer", "cross", "full", "natural", "as", "select", "from"]); interface ParseState { dialect: SqlSemanticDialectAdapter; diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 8266ad598..f6a6b8f71 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -2205,6 +2205,7 @@ function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] { "select", "from", "join", + "straight_join", "left", "right", "inner", @@ -2287,11 +2288,16 @@ function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] { "respect", ]); - const pattern = /\b(?:from|join|update|apply)\s+((?:"[^"]+"|`[^`]+`|[^\s,;()]+)(?:\.(?:"[^"]+"|`[^`]+`|[^\s,;()]+))?)(?:\s+(?:as\s+)?([A-Za-z_][\w$]*))?/gi; + // STRAIGHT_JOIN is a standalone MySQL table introducer, not a modifier followed by JOIN. + const pattern = /\b(?:from|join|straight_join|update|apply)\s+((?:"[^"]+"|`[^`]+`|[^\s,;()]+)(?:\.(?:"[^"]+"|`[^`]+`|[^\s,;()]+))?)(?:\s+(?:as\s+)?([A-Za-z_][\w$]*))?/gi; const referenced: SqlCompletionReferencedTable[] = []; - for (const match of sql.matchAll(pattern)) { + let match: RegExpExecArray | null; + while ((match = pattern.exec(sql)) !== null) { const rawName = match[1]; const alias = match[2]; + if (alias && ALIAS_BLACKLIST.has(alias.toLowerCase())) { + pattern.lastIndex = match.index + match[0].length - alias.length; + } const quotedName = !!rawName && (rawName.startsWith('"') || rawName.startsWith("`")); if (!quotedName && rawName && ALIAS_BLACKLIST.has(rawName.toLowerCase())) continue; // Filter out SQL keywords that accidentally matched as aliases diff --git a/packages/app-tests/sqlCompletion.test.ts b/packages/app-tests/sqlCompletion.test.ts index 8ad8a0c9f..c2bbf0f7a 100644 --- a/packages/app-tests/sqlCompletion.test.ts +++ b/packages/app-tests/sqlCompletion.test.ts @@ -967,6 +967,87 @@ test("keeps completed references while removing active JOIN table prefixes", () ); }); +test("extracts JOIN tables without explicit aliases", () => { + const sql = "select * from a join b on a.id = b.id"; + const context = getSqlCompletionContext(sql, sql.length); + + assert.deepEqual( + context.referencedTables.map((table) => table.name), + ["a", "b"], + ); +}); + +test("extracts MySQL backtick-qualified tables across a JOIN", () => { + const sql = [ + "select", + " `jobdb`.`job_application_ats_process`.`process_id`,", + " count(*)", + "from", + " `jobdb`.`job_application`", + "join `jobdb`.`job_application_ats_process` on", + " `jobdb`.`job_application`.`id` = `jobdb`.`job_application_ats_process`.`app_id`", + ].join("\n"); + const context = getSqlCompletionContext(sql, sql.length); + + assert.deepEqual( + context.referencedTables.map(({ schema, name }) => ({ schema, name })), + [ + { schema: "jobdb", name: "job_application" }, + { schema: "jobdb", name: "job_application_ats_process" }, + ], + ); +}); + +test("extracts every table across consecutive JOINs", () => { + const sql = "select * from db.a join db.b on 1=1 join db.c on 2=2"; + const context = getSqlCompletionContext(sql, sql.length); + + assert.deepEqual( + context.referencedTables.map(({ schema, name }) => ({ schema, name })), + [ + { schema: "db", name: "a" }, + { schema: "db", name: "b" }, + { schema: "db", name: "c" }, + ], + ); +}); + +test("extracts tables across a MySQL STRAIGHT_JOIN", () => { + const sql = "select * from db.a straight_join db.b on db.a.id = db.b.id"; + const context = getSqlCompletionContext(sql, sql.length); + + assert.deepEqual( + context.referencedTables.map(({ schema, name, alias }) => ({ schema, name, alias })), + [ + { schema: "db", name: "a", alias: undefined }, + { schema: "db", name: "b", alias: undefined }, + ], + ); +}); + +test("keeps explicit table aliases across a JOIN", () => { + const sql = "select * from db.a x join db.b y"; + const context = getSqlCompletionContext(sql, sql.length); + + assert.deepEqual( + context.referencedTables.map(({ schema, name, alias }) => ({ schema, name, alias })), + [ + { schema: "db", name: "a", alias: "x" }, + { schema: "db", name: "b", alias: "y" }, + ], + ); +}); + +test("does not treat WHERE as a table alias", () => { + const sql = "select * from a where id = 1"; + const context = getSqlCompletionContext(sql, sql.length); + + assert.deepEqual( + context.referencedTables.map(({ name, alias }) => ({ name, alias })), + [{ name: "a", alias: undefined }], + ); +}); + test("ranks exact table matches above prefix and fuzzy matches", () => { const items = buildSqlCompletionItems("select * from toh", "select * from toh".length, { tables: [