From 488f661bf3da35c61b07fb9ea34d9de091448925 Mon Sep 17 00:00:00 2001 From: zipg Date: Wed, 8 Jul 2026 17:51:03 +0800 Subject: [PATCH] fix(sql): allow quoted keyword table navigation --- .../src/components/editor/QueryEditor.vue | 15 +++++------ .../lib/__tests__/sql/sqlNavigation.spec.ts | 23 ++++++++++++++++- apps/desktop/src/lib/sql/sqlNavigation.ts | 25 ++++++++++++++----- 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index c545d45b19..f6d1b16c27 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -37,7 +37,7 @@ import { mergeSqlSemanticReferenceAnalysis, resolveSqlSemanticNavigationTarget } import { buildElasticsearchCompletionItemsFromContext, getElasticsearchCompletionContext, getElasticsearchCompletionResultValidFor, shouldAutoOpenElasticsearchCompletion, type ElasticsearchCompletionItem } from "@/lib/elasticsearch/elasticsearchCompletion"; import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, shouldAutoOpenMongoCompletion, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion"; import { resolveSqlCompletionTableLookupTarget } from "@/lib/sql/sqlCompletionLookupTarget"; -import { extractIdentifierAt, isSqlKeyword, matchTable, splitQualifiedIdentifier } from "@/lib/sql/sqlNavigation"; +import { extractIdentifierDetailsAt, isSqlKeyword, matchTable, splitQualifiedIdentifier } from "@/lib/sql/sqlNavigation"; import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sql/sqlDiagnostics"; import { DBX_TABLE_REFERENCE_MIME, @@ -505,9 +505,9 @@ function tableNavigationIdentifierAt(currentView: EditorViewType, event: MouseEv if (!props.connectionId || props.database == null) return null; const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY }); if (pos == null) return null; - const identifier = extractIdentifierAt(currentView.state.doc.toString(), pos); - if (!identifier || isSqlKeyword(identifier)) return null; - return identifier; + const extracted = extractIdentifierDetailsAt(currentView.state.doc.toString(), pos); + if (!extracted || (!extracted.quoted && isSqlKeyword(extracted.identifier))) return null; + return extracted.identifier; } function updateTableNavigationHover(currentView: EditorViewType, event: MouseEvent) { @@ -2889,13 +2889,14 @@ onMounted(async () => { } const doc = currentView.state.doc.toString(); - const identifier = extractIdentifierAt(doc, pos); - if (!identifier) { + const extracted = extractIdentifierDetailsAt(doc, pos); + if (!extracted) { return false; } - if (isSqlKeyword(identifier)) { + if (!extracted.quoted && isSqlKeyword(extracted.identifier)) { return false; } + const identifier = extracted.identifier; // Prevent default, resolve async event.preventDefault(); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts index 2a847952dc..9898d627f8 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { extractIdentifierAt, matchTable, splitQualifiedIdentifier } from "@/lib/sql/sqlNavigation"; +import { extractIdentifierAt, extractIdentifierDetailsAt, isSqlKeyword, matchTable, splitQualifiedIdentifier } from "@/lib/sql/sqlNavigation"; describe("extractIdentifierAt", () => { it("extracts unquoted qualified identifiers", () => { @@ -15,6 +15,27 @@ describe("extractIdentifierAt", () => { expect(extractIdentifierAt(sql, sql.indexOf("MAAC00"))).toBe("MAAC00.Accounts"); }); + it("preserves quote metadata for quoted keyword identifiers", () => { + const sql = "SELECT * FROM `group` LIMIT 100;"; + + expect(extractIdentifierDetailsAt(sql, sql.indexOf("group"))).toEqual({ + identifier: "group", + quoted: true, + }); + expect(matchTable(extractIdentifierAt(sql, sql.indexOf("group")) ?? "", [{ name: "group" }])).toEqual({ name: "group" }); + }); + + it("marks unquoted keyword identifiers as unquoted", () => { + const sql = "SELECT dept, COUNT(*) FROM users GROUP BY dept;"; + const extracted = extractIdentifierDetailsAt(sql, sql.indexOf("GROUP")); + + expect(extracted).toEqual({ + identifier: "GROUP", + quoted: false, + }); + expect(extracted && isSqlKeyword(extracted.identifier)).toBe(true); + }); + it("extracts double-quoted qualified identifiers", () => { const sql = 'select * from "MAAC00"."Accounts"'; diff --git a/apps/desktop/src/lib/sql/sqlNavigation.ts b/apps/desktop/src/lib/sql/sqlNavigation.ts index 6ecf134cd8..a9653f05cf 100644 --- a/apps/desktop/src/lib/sql/sqlNavigation.ts +++ b/apps/desktop/src/lib/sql/sqlNavigation.ts @@ -121,7 +121,12 @@ const SQL_KEYWORDS_SET = new Set([ "list_transform", ]); -type IdentifierPart = { value: string; start: number; end: number }; +type IdentifierPart = { value: string; start: number; end: number; quoted: boolean }; + +export interface ExtractedSqlIdentifier { + identifier: string; + quoted: boolean; +} function isIdentifierChar(char: string | undefined): boolean { return !!char && /^[A-Za-z0-9_$]$/.test(char); @@ -141,7 +146,7 @@ function readQuotedPart(text: string, start: number): IdentifierPart | null { i += 1; continue; } - return { value, start, end: i + 1 }; + return { value, start, end: i + 1, quoted: true }; } value += char; } @@ -152,7 +157,7 @@ function readUnquotedPart(text: string, start: number): IdentifierPart | null { if (!isIdentifierChar(text[start])) return null; let end = start + 1; while (end < text.length && isIdentifierChar(text[end])) end += 1; - return { value: text.slice(start, end), start, end }; + return { value: text.slice(start, end), start, end, quoted: false }; } function readIdentifierPart(text: string, start: number): IdentifierPart | null { @@ -185,8 +190,8 @@ function identifierSearchBounds(doc: string, pos: number): { start: number; end: return { start, end }; } -/** Extract identifier at position `pos` in the document. */ -export function extractIdentifierAt(doc: string, pos: number): string | null { +/** Extract identifier and quote metadata at position `pos` in the document. */ +export function extractIdentifierDetailsAt(doc: string, pos: number): ExtractedSqlIdentifier | null { if (pos < 0 || pos > doc.length) return null; const clickPos = pos === doc.length ? pos - 1 : pos; @@ -198,7 +203,10 @@ export function extractIdentifierAt(doc: string, pos: number): string | null { const parsed = parseQualifiedIdentifier(doc, index); if (parsed) { if (clickPos >= parsed.start && clickPos < parsed.end) { - return parsed.parts.map((part) => part.value).join("."); + return { + identifier: parsed.parts.map((part) => part.value).join("."), + quoted: parsed.parts.some((part) => part.quoted), + }; } index = Math.max(parsed.end, index + 1); continue; @@ -209,6 +217,11 @@ export function extractIdentifierAt(doc: string, pos: number): string | null { return null; } +/** Extract identifier at position `pos` in the document. */ +export function extractIdentifierAt(doc: string, pos: number): string | null { + return extractIdentifierDetailsAt(doc, pos)?.identifier ?? null; +} + /** Check whether the identifier is a SQL keyword (not a table/column name). */ export function isSqlKeyword(identifier: string): boolean { return SQL_KEYWORDS_SET.has(identifier.toLowerCase());