diff --git a/e2e/questdb b/e2e/questdb index 8bb20f7ce..7688f34d1 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 8bb20f7ce06a65a7c846816cb75458929ad7cdc2 +Subproject commit 7688f34d118970637232593df4041abfab667e79 diff --git a/e2e/tests/console/editor.spec.js b/e2e/tests/console/editor.spec.js index 3f7ee847b..555e24c92 100644 --- a/e2e/tests/console/editor.spec.js +++ b/e2e/tests/console/editor.spec.js @@ -654,14 +654,14 @@ describe("autocomplete", () => { const assertFrom = () => cy.getAutocomplete().within(() => { cy.getMonacoListRow() - .should("have.length", 4) + .should("have.length", 1) .eq(0) .should("contain", "FROM") }) - cy.typeQuery("select * from") + cy.typeQuery("select * fro") assertFrom() cy.clearEditor() - cy.typeQuery("SELECT * FROM") + cy.typeQuery("SELECT * FRO") assertFrom() }) @@ -681,37 +681,127 @@ describe("autocomplete", () => { // Columns .should("contain", "secret") .should("contain", "public") - // Tables list for the `secret` column - // list the tables containing `secret` column - .should("contain", "my_secrets, my_secrets2") .clearEditor() }) it("should suggest columns on SELECT only when applicable", () => { - cy.typeQuery("select secret") - cy.getAutocomplete().should("contain", "secret").eq(0).click() - cy.typeQuery(", public") - cy.getAutocomplete().should("contain", "public").eq(0).click() - cy.typeQuery(" ") - cy.getAutocomplete().should("not.be.visible") + cy.typeQuery("select secre") + cy.getAutocomplete().should("be.visible") + cy.typeQuery("{enter}") + cy.typeQuery(", publi") + cy.getAutocomplete().should("be.visible") + cy.typeQuery("{enter}") + cy.getAutocomplete().should("contain", "FROM") + cy.clearEditor() }) it("should suggest correct columns on 'where' filter", () => { cy.typeQuery("select * from my_secrets where ") + cy.getAutocomplete().eq(0).should("contain", "secret").clearEditor() + }) + + it("should suggest correct columns on 'on' clause", () => { + cy.typeQuery("select * from my_secrets join my_publics on ") cy.getAutocomplete() + .should("contain", "public") .should("contain", "secret") - .should("not.contain", "public") .clearEditor() }) - it("should suggest correct columns on 'on' clause", () => { - cy.typeQuery("select * from my_secrets join my_publics on ") + it("should suggest columns for dot-qualified alias", () => { + cy.typeQuery("select * from my_secrets s where s.") + cy.getAutocomplete().should("contain", "secret").clearEditor() + }) + + it("should replace partial text when accepting a suggestion", () => { + cy.typeQuery("select * from my_se") + cy.getAutocomplete().should("contain", "my_secrets") + cy.typeQuery("{enter}") + cy.window().then((win) => { + const value = win.monaco.editor.getEditors()[0].getValue() + expect(value).to.match(/select \* from my_secrets/) + expect(value).to.not.contain("my_semy_secrets") + }) + cy.clearEditor() + }) + + it("should suggest the new keyword after accepting a suggestion", () => { + cy.typeQuery("CR") + cy.getAutocomplete().should("contain", "CREATE") + cy.typeQuery("{enter}") + cy.typeQuery("T") + cy.getAutocomplete().should("contain", "TABLE") + cy.typeQuery("{enter}") + cy.getAutocomplete().should("contain", "IF") + cy.typeQuery("{enter}") + cy.getAutocomplete().should("contain", "NOT") + cy.typeQuery("{enter}") + cy.getAutocomplete().should("contain", "EXISTS") + cy.typeQuery("{enter}") + cy.clearEditor() + }) + + it("should not suggest the very same keyword when it's already typed", () => { + cy.typeQuery("SELECT * FROM") + cy.getAutocomplete().should("not.be.visible") + + cy.typeQuery(`${ctrlOrCmd}i`) + cy.getAutocomplete() + .should("be.visible") + .should("contain", "No suggestions") + cy.clearEditor() + }) + + it("should suggest tables in second statement of multi-statement buffer", () => { + cy.typeQuery("select * from my_secrets;{enter}select * from ") cy.getAutocomplete() - .should("contain", "my_publics.public") - .should("contain", "my_secrets.secret") + .should("contain", "my_secrets") + .should("contain", "my_publics") .clearEditor() }) + it("should not suggest anything immediately after opening parenthesis", () => { + cy.typeQuery("select count(") + cy.getAutocomplete().should("not.be.visible") + cy.clearEditor() + }) + + it("should suggest after parenthesis followed by space", () => { + cy.typeQuery("select count( ") + cy.getAutocomplete().should("be.visible").clearEditor() + }) + + it("should not suggest inside line comments", () => { + cy.typeQuery("-- select * from ") + cy.getAutocomplete().should("not.be.visible").clearEditor() + }) + + it("should not suggest in dead space between statements", () => { + cy.typeQuery("select 1;") + cy.typeQuery("{enter}{enter}") + cy.getAutocomplete().should("not.be.visible").clearEditor() + }) + + it("should display keywords in uppercase", () => { + cy.typeQuery("select * from my_secrets whe") + cy.getAutocomplete().should("contain", "WHERE") + // Should not contain lowercase variant + cy.getAutocomplete().within(() => { + cy.getMonacoListRow().first().should("contain", "WHERE") + }) + cy.clearEditor() + }) + + it("should suggest CTE name in FROM clause", () => { + cy.typeQuery("with cte as (select 1) select * from ") + cy.getAutocomplete().should("contain", "cte").clearEditor() + }) + + it("should not suggest inside block comments", () => { + cy.typeQuery("/* select * from ") + cy.getAutocomplete().should("not.be.visible").clearEditor() + }) + after(() => { cy.loadConsoleWithAuth() ;["my_publics", "my_secrets", "my_secrets2"].forEach((table) => { @@ -730,19 +820,12 @@ describe("errors", () => { cy.clearEditor() }) - it("should mark '(200000)' as error", () => { - const query = `create table test (\ncol symbol index CAPACITY (200000)` - cy.typeQuery(query) - cy.runLine() - cy.matchErrorMarkerPosition({ left: 237, width: 67 }) - cy.getCollapsedNotifications().should("contain", "bad integer") - }) - it("should mark date position as error", () => { const query = `select * from long_sequence(1) where cast(x as timestamp) = '2012-04-12T12:00:00A'` cy.typeQuery(query) cy.runLine() - cy.matchErrorMarkerPosition({ left: 506, width: 42 }) + // Whole date string is shown as error + cy.matchErrorMarkerPosition({ left: 506, width: 185 }) cy.getCollapsedNotifications().should("contain", "Invalid date") }) diff --git a/e2e/tests/console/tableDetails.spec.js b/e2e/tests/console/tableDetails.spec.js index a5441bbb8..b4824ec29 100644 --- a/e2e/tests/console/tableDetails.spec.js +++ b/e2e/tests/console/tableDetails.spec.js @@ -694,8 +694,6 @@ describe("TableDetailsDrawer", () => { "contain", "AI Assistant is not configured", ) - // Workaround - tooltip grace area causes problems in the test when quickly switching multiple triggers. - // Move mouse away from trigger for 200ms cy.getByDataHook("table-details-tab-monitoring").realHover() cy.wait(200) @@ -746,8 +744,6 @@ describe("TableDetailsDrawer", () => { "contain", "Schema access is not granted to this model", ) - // Workaround - tooltip grace area causes problems in the test when quickly switching multiple triggers. - // Move mouse away from trigger for 200ms cy.getByDataHook("table-details-tab-monitoring").realHover() cy.wait(200) diff --git a/package.json b/package.json index 778044fb1..3f84c5c49 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@monaco-editor/react": "^4.7.0", "@phosphor-icons/react": "^2.1.10", "@popperjs/core": "2.4.2", - "@questdb/sql-grammar": "1.4.2", + "@questdb/sql-parser": "0.1.6", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", @@ -179,7 +179,7 @@ }, { "path": "dist/assets/index-*.js", - "maxSize": "2.5MB", + "maxSize": "3MB", "compression": "none" }, { diff --git a/src/scenes/Editor/Monaco/index.tsx b/src/scenes/Editor/Monaco/index.tsx index 91fa96b61..b142ba98f 100644 --- a/src/scenes/Editor/Monaco/index.tsx +++ b/src/scenes/Editor/Monaco/index.tsx @@ -50,7 +50,10 @@ import { createSchemaCompletionProvider } from "./questdb-sql" import { Request } from "./utils" import { appendQuery, + applyValidationMarkers, + cancelAllValidationRequests, clearModelMarkers, + clearValidationMarkers, findMatches, getErrorRange, getQueryFromCursor, @@ -66,6 +69,7 @@ import { parseQueryKey, createQueryKeyFromRequest, validateQueryAtOffset, + validateQueryJIT, setErrorMarkerForQuery, getQueryStartOffset, getQueriesToRun, @@ -342,6 +346,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { }) const scrollTimeoutRef = useRef(null) const notificationTimeoutRef = useRef(null) + const validationTimeoutRef = useRef(null) const targetPositionRef = useRef<{ lineNumber: number column: number @@ -377,6 +382,23 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { dispatch(actions.query.toggleRunning(runningType)) } + const triggerJitValidation = () => { + if (runningValueRef.current !== RunningType.NONE || requestRef.current) { + return + } + + if (monacoRef.current && editorRef.current) { + const currentBufferId = activeBufferRef.current.id as number + validateQueryJIT( + monacoRef.current, + editorRef.current, + currentBufferId, + () => executionRefs.current[currentBufferId.toString()] || {}, + (q, signal) => quest.validateQuery(q, signal), + ) + } + } + const updateQueryNotification = (queryKey?: QueryKey) => { const currentAISuggestion = aiSuggestionRequestRef.current if (currentAISuggestion && activeNotificationRef.current) { @@ -521,6 +543,18 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { const startOffset = getQueryStartOffset(editor, query) const queryText = query.query + if (validationTimeoutRef.current) { + window.clearTimeout(validationTimeoutRef.current) + validationTimeoutRef.current = null + } + if (monacoRef.current) { + clearValidationMarkers( + monacoRef.current, + editor, + activeBufferRef.current.id as number, + ) + } + if (runningValueRef.current === RunningType.NONE) { setCursorBeforeRunning(query) toggleRunning(type) @@ -551,6 +585,18 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { return } + if (validationTimeoutRef.current) { + window.clearTimeout(validationTimeoutRef.current) + validationTimeoutRef.current = null + } + if (monacoRef.current) { + clearValidationMarkers( + monacoRef.current, + editor, + activeBufferRef.current.id as number, + ) + } + const position = model.getPositionAt(pending.startOffset) editor.setPosition(position) @@ -902,6 +948,15 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { } cursorChangeTimeoutRef.current = null }, 50) + + // JIT validation on cursor move (debounced) + if (validationTimeoutRef.current) { + window.clearTimeout(validationTimeoutRef.current) + } + validationTimeoutRef.current = window.setTimeout(() => { + triggerJitValidation() + validationTimeoutRef.current = null + }, 300) }) editor.onDidChangeModelContent(async (e) => { @@ -929,8 +984,6 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { newKey: QueryKey data: ExecutionInfo }> = [] - const keysToRemove: QueryKey[] = [] - Object.keys(bufferExecutions).forEach((key) => { const queryKey = key as QueryKey const { queryText, startOffset, endOffset } = @@ -948,36 +1001,23 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { } const newOffset = startOffset + effectiveOffsetDelta - if (validateQueryAtOffset(editor, queryText, newOffset)) { - const selection = bufferExecutions[queryKey].selection - const shiftedSelection = selection - ? { - startOffset: selection.startOffset + effectiveOffsetDelta, - endOffset: selection.endOffset + effectiveOffsetDelta, - } - : undefined - keysToUpdate.push({ - oldKey: queryKey, - newKey: createQueryKey(queryText, newOffset), - data: { - ...bufferExecutions[queryKey], - startOffset: newOffset, - endOffset: endOffset + effectiveOffsetDelta, - selection: shiftedSelection, - }, - }) - } else { - keysToRemove.push(queryKey) - notificationUpdates.push(() => - dispatch( - actions.query.removeNotification(queryKey, activeBufferId), - ), - ) - } - }) - - keysToRemove.forEach((key) => { - delete bufferExecutions[key] + const selection = bufferExecutions[queryKey].selection + const shiftedSelection = selection + ? { + startOffset: selection.startOffset + effectiveOffsetDelta, + endOffset: selection.endOffset + effectiveOffsetDelta, + } + : undefined + keysToUpdate.push({ + oldKey: queryKey, + newKey: createQueryKey(queryText, newOffset), + data: { + ...bufferExecutions[queryKey], + startOffset: newOffset, + endOffset: endOffset + effectiveOffsetDelta, + selection: shiftedSelection, + }, + }) }) keysToUpdate.forEach(({ oldKey, newKey, data }) => { @@ -1024,25 +1064,16 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { } const newOffset = startOffset + effectiveOffsetDelta - - if (validateQueryAtOffset(editor, queryText, newOffset)) { - const newKey = createQueryKey(queryText, newOffset) - notificationUpdates.push(() => - dispatch( - actions.query.updateNotificationKey( - queryKey, - newKey, - activeBufferId, - ), + const newKey = createQueryKey(queryText, newOffset) + notificationUpdates.push(() => + dispatch( + actions.query.updateNotificationKey( + queryKey, + newKey, + activeBufferId, ), - ) - } else { - notificationUpdates.push(() => - dispatch( - actions.query.removeNotification(queryKey, activeBufferId), - ), - ) - } + ), + ) }) if (bufferExecutions && Object.keys(bufferExecutions).length === 0) { @@ -1081,13 +1112,34 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { contentJustChangedRef.current = false notificationUpdates.forEach((update) => update()) + + // JIT validation (debounced) + if (validationTimeoutRef.current) { + window.clearTimeout(validationTimeoutRef.current) + } + validationTimeoutRef.current = window.setTimeout(() => { + triggerJitValidation() + validationTimeoutRef.current = null + }, 300) }) editor.onDidChangeModel(() => { + cancelAllValidationRequests() + if (validationTimeoutRef.current) { + window.clearTimeout(validationTimeoutRef.current) + validationTimeoutRef.current = null + } glyphWidgetsRef.current.forEach((widget) => { - editor.removeGlyphMarginWidget(widget) + editorRef.current?.removeGlyphMarginWidget(widget) }) glyphWidgetsRef.current.clear() + const lineCount = editorRef.current?.getModel()?.getLineCount() + if (lineCount) { + setLineNumbersMinChars( + getDefaultLineNumbersMinChars(canUseAIRef.current) + + (lineCount.toString().length - 1), + ) + } setTimeout(() => { if (monacoRef.current && editorRef.current) { applyGlyphsAndLineMarkings(monacoRef.current, editorRef.current) @@ -1182,6 +1234,8 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { } else { toggleRunning() } + } else { + triggerJitValidation() } } @@ -1209,6 +1263,15 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { | null = null dispatch(actions.query.setResult(undefined)) + // Clear JIT validation markers — execution result takes over. + if (monacoRef.current) { + clearValidationMarkers(monacoRef.current, editor, activeBufferId) + } + if (validationTimeoutRef.current) { + window.clearTimeout(validationTimeoutRef.current) + validationTimeoutRef.current = null + } + dispatch( actions.query.setActiveNotification({ type: NotificationType.LOADING, @@ -1621,17 +1684,27 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { useEffect(() => { runningValueRef.current = running - if ( - ![RunningType.NONE, RunningType.SCRIPT].includes(running) && - editorRef?.current - ) { - if (monacoRef?.current) { - clearModelMarkers(monacoRef.current, editorRef.current) - } + const editor = editorRef.current + const monaco = monacoRef.current + if (!editor || !monaco) { + return + } - if (monacoRef?.current && editorRef?.current) { - applyGlyphsAndLineMarkings(monacoRef.current, editorRef.current) + if (running !== RunningType.NONE) { + cancelAllValidationRequests() + clearModelMarkers(monaco, editor) + clearValidationMarkers( + monaco, + editor, + activeBufferRef.current.id as number, + ) + if (validationTimeoutRef.current) { + window.clearTimeout(validationTimeoutRef.current) + validationTimeoutRef.current = null } + } + if (![RunningType.NONE, RunningType.SCRIPT].includes(running)) { + applyGlyphsAndLineMarkings(monaco, editor) const request = running === RunningType.REFRESH @@ -1639,10 +1712,10 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { : running === RunningType.AI_SUGGESTION && aiSuggestionRequestRef.current ? getQueryRequestFromAISuggestion( - editorRef.current, + editor, aiSuggestionRequestRef.current, ) - : getQueryRequestFromEditor(editorRef.current) + : getQueryRequestFromEditor(editor) const isRunningExplain = running === RunningType.EXPLAIN const isAISuggestion = @@ -1652,7 +1725,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { const targetBufferId = activeBufferRef.current.id as number if (request?.query) { - editorRef.current?.updateOptions({ readOnly: true }) + editor.updateOptions({ readOnly: true }) const parentQuery = request.query // For AI_SUGGESTION, use the startOffset directly from aiSuggestionRequestRef // because the editor model doesn't contain the AI suggestion query @@ -1661,7 +1734,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { request.query, aiSuggestionRequestRef.current!.startOffset, ) - : createQueryKeyFromRequest(editorRef.current, request) + : createQueryKeyFromRequest(editor, request) const originalQueryText = request.selection ? request.selection.queryText : request.query @@ -1672,11 +1745,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { // give the notification a slight delay to prevent flashing for fast queries notificationTimeoutRef.current = window.setTimeout(() => { - if ( - runningValueRef.current && - requestRef.current && - editorRef.current - ) { + if (runningValueRef.current && requestRef.current && editor) { dispatch( actions.query.addNotification( { @@ -1932,7 +2001,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { setSchemaCompletionHandle( monacoRef.current.languages.registerCompletionItemProvider( QuestDBLanguageName, - createSchemaCompletionProvider(editorRef.current, tables, columns), + createSchemaCompletionProvider(tables, columns), ), ) setRefreshingTables(false) @@ -1952,6 +2021,13 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { clearModelMarkers(monacoRef.current, editorRef.current) applyGlyphsAndLineMarkings(monacoRef.current, editorRef.current) + + // Restore cached validation markers for this buffer + applyValidationMarkers( + monacoRef.current, + editorRef.current, + activeBuffer.id as number, + ) } }, [activeBuffer]) @@ -2026,6 +2102,15 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { window.clearTimeout(notificationTimeoutRef.current) } + if (validationTimeoutRef.current) { + window.clearTimeout(validationTimeoutRef.current) + clearValidationMarkers( + monacoRef.current, + editorRef.current, + activeBufferRef.current.id as number, + ) + } + glyphWidgetsRef.current.forEach((widget) => { editorRef.current?.removeGlyphMarginWidget(widget) }) @@ -2076,6 +2161,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { scrollBeyondLastLine: false, tabSize: 2, lineNumbersMinChars, + wordBasedSuggestions: "off", }} theme="dracula" /> diff --git a/src/scenes/Editor/Monaco/questdb-sql/completionProvider.test.ts b/src/scenes/Editor/Monaco/questdb-sql/completionProvider.test.ts new file mode 100644 index 000000000..01191702c --- /dev/null +++ b/src/scenes/Editor/Monaco/questdb-sql/completionProvider.test.ts @@ -0,0 +1,961 @@ +import { describe, it, expect, afterEach } from "vitest" +import "./monacoPolyfill" +import * as monaco from "monaco-editor/esm/vs/editor/editor.api" +import { createSchemaCompletionProvider } from "./createSchemaCompletionProvider" +import type { Table, InformationSchemaColumn } from "../../../../utils" +import type { languages, IRange, CancellationToken } from "monaco-editor" +import { CompletionItemKind } from "./types" +import { conf } from "./conf" + +monaco.languages.register({ id: "questdb-sql" }) +monaco.languages.setLanguageConfiguration("questdb-sql", conf) + +type TestProvider = Omit< + languages.CompletionItemProvider, + "provideCompletionItems" +> & { + provideCompletionItems( + model: monaco.editor.ITextModel, + position: monaco.Position, + ): ReturnType +} + +function wrapProvider(p: languages.CompletionItemProvider): TestProvider { + const original = p.provideCompletionItems.bind(p) + return { + ...p, + provideCompletionItems: (model, position) => + original( + model, + position, + {} as languages.CompletionContext, + {} as CancellationToken, + ), + } +} +const tables = [ + { + id: 1, + table_name: "trades", + partitionBy: "DAY", + designatedTimestamp: "ts", + walEnabled: true, + }, + { + id: 2, + table_name: "sensors", + partitionBy: "DAY", + designatedTimestamp: "timestamp", + walEnabled: true, + }, + { + id: 3, + table_name: "quoted-table.1", + partitionBy: "NONE", + designatedTimestamp: "", + walEnabled: false, + }, + { + id: 4, + table_name: "my table", + partitionBy: "NONE", + designatedTimestamp: "", + walEnabled: false, + }, + { + id: 5, + table_name: "123numeric", + partitionBy: "NONE", + designatedTimestamp: "", + walEnabled: false, + }, + { + id: 6, + table_name: "_valid$name", + partitionBy: "NONE", + designatedTimestamp: "", + walEnabled: false, + }, +] + +const informationSchemaColumns: Record = { + trades: [ + { + table_name: "trades", + ordinal_position: 1, + column_name: "symbol", + data_type: "SYMBOL", + }, + { + table_name: "trades", + ordinal_position: 2, + column_name: "price", + data_type: "DOUBLE", + }, + { + table_name: "trades", + ordinal_position: 3, + column_name: "ts", + data_type: "TIMESTAMP", + }, + ], + sensors: [ + { + table_name: "sensors", + ordinal_position: 1, + column_name: "temperature", + data_type: "DOUBLE", + }, + { + table_name: "sensors", + ordinal_position: 2, + column_name: "timestamp", + data_type: "TIMESTAMP", + }, + ], + "quoted-table.1": [ + { + table_name: "quoted-table.1", + ordinal_position: 1, + column_name: "value", + data_type: "DOUBLE", + }, + ], +} + +type CompletionResult = languages.CompletionList | null | undefined + +const models: monaco.editor.ITextModel[] = [] + +afterEach(() => { + models.forEach((m) => m.dispose()) + models.length = 0 +}) + +function createModel(text: string) { + const model = monaco.editor.createModel(text, "questdb-sql") + models.push(model) + return model +} + +function getSuggestions(result: CompletionResult): languages.CompletionItem[] { + return result?.suggestions ?? [] +} + +function getLabels(result: CompletionResult): string[] { + return getSuggestions(result).map((s) => { + if (typeof s.label === "string") return s.label + return s.label.label + }) +} + +function cursorInput(sql: string) { + const cursorIndex = sql.indexOf("|") + if (cursorIndex < 0) throw new Error("SQL string must contain a | cursor") + const text = sql.slice(0, cursorIndex) + sql.slice(cursorIndex + 1) + const model = createModel(text) + const position = model.getPositionAt(cursorIndex) + return { model, position } +} + +describe("createSchemaCompletionProvider", () => { + const provider = wrapProvider( + createSchemaCompletionProvider(tables as Table[], informationSchemaColumns), + ) + + describe("basic keyword suggestions", () => { + it("suggests keywords and columns after SELECT", () => { + const { model, position } = cursorInput("SELECT |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels.length).toBeGreaterThan(0) + }) + + it("suggests FROM after column list", () => { + const { model, position } = cursorInput("SELECT symbol |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels).toContain("FROM") + }) + + it("suggests table names after FROM", () => { + const { model, position } = cursorInput("SELECT * FROM |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels).toContain("trades") + expect(labels).toContain("sensors") + }) + + it("suggests WHERE after table name", () => { + const { model, position } = cursorInput("SELECT * FROM trades |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels).toContain("WHERE") + }) + }) + + describe("column suggestions", () => { + it("suggests columns for a table after WHERE", () => { + const { model, position } = cursorInput("SELECT * FROM trades WHERE |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels).toContain("symbol") + expect(labels).toContain("price") + expect(labels).toContain("ts") + }) + + it("suggests columns in SELECT clause when table is known", () => { + const { model, position } = cursorInput("SELECT | FROM trades") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels).toContain("symbol") + expect(labels).toContain("price") + }) + }) + + describe("keyword uppercasing", () => { + it("uppercases keywords in insertText", () => { + const { model, position } = cursorInput("SELECT * FROM trades |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const whereItem = items.find((s) => { + const label = typeof s.label === "string" ? s.label : s.label.label + return label === "WHERE" + }) + expect(whereItem).toBeDefined() + expect(whereItem!.insertText.startsWith("WHERE")).toBe(true) + }) + + it("does not uppercase table names", () => { + const { model, position } = cursorInput("SELECT * FROM |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const tradesItem = items.find((s) => { + const label = typeof s.label === "string" ? s.label : s.label.label + return label === "trades" + }) + expect(tradesItem).toBeDefined() + expect(tradesItem!.insertText.startsWith("trades")).toBe(true) + }) + }) + + describe("trailing space / re-trigger", () => { + it("appends trailing space to keyword insertText", () => { + const { model, position } = cursorInput("SELECT * FROM trades |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const whereItem = items.find((s) => { + const label = typeof s.label === "string" ? s.label : s.label.label + return label === "WHERE" + }) + expect(whereItem).toBeDefined() + expect(whereItem!.insertText.endsWith(" ")).toBe(true) + }) + + it("appends trailing space to table insertText", () => { + const { model, position } = cursorInput("SELECT * FROM |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const tradesItem = items.find((s) => { + const label = typeof s.label === "string" ? s.label : s.label.label + return label === "trades" + }) + expect(tradesItem).toBeDefined() + expect(tradesItem!.insertText.endsWith(" ")).toBe(true) + }) + + it("sets re-trigger command on non-function items", () => { + const { model, position } = cursorInput("SELECT * FROM |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const tradesItem = items.find((s) => { + const label = typeof s.label === "string" ? s.label : s.label.label + return label === "trades" + }) + expect(tradesItem?.command?.id).toBe("editor.action.triggerSuggest") + }) + }) + + describe("function suggestions", () => { + it("appends ($0) snippet to function insertText", () => { + const { model, position } = cursorInput("SELECT | FROM trades") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const funcItem = items.find((s) => s.kind === CompletionItemKind.Function) + if (funcItem) { + expect(funcItem.insertText.endsWith("($0)")).toBe(true) + // InsertAsSnippet = 4 + expect(funcItem.insertTextRules).toBe(4) + } + }) + + it("does not set re-trigger command on function items", () => { + const { model, position } = cursorInput("SELECT | FROM trades") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const funcItem = items.find((s) => s.kind === CompletionItemKind.Function) + if (funcItem) { + expect(funcItem.command).toBeUndefined() + } + }) + }) + + describe("filtering duplicate suggestions", () => { + it("does not suggest the word already typed", () => { + const { model, position } = cursorInput("SELECT * FROM trades|") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels).not.toContain("trades") + }) + + it("filters case-insensitively", () => { + const { model, position } = cursorInput("select * from TRADES|") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels).not.toContain("trades") + }) + }) + + describe("comment suppression", () => { + it("returns null when cursor is inside a line comment", () => { + const { model, position } = cursorInput("SELECT * -- comment |") + const result = provider.provideCompletionItems(model, position) + expect(result).toBeNull() + }) + + it("returns null when cursor is inside a block comment", () => { + const { model, position } = cursorInput("SELECT /* block | */") + const result = provider.provideCompletionItems(model, position) + expect(result).toBeNull() + }) + + it("returns suggestions after a block comment ends", () => { + const { model, position } = cursorInput("SELECT /* comment */ * FROM |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels).toContain("trades") + }) + }) + + describe("parenthesis suppression", () => { + it("returns null when character before cursor is (", () => { + const { model, position } = cursorInput("SELECT count(|") + const result = provider.provideCompletionItems(model, position) + expect(result).toBeNull() + }) + }) + + describe("multi-statement support", () => { + it("suggests for the current statement only", () => { + const { model, position } = cursorInput( + "SELECT * FROM trades; SELECT * FROM |", + ) + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels).toContain("trades") + expect(labels).toContain("sensors") + }) + + it("returns null in dead space between statements", () => { + const { model, position } = cursorInput( + "SELECT * FROM trades; | SELECT * FROM sensors", + ) + const result = provider.provideCompletionItems(model, position) + expect(result).toBeNull() + }) + + it("works with cursor in the first of multiple statements", () => { + const { model, position } = cursorInput( + "SELECT * FROM |; SELECT * FROM sensors", + ) + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels).toContain("trades") + }) + }) + + describe("multi-line queries", () => { + it("works across line breaks", () => { + const { model, position } = cursorInput("SELECT *\nFROM |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels).toContain("trades") + }) + + it("handles comment on previous line", () => { + const { model, position } = cursorInput( + "-- this is a comment\nSELECT * FROM |", + ) + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels).toContain("trades") + }) + }) + + describe("operator word handling", () => { + it("suggests after :: type cast operator", () => { + const { model, position } = cursorInput("SELECT price::| FROM trades") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + expect(getSuggestions(result).length).toBeGreaterThan(0) + }) + + it("range starts at cursor position for operator words", () => { + const { model, position } = cursorInput("SELECT price::| FROM trades") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + if (items.length > 0) { + const range = items[0].range as IRange + expect(range.startColumn).toBe(position.column) + } + }) + + it("uppercases data type suggestions", () => { + const { model, position } = cursorInput("SELECT price::| FROM trades") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const dataTypeItem = items.find( + (s) => s.kind === CompletionItemKind.TypeParameter, + ) + if (dataTypeItem) { + const label = + typeof dataTypeItem.label === "string" + ? dataTypeItem.label + : dataTypeItem.label.label + expect(label).toBe(label.toUpperCase()) + } + }) + }) + + describe("dot-qualified references", () => { + it("suggests columns after table.prefix", () => { + const { model, position } = cursorInput("SELECT trades.| FROM trades") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels).toContain("symbol") + expect(labels).toContain("price") + expect(labels).toContain("ts") + }) + + it("filters columns after partial typing", () => { + const { model, position } = cursorInput("SELECT trades.pr| FROM trades") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels).toContain("price") + }) + }) + + describe("quoted identifier support", () => { + it("suggests table names inside double quotes", () => { + const { model, position } = cursorInput('SELECT * FROM "q|"') + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels).toContain("quoted-table.1") + }) + + it("appends closing quote and space to insertText inside quotes", () => { + const { model, position } = cursorInput('SELECT * FROM "|"') + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const tableItem = items.find((s) => { + const label = typeof s.label === "string" ? s.label : s.label.label + return label === "trades" + }) + expect(tableItem).toBeDefined() + expect(tableItem!.insertText.endsWith('" ')).toBe(true) + }) + + it("suggests inside unclosed double quotes", () => { + const { model, position } = cursorInput('SELECT * FROM "|') + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels.length).toBeGreaterThan(0) + expect(labels).toContain("trades") + }) + + it("range covers content between quotes and closing quote", () => { + const text = 'SELECT * FROM "tra"' + const cursorIndex = text.indexOf("tra") + 2 + const model = createModel(text) + const position = model.getPositionAt(cursorIndex) + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + if (items.length > 0) { + const range = items[0].range as IRange + const openQuote = text.indexOf('"') + expect(range.startColumn).toBe(openQuote + 2) + const closingQuote = text.lastIndexOf('"') + expect(range.endColumn).toBe(closingQuote + 2) + } + }) + + it("does not trigger inside single-quoted strings", () => { + const { model, position } = cursorInput("SELECT 'tra|' FROM trades") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const insertTexts = items.map((s) => s.insertText) + expect(insertTexts.every((t) => !t.endsWith('" '))).toBe(true) + }) + + it("range ends at cursor when quote is unclosed", () => { + const text = 'SELECT * FROM "tra' + const model = createModel(text) + const position = model.getPositionAt(text.length) + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + if (items.length > 0) { + const range = items[0].range as IRange + expect(range.endColumn).toBe(position.column) + } + }) + + it("filters out the already-typed text inside quotes", () => { + const { model, position } = cursorInput('SELECT * FROM "trades|"') + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels).not.toContain("trades") + }) + }) + + describe("sortText and priority", () => { + it("prefixes sortText with priority code", () => { + const { model, position } = cursorInput("SELECT * FROM |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + expect(items.length).toBeGreaterThan(0) + for (const item of items) { + expect(item.sortText).toMatch(/^[1-5]/) + } + }) + }) + + describe("CompletionItemLabel with detail/description", () => { + it("uses CompletionItemLabel when detail is present", () => { + const { model, position } = cursorInput("SELECT | FROM trades") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const columnItem = items.find((s) => s.kind === CompletionItemKind.Field) + if (columnItem) { + expect(typeof columnItem.label).toBe("object") + const label = columnItem.label as languages.CompletionItemLabel + expect(label.label).toBeDefined() + } + }) + + it("uses plain string label when no detail or description", () => { + const { model, position } = cursorInput("SELECT * FROM trades |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const keywordItem = items.find( + (s) => s.kind === CompletionItemKind.Keyword, + ) + if (keywordItem) { + expect(typeof keywordItem.label).toBe("string") + } + }) + }) + + describe("completion item kinds", () => { + it("maps table suggestions to Class kind", () => { + const { model, position } = cursorInput("SELECT * FROM |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const tableItem = items.find((s) => { + const label = typeof s.label === "string" ? s.label : s.label.label + return label === "trades" + }) + expect(tableItem?.kind).toBe(CompletionItemKind.Class) + }) + + it("maps keyword suggestions to Keyword kind", () => { + const { model, position } = cursorInput("SELECT * FROM trades |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const whereItem = items.find((s) => { + const label = typeof s.label === "string" ? s.label : s.label.label + return label === "WHERE" + }) + expect(whereItem?.kind).toBe(CompletionItemKind.Keyword) + }) + + it("maps column suggestions to Field kind", () => { + const { model, position } = cursorInput("SELECT | FROM trades") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const colItem = items.find((s) => { + const label = typeof s.label === "string" ? s.label : s.label.label + return label === "symbol" + }) + expect(colItem?.kind).toBe(CompletionItemKind.Field) + }) + }) + + describe("incomplete flag", () => { + it("marks result as incomplete", () => { + const { model, position } = cursorInput("SELECT * FROM |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + expect(result?.incomplete).toBe(true) + }) + }) + + describe("range calculation", () => { + it("range starts at word start for partial typing", () => { + const { model, position } = cursorInput("SELECT * FROM tra|") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + if (items.length > 0) { + const range = items[0].range as IRange + expect(range.startColumn).toBe(15) + expect(range.endColumn).toBe(18) + } + }) + + it("range covers only after dot for qualified refs", () => { + const { model, position } = cursorInput("SELECT trades.pr| FROM trades") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + if (items.length > 0) { + const range = items[0].range as IRange + const dotPos = "SELECT trades.".length + expect(range.startColumn).toBe(dotPos + 1) + } + }) + }) + + describe("trigger characters", () => { + it("includes letters, space, dot, quote, colon, and open paren", () => { + const triggerChars = provider.triggerCharacters! + expect(triggerChars).toContain(" ") + expect(triggerChars).toContain(".") + expect(triggerChars).toContain('"') + expect(triggerChars).toContain(":") + expect(triggerChars).toContain("(") + expect(triggerChars).toContain("a") + expect(triggerChars).toContain("Z") + }) + }) + + describe("empty schema", () => { + it("works with no tables or columns", () => { + const emptyProvider = wrapProvider(createSchemaCompletionProvider([], {})) + const { model, position } = cursorInput("SELECT |") + const result = emptyProvider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels.length).toBeGreaterThan(0) + }) + }) + + describe("edge cases", () => { + it("handles cursor at the very beginning", () => { + const { model, position } = cursorInput("|SELECT * FROM trades") + const result = provider.provideCompletionItems(model, position) + expect(result).toBeNull() + }) + + it("handles empty text", () => { + const { model, position } = cursorInput("|") + const result = provider.provideCompletionItems(model, position) + expect(result).toBeNull() + }) + + it("handles cursor right after semicolon with no following tokens", () => { + const { model, position } = cursorInput("SELECT * FROM trades;|") + const result = provider.provideCompletionItems(model, position) + expect(result).toBeNull() + }) + + it("works with multiple line breaks", () => { + const { model, position } = cursorInput("SELECT\n *\nFROM\n |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels).toContain("trades") + }) + + it("handles cursor in whitespace after a keyword", () => { + const { model, position } = cursorInput("SELECT |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + expect(getSuggestions(result).length).toBeGreaterThan(0) + }) + + it("handles three statements with cursor in the middle one", () => { + const { model, position } = cursorInput( + "SELECT 1; SELECT * FROM |; SELECT 2", + ) + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels).toContain("trades") + }) + + it("handles comment between semicolons", () => { + const { model, position } = cursorInput( + "SELECT 1; -- comment\nSELECT * FROM |", + ) + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const labels = getLabels(result) + expect(labels).toContain("trades") + }) + + it("does not suggest inside block comment spanning multiple lines", () => { + const { model, position } = cursorInput( + "SELECT * FROM /*\n | \n*/ trades", + ) + const result = provider.provideCompletionItems(model, position) + expect(result).toBeNull() + }) + + it("uses default empty arrays when called with no arguments", () => { + const defaultProvider = wrapProvider(createSchemaCompletionProvider()) + const { model, position } = cursorInput("SELECT |") + const result = defaultProvider.provideCompletionItems( + model, + position, + ) as CompletionResult + expect(getSuggestions(result).length).toBeGreaterThan(0) + }) + }) + + describe("auto-quoting identifiers with special characters", () => { + it("wraps table names with special chars in double quotes", () => { + const { model, position } = cursorInput("SELECT * FROM |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const specialItem = items.find((s) => { + const label = typeof s.label === "string" ? s.label : s.label.label + return label === "quoted-table.1" + }) + expect(specialItem).toBeDefined() + expect(specialItem!.insertText).toBe('"quoted-table.1" ') + }) + + it("wraps table names with spaces in double quotes", () => { + const { model, position } = cursorInput("SELECT * FROM |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const spaceItem = items.find((s) => { + const label = typeof s.label === "string" ? s.label : s.label.label + return label === "my table" + }) + expect(spaceItem).toBeDefined() + expect(spaceItem!.insertText).toBe('"my table" ') + }) + + it("does not quote normal identifiers", () => { + const { model, position } = cursorInput("SELECT * FROM |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const tradesItem = items.find((s) => { + const label = typeof s.label === "string" ? s.label : s.label.label + return label === "trades" + }) + expect(tradesItem).toBeDefined() + expect(tradesItem!.insertText).toBe("trades ") + }) + + it("does not double-quote when already inside a quoted identifier", () => { + const { model, position } = cursorInput('SELECT * FROM "|"') + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const specialItem = items.find((s) => { + const label = typeof s.label === "string" ? s.label : s.label.label + return label === "quoted-table.1" + }) + expect(specialItem).toBeDefined() + // Inside quotes: just the name + closing quote + space, no extra wrapping + expect(specialItem!.insertText).toBe('quoted-table.1" ') + }) + + it("does not quote keywords", () => { + const { model, position } = cursorInput("SELECT * FROM trades |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const whereItem = items.find((s) => { + const label = typeof s.label === "string" ? s.label : s.label.label + return label === "WHERE" + }) + expect(whereItem).toBeDefined() + expect(whereItem!.insertText).toBe("WHERE ") + }) + + it("quotes identifiers starting with a digit", () => { + const { model, position } = cursorInput("SELECT * FROM |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const numericItem = items.find((s) => { + const label = typeof s.label === "string" ? s.label : s.label.label + return label === "123numeric" + }) + expect(numericItem).toBeDefined() + expect(numericItem!.insertText).toBe('"123numeric" ') + }) + + it("does not quote identifiers with only valid chars (underscore, dollar, alphanumeric)", () => { + const { model, position } = cursorInput("SELECT * FROM |") + const result = provider.provideCompletionItems( + model, + position, + ) as CompletionResult + const items = getSuggestions(result) + const validItem = items.find((s) => { + const label = typeof s.label === "string" ? s.label : s.label.label + return label === "_valid$name" + }) + expect(validItem).toBeDefined() + expect(validItem!.insertText).toBe("_valid$name ") + }) + }) +}) diff --git a/src/scenes/Editor/Monaco/questdb-sql/conf.ts b/src/scenes/Editor/Monaco/questdb-sql/conf.ts index 5c9227857..9ec2b2e16 100644 --- a/src/scenes/Editor/Monaco/questdb-sql/conf.ts +++ b/src/scenes/Editor/Monaco/questdb-sql/conf.ts @@ -7,7 +7,7 @@ export const conf: languages.LanguageConfiguration = { * An additional example is a "bad integer" error, i.e. (20000) - needs brackets to be allowed as well. */ wordPattern: - /(-?\d*\.\d\w*)|(::|:=|<<=|>>=|!=|<>|<=|>=|\|\||[-+*/%~<>^|&=!]|\b(?:not|and|or|in|between|within|like|ilike)\b|[^`~!@#$%^&*\-+[{\]}\\|;:",<>/?\s]+)/g, + /("(?:[^"]|"")*")|('(?:[^']|'')*')|(-?\d*\.\d\w*)|(::|:=|<<=|>>=|!=|<>|<=|>=|\|\||[-+*/%~<>^|&=!]|\b(?:not|and|or|in|between|within|like|ilike)\b|[^`~!@#$%^&*()\-+[{\]}\\|;:",<>/?\s]+)/g, comments: { lineComment: "--", blockComment: ["/*", "*/"], diff --git a/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts b/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts index 37179125e..8f624040f 100644 --- a/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts +++ b/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts @@ -1,175 +1,274 @@ -import { Table, uniq, InformationSchemaColumn } from "../../../../utils" -import type { editor, languages } from "monaco-editor" -import { CompletionItemPriority } from "./types" -import { findMatches, getQueryFromCursor } from "../utils" -import { getTableCompletions } from "./getTableCompletions" -import { getColumnCompletions } from "./getColumnCompletions" -import { getLanguageCompletions } from "./getLanguageCompletions" - -const trimQuotesFromTableName = (tableName: string) => { - return tableName.replace(/(^")|("$)/g, "") +import { Table, InformationSchemaColumn } from "../../../../utils" +import type { languages, IRange } from "monaco-editor" +import { CompletionItemKind, CompletionItemPriority } from "./types" +import { + createAutocompleteProvider, + SuggestionKind, + SuggestionPriority, + tokenize, + type SchemaInfo, + type Suggestion, +} from "@questdb/sql-parser" +import { isCursorInComment, isCursorInQuotedIdentifier } from "../utils" + +/** + * Map parser's SuggestionKind to Monaco's CompletionItemKind + */ +const KIND_MAP: Record = { + [SuggestionKind.Keyword]: CompletionItemKind.Keyword, + [SuggestionKind.Function]: CompletionItemKind.Function, + [SuggestionKind.Table]: CompletionItemKind.Class, + [SuggestionKind.Column]: CompletionItemKind.Field, + [SuggestionKind.Operator]: CompletionItemKind.Operator, + [SuggestionKind.DataType]: CompletionItemKind.TypeParameter, +} + +/** + * Map parser's SuggestionPriority to Monaco's sortText + */ +const PRIORITY_MAP: Record = { + [SuggestionPriority.High]: CompletionItemPriority.High, + [SuggestionPriority.Medium]: CompletionItemPriority.Medium, + [SuggestionPriority.MediumLow]: CompletionItemPriority.MediumLow, + [SuggestionPriority.Low]: CompletionItemPriority.Low, +} + +/** + * Convert UI schema format to parser's SchemaInfo format + */ +const convertToSchemaInfo = ( + tables: Table[], + informationSchemaColumns: Record, +): SchemaInfo => ({ + tables: tables.map((t) => ({ + name: t.table_name, + designatedTimestamp: t.designatedTimestamp, + })), + columns: Object.fromEntries( + Object.entries(informationSchemaColumns).map(([tableName, cols]) => [ + tableName.toLowerCase(), + cols.map((c) => ({ + name: c.column_name, + type: c.data_type, + })), + ]), + ), +}) + +const QUOTABLE_KINDS = new Set([SuggestionKind.Table, SuggestionKind.Column]) + +// Standalone identifiers must start with a letter or '_', followed by letters, digits, '_' or '$'. +function needsQuoting(name: string): boolean { + return !/^[a-zA-Z_][a-zA-Z0-9_$]*$/.test(name) } -const isInColumnListing = (text: string) => - text.match( - /(?:,$|,\s$|\b(?:SELECT|UPDATE|COLUMN|ON|JOIN|BY|WHERE|DISTINCT)\s$)/gim, - ) +const UPPERCASE_KINDS = new Set([ + SuggestionKind.Keyword, + SuggestionKind.Operator, + SuggestionKind.DataType, +]) + +const toCompletionItem = ( + suggestion: Suggestion, + range: languages.CompletionItem["range"], + isInsideQuotedIdentifier?: boolean, +): languages.CompletionItem => { + const shouldUppercase = UPPERCASE_KINDS.has(suggestion.kind) + const label = shouldUppercase + ? suggestion.label.toUpperCase() + : suggestion.label + const insertText = shouldUppercase + ? suggestion.insertText.toUpperCase() + : suggestion.insertText + + const isFunction = suggestion.kind === SuggestionKind.Function + const isQuotable = QUOTABLE_KINDS.has(suggestion.kind) + + // When outside quotes and the identifier contains special characters, + // wrap it in double quotes so the resulting SQL is valid. + const shouldAutoQuote = + !isInsideQuotedIdentifier && isQuotable && needsQuoting(insertText) + + const quotedInsertText = shouldAutoQuote ? '"' + insertText + '"' : insertText + + // Inside a quoted identifier, the range covers content + closing quote, + // so we append '" ' to close the identifier and add a trailing space. + const suffix = isFunction + ? "($0)" + : isInsideQuotedIdentifier + ? '"' + " " + : " " + + return { + label: + suggestion.detail != null || suggestion.description != null + ? { + label, + detail: suggestion.detail, + description: suggestion.description, + } + : label, + kind: KIND_MAP[suggestion.kind], + insertText: quotedInsertText + suffix, + insertTextRules: isFunction ? 4 : undefined, // CompletionItemInsertTextRule.InsertAsSnippet + filterText: suggestion.filterText, + sortText: PRIORITY_MAP[suggestion.priority] + label.toLowerCase(), + range, + command: isFunction + ? undefined + : { + id: "editor.action.triggerSuggest", + title: "Re-trigger suggestions", + }, + } +} export const createSchemaCompletionProvider = ( - editor: editor.IStandaloneCodeEditor, tables: Table[] = [], informationSchemaColumns: Record = {}, ) => { + // Convert UI schema to parser format and create provider + const schema = convertToSchemaInfo(tables, informationSchemaColumns) + const autocompleteProvider = createAutocompleteProvider(schema) + const completionProvider: languages.CompletionItemProvider = { triggerCharacters: - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\n ."'.split(""), + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz .":('.split(""), + provideCompletionItems(model, position) { const word = model.getWordUntilPosition(position) + const cursorOffset = model.getOffsetAt(position) + const fullText = model.getValue() - const queryAtCursor = getQueryFromCursor(editor) + // Suppress suggestions inside comments (the parser handles strings itself) + if (isCursorInComment(fullText, cursorOffset)) { + return null + } - // get text value in the current line - const textInLine = model.getValueInRange({ - startLineNumber: position.lineNumber, - startColumn: 1, - endLineNumber: position.lineNumber, - endColumn: position.column, - }) + const charBeforeCursor = + cursorOffset > 0 ? fullText[cursorOffset - 1] : "" + if (charBeforeCursor === "(") { + return null + } - let tableContext: string[] = [] + // Extract the current SQL statement for autocomplete by finding the + // nearest semicolon before the cursor. This is more robust than using + // the parser's statement splitting (getQueryFromCursor), which can + // break incomplete SQL into separate statements — e.g., "select * F" + // gets split into "select *" and "F", losing context for autocomplete. + const tokens = tokenize(fullText).tokens - const isWhitespaceOnly = /^\s*$/.test(textInLine) - const isLineComment = /(-- |--|\/\/ |\/\/)$/gim.test(textInLine) + let queryStartOffset = 0 + let queryEndOffset = fullText.length + for (const token of tokens) { + const tokenEnd = token.endOffset ?? token.startOffset + if (token.tokenType.name === "Semicolon") { + if (tokenEnd < cursorOffset) { + queryStartOffset = tokenEnd + 1 + } else if ( + token.startOffset >= cursorOffset && + queryEndOffset === fullText.length + ) { + queryEndOffset = token.startOffset + } + } + } - if (isWhitespaceOnly || isLineComment) { + // If there are no tokens between the query start and the cursor, + // the cursor is in dead space (whitespace/comments between statements). + // Don't suggest anything. + const hasTokensBeforeCursor = tokens.some( + (t) => + t.startOffset >= queryStartOffset && t.startOffset < cursorOffset, + ) + if (!hasTokensBeforeCursor) { return null } - if (queryAtCursor) { - const matches = findMatches(model, queryAtCursor.query) - if (matches.length > 0) { - const cursorMatch = matches.find( - (m) => m.range.startLineNumber === queryAtCursor.row + 1, - ) - - const fromMatch = queryAtCursor.query.match(/(?<=FROM\s)([^ )]+)/gim) - const joinMatch = queryAtCursor.query.match(/(JOIN)\s+([^ ]+)/i) - const alterTableMatch = queryAtCursor.query.match( - /(ALTER TABLE)\s+([^ ]+)/i, - ) - if (fromMatch) { - tableContext = uniq(fromMatch) - } else if (alterTableMatch && alterTableMatch[2]) { - tableContext.push(alterTableMatch[2]) - } - if (joinMatch && joinMatch[2]) { - tableContext.push(joinMatch[2]) - } + // Pass the full statement (including text after cursor) so the parser + // can detect when the cursor is inside a string literal or comment. + const query = fullText.substring(queryStartOffset, queryEndOffset) - tableContext = tableContext.map(trimQuotesFromTableName) + const openQuoteOffset = isCursorInQuotedIdentifier( + fullText, + queryStartOffset, + cursorOffset, + ) + const isInsideQuotedIdentifier = openQuoteOffset >= 0 - const textUntilPosition = model.getValueInRange({ - startLineNumber: cursorMatch?.range.startLineNumber ?? 1, - startColumn: cursorMatch?.range.startColumn ?? 1, - endLineNumber: position.lineNumber, - endColumn: word.startColumn, - }) + // When inside a quoted identifier, the parser suppresses suggestions. + // Work around this by positioning the cursor at the opening " so the + // parser sees e.g. "SELECT * FROM" and returns table suggestions. + const relativeCursorOffset = isInsideQuotedIdentifier + ? openQuoteOffset - queryStartOffset + : cursorOffset - queryStartOffset - const range = { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: word.startColumn, - endColumn: word.endColumn, - } + const suggestions = autocompleteProvider.getSuggestions( + query, + relativeCursorOffset, + ) - const nextChar = model.getValueInRange({ - startLineNumber: position.lineNumber, - startColumn: word.endColumn, - endLineNumber: position.lineNumber, - endColumn: word.endColumn + 1, - }) - - const openQuote = textUntilPosition.substr(-1) === '"' - const nextCharQuote = nextChar == '"' - - if ( - /(FROM|INTO|(ALTER|BACKUP|DROP|REINDEX|RENAME|TRUNCATE|VACUUM) TABLE|JOIN|UPDATE)\s$/gim.test( - textUntilPosition, - ) || - (/'$/gim.test(textUntilPosition) && - !textUntilPosition.endsWith("= '")) - ) { - return { - suggestions: getTableCompletions({ - tables, - range, - priority: CompletionItemPriority.High, - openQuote, - nextCharQuote, - }), - } - } + // When the "word" at cursor is an operator (e.g. :: from type cast), + // don't replace it — insert after it instead. + const isOperatorWord = + !isInsideQuotedIdentifier && + word.word.length > 0 && + !/[a-zA-Z0-9_]/.test(word.word[0]) - if ( - /(?:(SELECT|UPDATE).*?(?:(?:,(?:COLUMN )?)|(?:ALTER COLUMN ))?(?:WHERE )?(?: BY )?(?: ON )?(?: SET )?$|ALTER COLUMN )/gim.test( - textUntilPosition, - ) && - !isWhitespaceOnly - ) { - if (tableContext.length > 0) { - const withTableName = - textUntilPosition.match(/\sON\s/gim) !== null - return { - suggestions: [ - ...(isInColumnListing(textUntilPosition) - ? getColumnCompletions({ - columns: tableContext.reduce( - (acc, tableName) => [ - ...acc, - ...(informationSchemaColumns[tableName] ?? []), - ], - [] as InformationSchemaColumn[], - ), - range, - withTableName, - priority: CompletionItemPriority.High, - }) - : []), - ...getLanguageCompletions(range), - ], - } - } else if (isInColumnListing(textUntilPosition)) { - return { - suggestions: [ - ...getColumnCompletions({ - columns: Object.values(informationSchemaColumns).reduce( - (acc, columns) => [...acc, ...columns], - [] as InformationSchemaColumn[], - ), - range, - withTableName: false, - priority: CompletionItemPriority.High, - }), - ], - } - } - } + // When the word contains a dot (qualified reference like "t." or "t.col"), + // only replace after the last dot. The prefix before the dot is the + // table/alias qualifier and should be kept. Without this, Monaco filters + // suggestions against "t." and nothing matches. + const dotIndex = word.word.lastIndexOf(".") - if (word.word) { - return { - suggestions: [ - ...getTableCompletions({ - tables, - range, - priority: CompletionItemPriority.High, - openQuote, - nextCharQuote, - }), - ...getLanguageCompletions(range), - ], - } - } + let range: IRange + + if (isInsideQuotedIdentifier) { + // Range covers content between quotes AND the closing quote, + // so we can replace it all and append '" ' (close quote + space). + const contentStart = model.getPositionAt(openQuoteOffset + 1) + const lineContent = model.getLineContent(position.lineNumber) + const closingIdx = lineContent.indexOf('"', position.column - 1) + range = { + startLineNumber: contentStart.lineNumber, + startColumn: contentStart.column, + endLineNumber: position.lineNumber, + // Include the closing " if found, otherwise end at cursor + endColumn: closingIdx >= 0 ? closingIdx + 2 : position.column, } + } else { + const startColumn = isOperatorWord + ? position.column + : dotIndex >= 0 + ? word.startColumn + dotIndex + 1 + : word.startColumn + range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn, + endColumn: word.endColumn, + } + } + + // Filter out suggestions that exactly match the word already typed, + // e.g. don't suggest "FROM" when cursor is right after "FROM". + const currentWord = isInsideQuotedIdentifier + ? fullText.substring(openQuoteOffset + 1, cursorOffset) + : isOperatorWord + ? "" + : dotIndex >= 0 + ? word.word.substring(dotIndex + 1) + : word.word + + const filtered = suggestions.filter( + (s) => s.insertText.toUpperCase() !== currentWord.toUpperCase(), + ) + + return { + incomplete: true, + suggestions: filtered.map((s) => + toCompletionItem(s, range, isInsideQuotedIdentifier), + ), } }, } diff --git a/src/scenes/Editor/Monaco/questdb-sql/getLanguageCompletions.ts b/src/scenes/Editor/Monaco/questdb-sql/getLanguageCompletions.ts index d18ba4c0f..5291aff12 100644 --- a/src/scenes/Editor/Monaco/questdb-sql/getLanguageCompletions.ts +++ b/src/scenes/Editor/Monaco/questdb-sql/getLanguageCompletions.ts @@ -1,5 +1,4 @@ -import { operators } from "./operators" -import { dataTypes, functions, keywords } from "@questdb/sql-grammar" +import { dataTypes, functions, keywords, operators } from "@questdb/sql-parser" import { CompletionItemKind } from "./types" import type { IRange } from "monaco-editor" diff --git a/src/scenes/Editor/Monaco/questdb-sql/language.ts b/src/scenes/Editor/Monaco/questdb-sql/language.ts index 01654a870..2fbc2608c 100644 --- a/src/scenes/Editor/Monaco/questdb-sql/language.ts +++ b/src/scenes/Editor/Monaco/questdb-sql/language.ts @@ -1,6 +1,11 @@ -import { operators } from "./operators" import type { languages } from "monaco-editor" -import { constants, dataTypes, functions, keywords } from "@questdb/sql-grammar" +import { + constants, + dataTypes, + functions, + keywords, + operators, +} from "@questdb/sql-parser" import { escapeRegExpCharacters } from "../../../../utils/textSearch" const functionPattern = new RegExp( @@ -121,7 +126,7 @@ export const language: languages.IMonarchLanguage = { ], ], numbers: [ - [/\b(\d+)([utsmhdwmy])\b/i, "number"], // sampling rate + [/[+-]?\d+[utsmhdwyn]\b/i, "number"], // sampling rate/ horizons [/([+-]?\d+\.\d+[eE]?[+-]?\d+)/, "number"], // floating point number [/0[xX][0-9a-fA-F]*/, "number"], // hex integers [/[+-]?\d+((_)?\d+)*[Ll]?/, "number"], // integers diff --git a/src/scenes/Editor/Monaco/questdb-sql/monacoPolyfill.ts b/src/scenes/Editor/Monaco/questdb-sql/monacoPolyfill.ts new file mode 100644 index 000000000..b8beda658 --- /dev/null +++ b/src/scenes/Editor/Monaco/questdb-sql/monacoPolyfill.ts @@ -0,0 +1,84 @@ +/** + * Minimal browser globals polyfill so that Monaco editor modules + * can be imported in a Node/vitest environment. + * + * Must be loaded via vitest `setupFiles` BEFORE any monaco-editor import. + */ + +if (typeof window === "undefined") { + const g = globalThis as Record + g.window = globalThis + g.self = globalThis + + const define = (key: string, value: unknown) => + Object.defineProperty(globalThis, key, { + value, + writable: true, + configurable: true, + }) + + define("navigator", { userAgent: "", language: "en" }) + define("location", { + href: "http://localhost", + origin: "http://localhost", + protocol: "http:", + host: "localhost", + hostname: "localhost", + pathname: "/", + search: "", + hash: "", + }) + define("document", { + createEvent: () => ({}), + addEventListener: () => {}, + removeEventListener: () => {}, + querySelector: () => null, + querySelectorAll: () => [], + documentElement: { style: {} }, + body: { appendChild: () => {}, removeChild: () => {} }, + head: { appendChild: () => {}, removeChild: () => {} }, + createElement: (tag: string) => ({ + tagName: tag, + style: {}, + setAttribute: () => {}, + getAttribute: () => null, + addEventListener: () => {}, + removeEventListener: () => {}, + appendChild: () => {}, + classList: { add: () => {}, remove: () => {} }, + }), + createTextNode: () => ({}), + createDocumentFragment: () => ({ appendChild: () => {} }), + }) + define("matchMedia", () => ({ + matches: false, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) + define("requestAnimationFrame", (cb: () => void) => setTimeout(cb, 0)) + define("cancelAnimationFrame", (id: ReturnType) => + clearTimeout(id), + ) + define("getComputedStyle", () => ({ getPropertyValue: () => "" })) + define( + "ResizeObserver", + class { + observe() {} + disconnect() {} + unobserve() {} + }, + ) + define( + "MutationObserver", + class { + observe() {} + disconnect() {} + takeRecords() { + return [] + } + }, + ) +} diff --git a/src/scenes/Editor/Monaco/questdb-sql/operators.ts b/src/scenes/Editor/Monaco/questdb-sql/operators.ts deleted file mode 100644 index 05e7c32ec..000000000 --- a/src/scenes/Editor/Monaco/questdb-sql/operators.ts +++ /dev/null @@ -1,37 +0,0 @@ -export const operators = [ - // Logical - "ALL", - "AND", - "ANY", - "BETWEEN", - "EXISTS", - "IN", - "LIKE", - "NOT", - "OR", - "SOME", - // Set - "EXCEPT", - "INTERSECT", - "UNION", - // Join - "APPLY", - "CROSS", - "FULL", - "INNER", - "JOIN", - "LEFT", - "OUTER", - "RIGHT", - // Predicates - "CONTAINS", - "FREETEXT", - "IS", - "NULL", - // Pivoting - "PIVOT", - "UNPIVOT", - // Merging - "MATCHED", - "EXPLAIN", -] diff --git a/src/scenes/Editor/Monaco/utils.test.ts b/src/scenes/Editor/Monaco/utils.test.ts new file mode 100644 index 000000000..02883a90f --- /dev/null +++ b/src/scenes/Editor/Monaco/utils.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect } from "vitest" +import { isCursorInComment, isCursorInQuotedIdentifier } from "./utils" + +describe("isCursorInComment", () => { + it("returns false when cursor is in normal SQL", () => { + const text = "SELECT * FROM table" + expect(isCursorInComment(text, 7)).toBe(false) + }) + + it("detects cursor inside a line comment", () => { + const text = "SELECT * -- this is a comment\nFROM table" + // cursor inside "this is a comment" + expect(isCursorInComment(text, 15)).toBe(true) + }) + + it("returns false after a line comment ends (next line)", () => { + const text = "SELECT * -- comment\nFROM table" + // cursor at "FROM" + expect(isCursorInComment(text, 24)).toBe(false) + }) + + it("detects cursor inside a block comment", () => { + const text = "SELECT /* block comment */ * FROM table" + expect(isCursorInComment(text, 15)).toBe(true) + }) + + it("returns false after a block comment closes", () => { + const text = "SELECT /* block */ * FROM table" + // cursor at "* FROM" + expect(isCursorInComment(text, 20)).toBe(false) + }) + + it("handles multi-line block comment", () => { + const text = "SELECT /*\n multi\n line\n*/ * FROM table" + // cursor inside the block comment + expect(isCursorInComment(text, 15)).toBe(true) + // cursor after the block comment + expect(isCursorInComment(text, 30)).toBe(false) + }) + + it("does not treat -- inside a single-quoted string as a comment", () => { + const text = "SELECT '--not a comment' FROM table" + expect(isCursorInComment(text, 12)).toBe(false) + }) + + it("does not treat -- inside a double-quoted identifier as a comment", () => { + const text = 'SELECT * FROM "my--table"' + expect(isCursorInComment(text, 18)).toBe(false) + }) + + it("detects comment after a quoted string", () => { + const text = "SELECT 'value' -- comment here" + expect(isCursorInComment(text, 25)).toBe(true) + }) + + it("handles cursor at the very start of a line comment", () => { + const text = "SELECT * --comment" + // cursor right at the first "-" + expect(isCursorInComment(text, 9)).toBe(false) + // cursor right after "--" + expect(isCursorInComment(text, 11)).toBe(true) + }) + + it("handles cursor at the start/end of block comment delimiters", () => { + const text = "SELECT /* comment */ FROM" + // cursor at the "/" of "/*" + expect(isCursorInComment(text, 7)).toBe(false) + // cursor right after "/*" + expect(isCursorInComment(text, 9)).toBe(true) + // cursor right after "*/" + expect(isCursorInComment(text, 20)).toBe(false) + }) + + it("handles multiple comments in sequence", () => { + const text = "SELECT -- first\n* /* second */ FROM -- third\ntable" + // inside first comment + expect(isCursorInComment(text, 12)).toBe(true) + // between first and second comment (at "*") + expect(isCursorInComment(text, 17)).toBe(false) + // inside second comment + expect(isCursorInComment(text, 22)).toBe(true) + // after second comment, at "FROM" + expect(isCursorInComment(text, 35)).toBe(false) + // inside third comment + expect(isCursorInComment(text, 42)).toBe(true) + }) + + it("handles empty text", () => { + expect(isCursorInComment("", 0)).toBe(false) + }) + + it("handles unclosed single-quoted string before a real comment", () => { + // An unclosed string could swallow a subsequent comment marker + // if quote handling scans past cursorOffset. + const text = "SELECT 'unclosed -- real comment?" + // cursor inside -- area, which is inside the unclosed string + // so it should NOT be a comment + const cursor = text.indexOf("-- real") + 3 + expect(isCursorInComment(text, cursor)).toBe(false) + }) + + it("handles unclosed double-quoted identifier before a real comment", () => { + const text = 'SELECT "unclosed -- real comment?' + const cursor = text.indexOf("-- real") + 3 + // -- is inside the unclosed identifier, not a comment + expect(isCursorInComment(text, cursor)).toBe(false) + }) +}) + +describe("isCursorInQuotedIdentifier", () => { + it("returns -1 when cursor is in normal SQL", () => { + const text = "SELECT * FROM table" + expect(isCursorInQuotedIdentifier(text, 0, 7)).toBe(-1) + }) + + it("detects cursor inside a double-quoted identifier", () => { + const text = 'SELECT * FROM "my_table"' + // cursor inside "my_table" + const offset = text.indexOf("my_table") + expect( + isCursorInQuotedIdentifier(text, 0, offset + 3), + ).toBeGreaterThanOrEqual(0) + }) + + it("returns the offset of the opening quote", () => { + const text = 'SELECT * FROM "my_table"' + const openQuote = text.indexOf('"') + const cursor = text.indexOf("my_table") + 3 + expect(isCursorInQuotedIdentifier(text, 0, cursor)).toBe(openQuote) + }) + + it("returns -1 when cursor is after a closed quoted identifier", () => { + const text = 'SELECT * FROM "my_table" WHERE' + const cursor = text.indexOf(" WHERE") + expect(isCursorInQuotedIdentifier(text, 0, cursor)).toBe(-1) + }) + + it("detects cursor inside an unclosed quoted identifier", () => { + const text = 'SELECT * FROM "my_table' + const cursor = text.length + expect(isCursorInQuotedIdentifier(text, 0, cursor)).toBe(text.indexOf('"')) + }) + + it("handles identifier with dashes", () => { + const text = 'SELECT * FROM "quoted-table"' + // cursor after the dash + const cursor = text.indexOf("-") + 1 + expect(isCursorInQuotedIdentifier(text, 0, cursor)).toBe(text.indexOf('"')) + }) + + it("handles identifier with dots", () => { + const text = 'SELECT * FROM "table.with.dots"' + const cursor = text.indexOf("with") + expect(isCursorInQuotedIdentifier(text, 0, cursor)).toBe(text.indexOf('"')) + }) + + it("does not confuse single-quoted strings with double-quoted identifiers", () => { + const text = "SELECT 'hello' FROM table" + // cursor inside 'hello' + const cursor = text.indexOf("hello") + 2 + expect(isCursorInQuotedIdentifier(text, 0, cursor)).toBe(-1) + }) + + it("does not confuse double quote inside single-quoted string", () => { + const text = `SELECT 'has "quotes" inside' FROM table` + // cursor inside the single-quoted string where " appears + const cursor = text.indexOf('"quotes"') + 1 + expect(isCursorInQuotedIdentifier(text, 0, cursor)).toBe(-1) + }) + + it("handles escaped double quotes inside identifier", () => { + const text = 'SELECT * FROM "has""escaped"' + // cursor between the escaped "" + const cursor = text.indexOf('""') + 2 + // should still be inside the identifier (escaped quote doesn't close it) + expect(isCursorInQuotedIdentifier(text, 0, cursor)).toBe(text.indexOf('"')) + }) + + it("handles escaped single quotes inside string (not a quoted identifier)", () => { + const text = "SELECT 'it''s fine' FROM table" + // cursor inside the single-quoted string + const cursor = text.indexOf("s fine") + expect(isCursorInQuotedIdentifier(text, 0, cursor)).toBe(-1) + }) + + it("handles multiple quoted identifiers — cursor in second", () => { + const text = 'SELECT "col1" FROM "my_table"' + // cursor inside "my_table" + const openingQuote = text.indexOf('"', text.indexOf("my_table") - 1) + const cursor = text.indexOf("my_table") + 2 + expect(isCursorInQuotedIdentifier(text, 0, cursor)).toBe(openingQuote) + }) + + it("handles multiple quoted identifiers — cursor after both", () => { + const text = 'SELECT "col1" FROM "my_table" WHERE' + const cursor = text.indexOf(" WHERE") + expect(isCursorInQuotedIdentifier(text, 0, cursor)).toBe(-1) + }) + + it("respects startOffset parameter", () => { + const text = 'SELECT "col1"; SELECT * FROM "tbl"' + // start scanning from the second statement + const secondStart = text.indexOf(";") + 1 + const cursor = text.indexOf("tbl") + 1 + expect(isCursorInQuotedIdentifier(text, secondStart, cursor)).toBe( + text.lastIndexOf('"', cursor - 1), + ) + }) + + it("handles mix of comments and quoted identifiers", () => { + const text = 'SELECT * -- "not an identifier\nFROM "real_id"' + // cursor inside "real_id" — the " inside the comment is skipped + const cursor = text.indexOf("real_id") + 3 + const openingQuote = text.indexOf('"', text.indexOf("FROM")) + expect(isCursorInQuotedIdentifier(text, 0, cursor)).toBe(openingQuote) + }) + + it("works with multi-line quoted identifier", () => { + const text = 'SELECT * FROM "multi\nline"' + const cursor = text.indexOf("line") + expect(isCursorInQuotedIdentifier(text, 0, cursor)).toBe(text.indexOf('"')) + }) + + it("handles empty text", () => { + expect(isCursorInQuotedIdentifier("", 0, 0)).toBe(-1) + }) + + it("cursor right after opening quote", () => { + const text = 'SELECT * FROM "' + expect(isCursorInQuotedIdentifier(text, 0, text.length)).toBe( + text.indexOf('"'), + ) + }) + + it("cursor right at closing quote position", () => { + const text = 'SELECT * FROM "tbl"' + // cursor at the closing " (still inside) + const cursor = text.lastIndexOf('"') + expect(isCursorInQuotedIdentifier(text, 0, cursor)).toBe(text.indexOf('"')) + }) +}) diff --git a/src/scenes/Editor/Monaco/utils.ts b/src/scenes/Editor/Monaco/utils.ts index 45a7a4212..fbbb4eb1d 100644 --- a/src/scenes/Editor/Monaco/utils.ts +++ b/src/scenes/Editor/Monaco/utils.ts @@ -25,6 +25,7 @@ import type { editor, IPosition, IRange } from "monaco-editor" import type { Monaco } from "@monaco-editor/react" import type { ErrorResult } from "../../../utils" import { hashString } from "../../../utils" +import type { ValidateQueryResult } from "../../../utils/questdb" type IStandaloneCodeEditor = editor.IStandaloneCodeEditor @@ -198,7 +199,8 @@ export const getQueriesFromPosition = ( let startCol = start.column let startPos = startCharIndex - 1 let nextSql = null - let inQuote = false + let inSingleQuote = false + let inDoubleQuote = false let singleLineCommentStack: number[] = [] let multiLineCommentStack: number[] = [] let inSingleLineComment = false @@ -231,7 +233,12 @@ export const getQueriesFromPosition = ( switch (char) { case ";": { - if (inQuote || inSingleLineComment || inMultiLineComment) { + if ( + inSingleQuote || + inDoubleQuote || + inSingleLineComment || + inMultiLineComment + ) { column++ break } @@ -297,15 +304,23 @@ export const getQueriesFromPosition = ( } case "'": { - if (!inMultiLineComment && !inSingleLineComment) { - inQuote = !inQuote + if (!inMultiLineComment && !inSingleLineComment && !inDoubleQuote) { + inSingleQuote = !inSingleQuote + } + column++ + break + } + + case '"': { + if (!inMultiLineComment && !inSingleLineComment && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote } column++ break } case "-": { - if (!inMultiLineComment && !inQuote) { + if (!inMultiLineComment && !inSingleQuote && !inDoubleQuote) { singleLineCommentStack.push(i) if (singleLineCommentStack.length === 2) { if (singleLineCommentStack[0] + 1 === singleLineCommentStack[1]) { @@ -326,7 +341,12 @@ export const getQueriesFromPosition = ( } case "/": { - if (!inMultiLineComment && !inSingleLineComment && !inQuote) { + if ( + !inMultiLineComment && + !inSingleLineComment && + !inSingleQuote && + !inDoubleQuote + ) { if (multiLineCommentStack.length === 0) { multiLineCommentStack.push(i) } else { @@ -352,7 +372,12 @@ export const getQueriesFromPosition = ( } case "*": { - if (!inMultiLineComment && !inSingleLineComment) { + if ( + !inMultiLineComment && + !inSingleLineComment && + !inSingleQuote && + !inDoubleQuote + ) { if ( multiLineCommentStack.length === 1 && multiLineCommentStack[0] + 1 === i @@ -1249,6 +1274,176 @@ export const setErrorMarkerForQuery = ( monaco.editor.setModelMarkers(model, QuestDBLanguageName, markers) } +const ValidationOwner = "questdb-validation" +export const JIT_VALIDATION_QUERY_CHAR_CAP = 10_000 + +const validationRefs: Record< + string, + { markers: editor.IMarkerData[]; queryText: string; version: number } +> = {} + +const validationControllers: Record = {} + +export const cancelAllValidationRequests = () => { + Object.keys(validationControllers).forEach((bufferKey) => { + validationControllers[bufferKey]?.abort() + delete validationControllers[bufferKey] + }) +} + +export const clearValidationMarkers = ( + monaco: Monaco | null, + editor: IStandaloneCodeEditor | null, + bufferId?: number, +) => { + const model = editor?.getModel() + if (model && monaco) { + monaco.editor.setModelMarkers(model, ValidationOwner, []) + } + if (bufferId !== undefined) { + const bufferKey = bufferId.toString() + delete validationRefs[bufferKey] + validationControllers[bufferKey]?.abort() + delete validationControllers[bufferKey] + } +} + +export const applyValidationMarkers = ( + monaco: Monaco, + editor: IStandaloneCodeEditor, + bufferId: number, +) => { + const model = editor.getModel() + if (!model) return + + const bufferKey = bufferId.toString() + const cached = validationRefs[bufferKey] + if (cached) { + if (cached.version === model.getVersionId()) { + monaco.editor.setModelMarkers(model, ValidationOwner, cached.markers) + } else { + delete validationRefs[bufferKey] + } + } +} + +export const validateQueryJIT = ( + monaco: Monaco, + editor: IStandaloneCodeEditor, + bufferId: number, + getBufferExecutions: () => Record, + validateQuery: ( + query: string, + signal: AbortSignal, + ) => Promise, +) => { + const model = editor.getModel() + if (!model) return + + const bufferKey = bufferId.toString() + const modelUri = model.uri.toString() + const queryAtCursor = getQueryFromCursor(editor) + + if (!queryAtCursor) { + validationControllers[bufferKey]?.abort() + delete validationControllers[bufferKey] + monaco.editor.setModelMarkers(model, ValidationOwner, []) + delete validationRefs[bufferKey] + return + } + + const queryText = normalizeQueryText(queryAtCursor.query) + const version = model.getVersionId() + + // Skip if already validated this exact query+version + const cached = validationRefs[bufferKey] + if (cached && cached.queryText === queryText && cached.version === version) { + return + } + + if (queryText.length > JIT_VALIDATION_QUERY_CHAR_CAP) { + validationControllers[bufferKey]?.abort() + delete validationControllers[bufferKey] + validationRefs[bufferKey] = { markers: [], queryText, version } + monaco.editor.setModelMarkers(model, ValidationOwner, []) + return + } + + // Skip if execution result already exists for this query + const queryKey = createQueryKeyFromRequest(editor, queryAtCursor) + const bufferExecutions = getBufferExecutions() + if (bufferExecutions[queryKey]) { + validationControllers[bufferKey]?.abort() + delete validationControllers[bufferKey] + monaco.editor.setModelMarkers(model, ValidationOwner, []) + delete validationRefs[bufferKey] + return + } + + // Abort any previous in-flight validation for this buffer + validationControllers[bufferKey]?.abort() + const controller = new AbortController() + validationControllers[bufferKey] = controller + + validateQuery(queryText, controller.signal) + .then((result: ValidateQueryResult) => { + if (validationControllers[bufferKey] !== controller) { + return + } + delete validationControllers[bufferKey] + + const currentModel = editor.getModel() + if ( + !currentModel || + currentModel.uri.toString() !== modelUri || + currentModel.getVersionId() !== version + ) { + return + } + + // Query was executed while validation was in flight — skip + if (getBufferExecutions()[queryKey]) return + + if ("error" in result) { + const errorRange = getErrorRange(editor, queryAtCursor, result.position) + const markers: editor.IMarkerData[] = [] + + if (errorRange) { + markers.push({ + message: result.error, + severity: monaco.MarkerSeverity.Error, + startLineNumber: errorRange.startLineNumber, + endLineNumber: errorRange.endLineNumber, + startColumn: errorRange.startColumn, + endColumn: errorRange.endColumn, + }) + } else { + const errorPos = toTextPosition(queryAtCursor, result.position) + markers.push({ + message: result.error, + severity: monaco.MarkerSeverity.Error, + startLineNumber: errorPos.lineNumber, + endLineNumber: errorPos.lineNumber, + startColumn: errorPos.column, + endColumn: errorPos.column, + }) + } + + validationRefs[bufferKey] = { markers, queryText, version } + monaco.editor.setModelMarkers(currentModel, ValidationOwner, markers) + } else { + delete validationRefs[bufferKey] + monaco.editor.setModelMarkers(currentModel, ValidationOwner, []) + } + }) + .catch(() => { + // Abort or network error — silently ignore + if (validationControllers[bufferKey] === controller) { + delete validationControllers[bufferKey] + } + }) +} + // Creates a QueryKey for schema explanation conversations // Uses DDL hash so same schema = same queryKey = cached conversation export const createSchemaQueryKey = ( @@ -1258,3 +1453,85 @@ export const createSchemaQueryKey = ( const ddlHash = hashString(ddl) return `schema:${tableName}:${ddlHash}@0-0` as QueryKey } + +/** + * Check if cursor is inside a line comment (--) or block comment. + * Skips over string literals and quoted identifiers so quotes + * inside comments don't cause false positives. + */ +export function isCursorInComment(text: string, cursorOffset: number): boolean { + let i = 0 + const end = Math.min(cursorOffset, text.length) + while (i < end) { + const ch = text[i] + const next = text[i + 1] + // Line comment: -- until end of line + if (ch === "-" && next === "-") { + i += 2 + while (i < end && text[i] !== "\n") i++ + if (i >= cursorOffset) return true + continue + } + // Block comment: /* until */ + if (ch === "/" && next === "*") { + i += 2 + while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++ + if (i >= cursorOffset) return true + i += 2 // skip */ + continue + } + // Skip over string literals and quoted identifiers so quotes inside comments don't confuse us + if (ch === "'" || ch === '"') { + i++ + while (i < text.length && text[i] !== ch) i++ + i++ // skip closing quote + continue + } + i++ + } + return false +} + +/** + * Check if cursor is inside a double-quoted identifier (e.g. "my-table"). + * Tracks quote state from `startOffset` to `cursorOffset`, handling + * escaped quotes (""), single-quoted strings, and comments. + * Returns the offset of the opening " if inside, or -1 if not. + */ +export function isCursorInQuotedIdentifier( + text: string, + startOffset: number, + cursorOffset: number, +): number { + if (isCursorInComment(text, cursorOffset)) return -1 + + let inDouble = false + let inSingle = false + let openQuoteOffset = -1 + for (let i = startOffset; i < cursorOffset; i++) { + const ch = text[i] + const next = text[i + 1] + if (inSingle) { + if (ch === "'" && next === "'") i++ + else if (ch === "'") inSingle = false + } else if (inDouble) { + if (ch === '"' && next === '"') i++ + else if (ch === '"') inDouble = false + } else if (ch === "-" && next === "-") { + // Skip line comment + i += 2 + while (i < cursorOffset && text[i] !== "\n") i++ + } else if (ch === "/" && next === "*") { + // Skip block comment + i += 2 + while (i < cursorOffset && !(text[i] === "*" && text[i + 1] === "/")) i++ + i++ // skip past */ + } else if (ch === '"') { + inDouble = true + openQuoteOffset = i + } else if (ch === "'") { + inSingle = true + } + } + return inDouble ? openQuoteOffset : -1 +} diff --git a/src/utils/questdb/client.ts b/src/utils/questdb/client.ts index 017e87e62..45c034ed2 100644 --- a/src/utils/questdb/client.ts +++ b/src/utils/questdb/client.ts @@ -339,11 +339,15 @@ export class Client { } } - async validateQuery(query: string): Promise { + async validateQuery( + query: string, + signal?: AbortSignal, + ): Promise { const response = await fetch( `api/v1/sql/validate?${Client.encodeParams({ query })}`, { headers: this.commonHeaders, + signal, }, ) if (response.ok) { diff --git a/yarn.lock b/yarn.lock index 5e869060a..b5aef22b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1471,6 +1471,48 @@ __metadata: languageName: node linkType: hard +"@chevrotain/cst-dts-gen@npm:11.1.1": + version: 11.1.1 + resolution: "@chevrotain/cst-dts-gen@npm:11.1.1" + dependencies: + "@chevrotain/gast": "npm:11.1.1" + "@chevrotain/types": "npm:11.1.1" + lodash-es: "npm:4.17.23" + checksum: 10/ff69fa978abea4075cbeb8356e1d297c7063091044d4766ea9f7e037c1b0745f19bade1527f08bc7304903e54a80342faab2641004a3c9e7e9b8027c979f1cbf + languageName: node + linkType: hard + +"@chevrotain/gast@npm:11.1.1": + version: 11.1.1 + resolution: "@chevrotain/gast@npm:11.1.1" + dependencies: + "@chevrotain/types": "npm:11.1.1" + lodash-es: "npm:4.17.23" + checksum: 10/7e3e951cdf1f4c90634f75a7f284b521bf01e03580ae725deefeb4178d12c70c4eeb0e44f39938cb061943940d14e5d2a79c355dae1ca7ed26a86cfc16f72e6f + languageName: node + linkType: hard + +"@chevrotain/regexp-to-ast@npm:11.1.1": + version: 11.1.1 + resolution: "@chevrotain/regexp-to-ast@npm:11.1.1" + checksum: 10/6eef0f317107e79b72bcd8af0f05837166200e3b8dec8dd2e235a1b73f190e2461beb86a3824627d392f59ca3bd9eb9ac0361928d9fa36664772bdac1ce857ca + languageName: node + linkType: hard + +"@chevrotain/types@npm:11.1.1": + version: 11.1.1 + resolution: "@chevrotain/types@npm:11.1.1" + checksum: 10/99c3cd6f1f77af9a0929b8ec0324ba08bbba0cac8c59e74c83ded1316b13ead5546881363089ecb45c0921738ba243c0ec15ca9d1e3a45b3aac0b157e7030cb2 + languageName: node + linkType: hard + +"@chevrotain/utils@npm:11.1.1": + version: 11.1.1 + resolution: "@chevrotain/utils@npm:11.1.1" + checksum: 10/c3a0dd1fbd3062b3193d200e043b612347b116383b2940239dd3d52764f14bb50a01deeb84ec944672c17799e94af9245057a9442cf6f3165fd8e0ec2026d814 + languageName: node + linkType: hard + "@colors/colors@npm:1.5.0": version: 1.5.0 resolution: "@colors/colors@npm:1.5.0" @@ -2449,10 +2491,12 @@ __metadata: languageName: node linkType: hard -"@questdb/sql-grammar@npm:1.4.2": - version: 1.4.2 - resolution: "@questdb/sql-grammar@npm:1.4.2" - checksum: 10/55c1dd2af91bebcf31953b110f6922f03145fef6bcbb905d8758d5bb6252d7b1ce25215bb63d1811150476f901da533d6a8fff3d667ded0ec44e5856064165cd +"@questdb/sql-parser@npm:0.1.6": + version: 0.1.6 + resolution: "@questdb/sql-parser@npm:0.1.6" + dependencies: + chevrotain: "npm:^11.1.1" + checksum: 10/ebf9b038cef3d49ef994fa8f8aa0be92ac112e7fb81755b88c5c09b1aa9e44bb6197e0a00a841ae50cfc7d44969aa9151c9eec3b8d76f2f982624f76c458c389 languageName: node linkType: hard @@ -2474,7 +2518,7 @@ __metadata: "@monaco-editor/react": "npm:^4.7.0" "@phosphor-icons/react": "npm:^2.1.10" "@popperjs/core": "npm:2.4.2" - "@questdb/sql-grammar": "npm:1.4.2" + "@questdb/sql-parser": "npm:0.1.6" "@radix-ui/react-alert-dialog": "npm:^1.1.15" "@radix-ui/react-context-menu": "npm:^2.2.16" "@radix-ui/react-dialog": "npm:^1.1.15" @@ -5218,6 +5262,20 @@ __metadata: languageName: node linkType: hard +"chevrotain@npm:^11.1.1": + version: 11.1.1 + resolution: "chevrotain@npm:11.1.1" + dependencies: + "@chevrotain/cst-dts-gen": "npm:11.1.1" + "@chevrotain/gast": "npm:11.1.1" + "@chevrotain/regexp-to-ast": "npm:11.1.1" + "@chevrotain/types": "npm:11.1.1" + "@chevrotain/utils": "npm:11.1.1" + lodash-es: "npm:4.17.23" + checksum: 10/e90972f939b597908843e2b6ed23ed6756b0ce103ee2822c651d0a75cd93913e1e3d6c486315922ce3334a1ea9e1f6d0e62288d1ace8ff8e419bf8613be1b694 + languageName: node + linkType: hard + "chokidar@npm:^3.6.0": version: 3.6.0 resolution: "chokidar@npm:3.6.0" @@ -8445,6 +8503,13 @@ __metadata: languageName: node linkType: hard +"lodash-es@npm:4.17.23": + version: 4.17.23 + resolution: "lodash-es@npm:4.17.23" + checksum: 10/1feae200df22eb0bd93ca86d485e77784b8a9fb1d13e91b66e9baa7a7e5e04be088c12a7e20c2250fc0bd3db1bc0ef0affc7d9e3810b6af2455a3c6bf6dde59e + languageName: node + linkType: hard + "lodash.clamp@npm:^4.0.0": version: 4.0.3 resolution: "lodash.clamp@npm:4.0.3"