diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 99b1c5eb7..e7260ec7b 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -25,6 +25,7 @@ import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/edito import { createSqlSignatureTooltipDom } from "@/lib/editor/sqlSignatureTooltip"; import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste"; import { resolveSqlSingleQuoteKeyAction } from "@/lib/sql/sqlQuoteCaret"; +import { convertSqlSelectionCase, type SqlSelectionCaseMode } from "@/lib/sql/sqlSelectionCase"; import { formatMongoShellText } from "@/lib/mongo/mongoFormatter"; import { useConnectionStore, COMPLETION_METADATA_CONCURRENCY } from "@/stores/connectionStore"; import { useSettingsStore } from "@/stores/settingsStore"; @@ -420,7 +421,6 @@ function runStatementGutterExtension(markers = props.statementExecutionMarkers ? return shouldShowStatementGutter(showRunButtons, markers.length) ? (buildRunStatementGutterExtension?.() ?? []) : []; } -type SelectionCaseMode = "upper" | "lower"; let executableStatementRangeCache: ExecutableStatementRangeCache | null = null; let editorScrollbarPointerCleanup: (() => void) | null = null; let editorSelectionDragCleanup: (() => void) | null = null; @@ -1078,17 +1078,17 @@ function selectAllSqlFromContextMenu() { focusEditor(); } -function convertSelectedSqlCase(mode: SelectionCaseMode): boolean { +function convertSelectedSqlCase(mode: SqlSelectionCaseMode): boolean { const currentView = view.value; const EditorSelection = codeMirrorEditorSelection; if (!currentView || !EditorSelection) return false; const state = currentView.state; + const documentText = state.doc.toString(); const transaction = state.changeByRange((range) => { if (range.empty) return { range }; - const selectedText = state.sliceDoc(range.from, range.to); - const convertedText = mode === "upper" ? selectedText.toUpperCase() : selectedText.toLowerCase(); + const convertedText = convertSqlSelectionCase(documentText, { from: range.from, to: range.to }, mode, sqlBehaviorDialect()); return { changes: { from: range.from, to: range.to, insert: convertedText }, range: EditorSelection.range(range.from, range.from + convertedText.length), diff --git a/apps/desktop/src/lib/__tests__/sql/sqlSelectionCase.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlSelectionCase.spec.ts new file mode 100644 index 000000000..3b2786317 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/sqlSelectionCase.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { convertSqlSelectionCase } from "@/lib/sql/sqlSelectionCase"; + +describe("convertSqlSelectionCase", () => { + it("converts SQL text without changing string literals", () => { + const sql = "SELECT Code FROM Orders WHERE Code = 'ABC001' AND Note = 'It''s Ready'"; + + expect(convertSqlSelectionCase(sql, { from: 0, to: sql.length }, "lower")).toBe("select code from orders where code = 'ABC001' and note = 'It''s Ready'"); + }); + + it("preserves string literals when converting to uppercase", () => { + const sql = "select code from orders where code = 'abc001'"; + + expect(convertSqlSelectionCase(sql, { from: 0, to: sql.length }, "upper")).toBe("SELECT CODE FROM ORDERS WHERE CODE = 'abc001'"); + }); + + it("preserves the selected fragment when the selection is inside a string literal", () => { + const sql = "select * from orders where code = 'AbC001'"; + const from = sql.indexOf("bC"); + + expect(convertSqlSelectionCase(sql, { from, to: from + 2 }, "lower")).toBe("bC"); + }); + + it("preserves PostgreSQL dollar-quoted string literals", () => { + const sql = "select $tag$Mixed Value$tag$ as label"; + + expect(convertSqlSelectionCase(sql, { from: 0, to: sql.length }, "upper", "postgres")).toBe("SELECT $tag$Mixed Value$tag$ AS LABEL"); + }); + + it("continues converting comments and quoted identifiers", () => { + const sql = 'select "MixedName" -- Keep Comment\nfrom users'; + + expect(convertSqlSelectionCase(sql, { from: 0, to: sql.length }, "lower")).toBe('select "mixedname" -- keep comment\nfrom users'); + }); + + it("uses SQL Server tokenization so temp tables do not hide later literals", () => { + const sql = "SELECT * FROM #Temp WHERE Code = 'AbC001'"; + + expect(convertSqlSelectionCase(sql, { from: 0, to: sql.length }, "lower", "sqlserver")).toBe("select * from #temp where code = 'AbC001'"); + }); + + it("preserves MySQL double-quoted strings", () => { + const sql = 'SELECT "Mixed Value" AS Label'; + + expect(convertSqlSelectionCase(sql, { from: 0, to: sql.length }, "lower", "mysql")).toBe('select "Mixed Value" as label'); + }); + + it("preserves MySQL executable comments", () => { + const sql = "SELECT 1 /*!40101 SET @Name = 'Mixed Value' */ FROM Dual"; + + expect(convertSqlSelectionCase(sql, { from: 0, to: sql.length }, "lower", "mysql")).toBe("select 1 /*!40101 SET @Name = 'Mixed Value' */ from dual"); + }); +}); diff --git a/apps/desktop/src/lib/sql/sqlSelectionCase.ts b/apps/desktop/src/lib/sql/sqlSelectionCase.ts new file mode 100644 index 000000000..b910be0b4 --- /dev/null +++ b/apps/desktop/src/lib/sql/sqlSelectionCase.ts @@ -0,0 +1,37 @@ +import { tokenizeSqlSemantic } from "@/lib/sql/semantic/tokens"; + +export type SqlSelectionCaseMode = "upper" | "lower"; + +type SqlSelectionRange = { + from: number; + to: number; +}; + +function convertCase(text: string, mode: SqlSelectionCaseMode): string { + return mode === "upper" ? text.toUpperCase() : text.toLowerCase(); +} + +export function convertSqlSelectionCase(sql: string, range: SqlSelectionRange, mode: SqlSelectionCaseMode, dialectId?: "mysql" | "postgres" | "sqlserver"): string { + const from = Math.max(0, Math.min(range.from, sql.length)); + const to = Math.max(from, Math.min(range.to, sql.length)); + const protectedTokens = tokenizeSqlSemantic(sql, dialectId).filter((item) => { + if (item.span.end <= from || item.span.start >= to) return false; + if (item.kind === "string") return true; + if (dialectId !== "mysql") return false; + if (item.kind === "quoted_identifier" && item.quote === '"') return true; + return item.kind === "comment" && /^\/\*(?:!|M!)/i.test(item.text); + }); + if (protectedTokens.length === 0) return convertCase(sql.slice(from, to), mode); + + let converted = ""; + let cursor = from; + for (const item of protectedTokens) { + const literalFrom = Math.max(from, item.span.start); + const literalTo = Math.min(to, item.span.end); + converted += convertCase(sql.slice(cursor, literalFrom), mode); + converted += sql.slice(literalFrom, literalTo); + cursor = literalTo; + } + converted += convertCase(sql.slice(cursor, to), mode); + return converted; +}