From c1ef6a5c14c001b4ac6d8cd62be0b9e0a747e224 Mon Sep 17 00:00:00 2001 From: emrberk Date: Fri, 13 Feb 2026 17:16:13 +0300 Subject: [PATCH 01/19] refactor: use new parser for autocompletion and query detection --- e2e/questdb | 2 +- package.json | 2 +- src/scenes/Editor/Monaco/index.tsx | 29 +- .../createSchemaCompletionProvider.ts | 230 +++++---- .../questdb-sql/getLanguageCompletions.ts | 3 +- .../Editor/Monaco/questdb-sql/language.ts | 9 +- .../Editor/Monaco/questdb-sql/operators.ts | 37 -- src/scenes/Editor/Monaco/utils.test.ts | 437 ++++++++++++++++++ src/scenes/Editor/Monaco/utils.ts | 429 ++++++++++++++++- yarn.lock | 75 ++- 10 files changed, 1067 insertions(+), 186 deletions(-) delete mode 100644 src/scenes/Editor/Monaco/questdb-sql/operators.ts create mode 100644 src/scenes/Editor/Monaco/utils.test.ts diff --git a/e2e/questdb b/e2e/questdb index 5644a8a13..3ebc75733 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 5644a8a13a84075a8e9123631c36f06d684fafdc +Subproject commit 3ebc75733a4ecdaeef2064b93126ec74d78fda7b diff --git a/package.json b/package.json index cce8d8a68..f8b9afeac 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": "file:../questdb-sql-parser", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", diff --git a/src/scenes/Editor/Monaco/index.tsx b/src/scenes/Editor/Monaco/index.tsx index ada3b64e3..a99686ff5 100644 --- a/src/scenes/Editor/Monaco/index.tsx +++ b/src/scenes/Editor/Monaco/index.tsx @@ -70,6 +70,7 @@ import { getQueryStartOffset, getQueriesToRun, getQueriesStartingFromLine, + setParseRange, } from "./utils" import { toast } from "../../../components/Toast" import ButtonBar from "../ButtonBar" @@ -365,6 +366,26 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { void updateBuffer(activeBuffer.id as number, { value }) } + const updateParseRange = () => { + const editorInstance = editorRef.current + const model = editorInstance?.getModel() + if (!editorInstance || !model) return + + const visibleRanges = editorInstance.getVisibleRanges() + if (visibleRanges.length > 0) { + const totalLines = model.getLineCount() + const startLine = Math.max(1, visibleRanges[0].startLineNumber - 150) + const endLine = Math.min(totalLines, visibleRanges[0].endLineNumber + 150) + setParseRange( + model.getOffsetAt({ lineNumber: startLine, column: 1 }), + model.getOffsetAt({ + lineNumber: endLine, + column: model.getLineMaxColumn(endLine), + }), + ) + } + } + // Set the initial line number width in chars based on the number of lines in the active buffer const [lineNumbersMinChars, setLineNumbersMinChars] = useState( (canUseAI ? 7 : 5) + @@ -680,7 +701,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { queries = getAllQueries(editor) } else { const totalLines = model.getLineCount() - const bufferSize = 500 + const bufferSize = 150 const startLine = Math.max(1, visibleLines.startLine - bufferSize) const endLine = Math.min(totalLines, visibleLines.endLine + bufferSize) @@ -907,6 +928,8 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { const model = editor.getModel() if (!model) return + updateParseRange() + const lineCount = model.getLineCount() if (lineCount) { setLineNumbersMinChars( @@ -1121,6 +1144,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { visibleLinesRef.current = newVisibleLines if (startLineDiff > 100 || endLineDiff > 100) { + updateParseRange() if (monacoRef.current && editorRef.current) { applyGlyphsAndLineMarkings( monacoRef.current, @@ -1168,6 +1192,9 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { } } + // Set initial parse range for viewport-scoped parsing + updateParseRange() + // Initial decoration setup applyGlyphsAndLineMarkings(monaco, editor) const queriesToRun = getQueriesToRun(editor, queryOffsetsRef.current ?? []) diff --git a/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts b/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts index 37179125e..f06d3ba2b 100644 --- a/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts +++ b/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts @@ -1,34 +1,100 @@ -import { Table, uniq, InformationSchemaColumn } from "../../../../utils" +import { Table, InformationSchemaColumn } from "../../../../utils" import type { editor, languages } from "monaco-editor" -import { CompletionItemPriority } from "./types" +import { CompletionItemKind, CompletionItemPriority } from "./types" import { findMatches, getQueryFromCursor } from "../utils" -import { getTableCompletions } from "./getTableCompletions" -import { getColumnCompletions } from "./getColumnCompletions" -import { getLanguageCompletions } from "./getLanguageCompletions" +import { + createAutocompleteProvider, + SuggestionKind, + SuggestionPriority, + type SchemaInfo, + type Suggestion, +} from "@questdb/sql-parser" + +/** + * 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, +} -const trimQuotesFromTableName = (tableName: string) => { - return tableName.replace(/(^")|("$)/g, "") +/** + * 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, } -const isInColumnListing = (text: string) => - text.match( - /(?:,$|,\s$|\b(?:SELECT|UPDATE|COLUMN|ON|JOIN|BY|WHERE|DISTINCT)\s$)/gim, - ) +/** + * 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, + })), + ]), + ), +}) + +/** + * Convert parser's Suggestion to Monaco's CompletionItem. + * For columns, uses CompletionItemLabel to show table names inline + * and data type on the right side. + */ +const toCompletionItem = ( + suggestion: Suggestion, + range: languages.CompletionItem["range"], +): languages.CompletionItem => ({ + label: + suggestion.detail != null || suggestion.description != null + ? { + label: suggestion.label, + detail: suggestion.detail, + description: suggestion.description, + } + : suggestion.label, + kind: KIND_MAP[suggestion.kind], + insertText: suggestion.insertText, + filterText: suggestion.filterText, + sortText: PRIORITY_MAP[suggestion.priority], + range, +}) 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\n .":'.split(""), + provideCompletionItems(model, position) { const word = model.getWordUntilPosition(position) - const queryAtCursor = getQueryFromCursor(editor) - - // get text value in the current line + // Get text value in the current line const textInLine = model.getValueInRange({ startLineNumber: position.lineNumber, startColumn: 1, @@ -36,8 +102,6 @@ export const createSchemaCompletionProvider = ( endColumn: position.column, }) - let tableContext: string[] = [] - const isWhitespaceOnly = /^\s*$/.test(textInLine) const isLineComment = /(-- |--|\/\/ |\/\/)$/gim.test(textInLine) @@ -45,6 +109,8 @@ export const createSchemaCompletionProvider = ( return null } + const queryAtCursor = getQueryFromCursor(editor) + if (queryAtCursor) { const matches = findMatches(model, queryAtCursor.query) if (matches.length > 0) { @@ -52,125 +118,39 @@ export const createSchemaCompletionProvider = ( (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]) - } - - tableContext = tableContext.map(trimQuotesFromTableName) - - const textUntilPosition = model.getValueInRange({ - startLineNumber: cursorMatch?.range.startLineNumber ?? 1, - startColumn: cursorMatch?.range.startColumn ?? 1, - endLineNumber: position.lineNumber, - endColumn: word.startColumn, + // Calculate cursor offset within the current query + const queryStartOffset = model.getOffsetAt({ + lineNumber: cursorMatch?.range.startLineNumber ?? 1, + column: cursorMatch?.range.startColumn ?? 1, }) + const cursorOffset = model.getOffsetAt(position) + const relativeCursorOffset = cursorOffset - queryStartOffset + + // Get suggestions from the parser-based provider + const suggestions = autocompleteProvider.getSuggestions( + queryAtCursor.query, + relativeCursorOffset, + ) + // When the "word" at cursor is an operator (e.g. :: from type cast), + // don't replace it — insert after it instead. + const isOperatorWord = + word.word.length > 0 && !/[a-zA-Z0-9_]/.test(word.word[0]) const range = { startLineNumber: position.lineNumber, endLineNumber: position.lineNumber, - startColumn: word.startColumn, + startColumn: isOperatorWord ? position.column : word.startColumn, endColumn: word.endColumn, } - 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, - }), - } - } - - 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, - }), - ], - } - } - } - - if (word.word) { - return { - suggestions: [ - ...getTableCompletions({ - tables, - range, - priority: CompletionItemPriority.High, - openQuote, - nextCharQuote, - }), - ...getLanguageCompletions(range), - ], - } + // Convert parser suggestions to Monaco completion items + return { + suggestions: suggestions.map((s) => toCompletionItem(s, range)), } } } + + return null }, } 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..1f6d57a35 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( 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..9f6290651 --- /dev/null +++ b/src/scenes/Editor/Monaco/utils.test.ts @@ -0,0 +1,437 @@ +import { describe, it, expect } from "vitest" +import { _getQueriesFromText } from "./utils" + +/** + * Helper to extract query text strings from _getQueriesFromText result. + */ +const getQueries = ( + text: string, + cursorRow: number, + cursorCol: number, + startRow?: number, + startCol?: number, +) => { + const result = _getQueriesFromText( + text, + { row: cursorRow, column: cursorCol }, + startRow !== undefined && startCol !== undefined + ? { row: startRow, column: startCol } + : undefined, + ) + return { + stack: result.sqlTextStack.map((item) => + text.substring(item.position, item.limit), + ), + next: result.nextSql + ? text.substring(result.nextSql.position, result.nextSql.limit) + : null, + // Raw items for position verification + rawStack: result.sqlTextStack, + rawNext: result.nextSql, + } +} + +describe("_getQueriesFromText", () => { + describe("basic query identification without semicolons", () => { + it("should identify two queries without semicolons", () => { + const text = "SELECT 1\nSELECT 2" + // Cursor at end of text + const result = getQueries(text, 1, 9) + expect(result.stack).toEqual(["SELECT 1"]) + expect(result.next).toBe("SELECT 2") + }) + + it("should identify two queries with semicolons", () => { + const text = "SELECT 1;\nSELECT 2" + const result = getQueries(text, 1, 9) + expect(result.stack).toEqual(["SELECT 1"]) + expect(result.next).toBe("SELECT 2") + }) + + it("should identify three queries without semicolons", () => { + const text = + "SELECT * FROM trades\nCREATE TABLE t1 AS (SELECT * FROM t2)\nSELECT count() FROM orders" + // Cursor at the very end + const result = getQueries(text, 2, 27) + expect(result.stack.length).toBe(2) + expect(result.stack[0]).toBe("SELECT * FROM trades") + expect(result.stack[1]).toBe("CREATE TABLE t1 AS (SELECT * FROM t2)") + expect(result.next).toBe("SELECT count() FROM orders") + }) + + it("should identify DROP TABLE and SELECT without semicolons", () => { + const text = "DROP TABLE IF EXISTS t1\nSELECT * FROM t2" + const result = getQueries(text, 1, 17) + expect(result.stack.length + (result.next ? 1 : 0)).toBe(2) + }) + }) + + describe("incomplete SQL during editing", () => { + it("should treat incomplete SQL as a single statement", () => { + const text = "SELECT * FROM " + const result = getQueries(text, 0, 15) + expect(result.next).toContain("SELECT") + expect(result.next).toContain("FROM") + }) + + it("should handle just SELECT keyword", () => { + const text = "SELECT " + const result = getQueries(text, 0, 8) + expect(result.next).toContain("SELECT") + }) + + it("should handle incomplete WHERE clause", () => { + const text = "SELECT * FROM trades WHERE " + const result = getQueries(text, 0, 27) + const allText = [ + ...result.stack, + ...(result.next ? [result.next] : []), + ].join("") + expect(allText).toContain("SELECT") + expect(allText).toContain("WHERE") + }) + + it("should split valid + incomplete query with semicolons", () => { + const text = "SELECT 1;\nSELECT * FROM " + const result = getQueries(text, 1, 15) + expect(result.stack).toEqual(["SELECT 1"]) + expect(result.next).toContain("SELECT * FROM") + }) + + it("should split valid + incomplete query without semicolons", () => { + const text = "SELECT 1\nSELECT * FROM " + // Cursor at end of text + const result = getQueries(text, 1, 15) + expect(result.stack).toEqual(["SELECT 1"]) + expect(result.next).toContain("SELECT * FROM") + }) + }) + + describe("incomplete SQL mixed with valid queries", () => { + it("should handle three queries with middle incomplete (semicolons)", () => { + const text = "SELECT 1;\nSELECT * FROM ;\nSELECT 2" + // Cursor at end + const result = getQueries(text, 2, 9) + const allQueries = [ + ...result.stack, + ...(result.next ? [result.next] : []), + ] + expect(allQueries.length).toBe(3) + expect(allQueries[0]).toBe("SELECT 1") + expect(allQueries[1]).toContain("SELECT * FROM") + expect(allQueries[2]).toBe("SELECT 2") + }) + + it("should handle incomplete WHERE + valid query (semicolons)", () => { + const text = "SELECT * FROM trades WHERE ;\nSELECT 2" + const result = getQueries(text, 1, 9) + const allQueries = [ + ...result.stack, + ...(result.next ? [result.next] : []), + ] + expect(allQueries.length).toBe(2) + expect(allQueries[0]).toContain("WHERE") + expect(allQueries[1]).toBe("SELECT 2") + }) + + it("should handle incomplete WHERE + valid query (no semicolons)", () => { + const text = "SELECT * FROM trades WHERE \nSELECT 2" + const result = getQueries(text, 1, 9) + const allQueries = [ + ...result.stack, + ...(result.next ? [result.next] : []), + ] + expect(allQueries.length).toBe(2) + expect(allQueries[0]).toContain("WHERE") + expect(allQueries[1]).toBe("SELECT 2") + }) + + it("should handle valid, incomplete, valid (no semicolons)", () => { + const text = "SELECT 1\nSELECT * FROM \nSELECT 2" + // Cursor at end + const result = getQueries(text, 2, 9) + const allQueries = [ + ...result.stack, + ...(result.next ? [result.next] : []), + ] + expect(allQueries.length).toBe(3) + expect(allQueries[0]).toBe("SELECT 1") + expect(allQueries[1]).toContain("SELECT * FROM") + expect(allQueries[2]).toBe("SELECT 2") + }) + }) + + describe("CREATE TABLE AS SELECT (nested SELECT)", () => { + it("should treat CREATE TABLE AS SELECT as a single statement", () => { + const text = "CREATE TABLE t1 AS (\nSELECT * FROM t2\n)" + const result = getQueries(text, 2, 2) + const allQueries = [ + ...result.stack, + ...(result.next ? [result.next] : []), + ] + expect(allQueries.length).toBe(1) + expect(allQueries[0]).toContain("CREATE TABLE") + expect(allQueries[0]).toContain("SELECT * FROM t2") + }) + + it("should handle CREATE TABLE AS SELECT + another query", () => { + const text = "CREATE TABLE t1 AS (\nSELECT * FROM t2\n)\nSELECT 1" + const result = getQueries(text, 3, 9) + const allQueries = [ + ...result.stack, + ...(result.next ? [result.next] : []), + ] + expect(allQueries.length).toBe(2) + expect(allQueries[0]).toContain("CREATE TABLE") + expect(allQueries[1]).toBe("SELECT 1") + }) + }) + + describe("comments", () => { + it("should handle comments between queries", () => { + const text = "SELECT 1\n-- this is a comment\nSELECT 2" + const result = getQueries(text, 2, 9) + const allQueries = [ + ...result.stack, + ...(result.next ? [result.next] : []), + ] + expect(allQueries.length).toBe(2) + expect(allQueries[0]).toBe("SELECT 1") + expect(allQueries[1]).toBe("SELECT 2") + }) + }) + + describe("edge cases", () => { + it("should return empty for empty input", () => { + const result = getQueries("", 0, 1) + expect(result.stack).toEqual([]) + expect(result.next).toBeNull() + }) + + it("should return empty for whitespace-only input", () => { + const result = getQueries(" \n\n ", 1, 1) + expect(result.stack).toEqual([]) + expect(result.next).toBeNull() + }) + + it("should handle double semicolons", () => { + const text = "SELECT 1;;\nSELECT 2" + const result = getQueries(text, 1, 9) + const allQueries = [ + ...result.stack, + ...(result.next ? [result.next] : []), + ] + expect(allQueries.length).toBe(2) + expect(allQueries[0]).toBe("SELECT 1") + expect(allQueries[1]).toBe("SELECT 2") + }) + + it("should handle semicolons in strings", () => { + const text = "SELECT 'hello;world' FROM t1\nSELECT 2" + const result = getQueries(text, 1, 9) + const allQueries = [ + ...result.stack, + ...(result.next ? [result.next] : []), + ] + expect(allQueries.length).toBe(2) + expect(allQueries[0]).toBe("SELECT 'hello;world' FROM t1") + expect(allQueries[1]).toBe("SELECT 2") + }) + + it("should handle a single complete query", () => { + const text = "SELECT * FROM trades" + const result = getQueries(text, 0, 21) + const allQueries = [ + ...result.stack, + ...(result.next ? [result.next] : []), + ] + expect(allQueries.length).toBe(1) + expect(allQueries[0]).toBe("SELECT * FROM trades") + }) + }) + + describe("unparseable syntax (DECLARE with array subscripts)", () => { + it("should split DECLARE + SELECT into two statements", () => { + const text = `DECLARE + @level := insertion_point(bids[2], bid_volume), + @price := bids[1:4][@level] +SELECT + md.timestamp market_time, + @level level, + @price market_price, + cp.timestamp core_time, + cp.bid_price core_price +FROM ( + core_price + WHERE timestamp IN today() + AND symbol = 'GBPUSD' + LIMIT -6 +) cp +-- Match the bid to its nearest price within one second. +ASOF JOIN market_data md +ON symbol TOLERANCE 1s + +SELECT * +FROM trades +WHERE symbol IN ('BTC-USDT', 'ETH-USDT') +LATEST ON timestamp PARTITION BY symbol` + + // Cursor on the last line + const lastLineIndex = text.split("\n").length - 1 + const result = getQueries(text, lastLineIndex, 40) + const allQueries = [ + ...result.stack, + ...(result.next ? [result.next] : []), + ] + expect(allQueries.length).toBe(2) + // First statement is the DECLARE + SELECT (parser now handles array subscripts) + expect(allQueries[0]).toContain("DECLARE") + expect(allQueries[0]).toContain("ASOF JOIN") + // Second statement is the standalone SELECT + expect(allQueries[1]).toContain("SELECT *") + expect(allQueries[1]).toContain("LATEST ON timestamp") + }) + }) + + describe("DECLARE + WITH subquery comparison (full SQL)", () => { + it("should split into 2 statements: DECLARE+SELECT and WITH+SELECT", () => { + const text = `DECLARE + @prices := asks[1], + @volumes := asks[2], + @best_price := @prices[1], + @multiplier := 1.01, + @target_price := @multiplier * @best_price, + @rel_vol := @volumes[ + 1:insertion_point(@prices, @target_price) + ] +SELECT timestamp, array_sum(@rel_vol) total_volume +FROM market_data +WHERE timestamp > dateadd('m', -1, now()) +AND symbol='EURUSD' + +WITH yesterday_range AS ( + SELECT + dateadd('d', -1, date_trunc('day', now())) as start_time, + date_trunc('day', now()) as end_time +), +aggregated_data AS ( + SELECT + timestamp, + sum(price * amount) / sum(amount) as weighted_avg_price, + sum(amount) as interval_volume, + sum(price * amount) as interval_notional + FROM + trades + WHERE + symbol = 'BTC-USDT' + AND timestamp >= ( + SELECT + start_time + FROM + yesterday_range + ) + AND timestamp < ( + SELECT + end_time + FROM + yesterday_range + ) SAMPLE BY 10m +) +SELECT + timestamp, + weighted_avg_price, + cumulative_notional / cumulative_volume as cumulative_weighted_avg +FROM + aggregated_data +ORDER BY + timestamp` + + const lastLineIndex = text.split("\n").length - 1 + const result = getQueries(text, lastLineIndex, 40) + const allQueries = [ + ...result.stack, + ...(result.next ? [result.next] : []), + ] + expect(allQueries.length).toBe(2) + // First: DECLARE + SELECT + expect(allQueries[0]).toContain("DECLARE") + expect(allQueries[0]).toContain("array_sum") + expect(allQueries[0]).toContain("EURUSD") + // Second: WITH + SELECT (with subquery comparisons) + expect(allQueries[1]).toContain("WITH") + expect(allQueries[1]).toContain("timestamp >=") + expect(allQueries[1]).toContain("ORDER BY") + }) + }) + + describe("cursor position handling", () => { + it("should put query before cursor in stack", () => { + const text = "SELECT 1;\nSELECT 2;\nSELECT 3" + // Cursor at line 2 (0-based row=2) + const result = getQueries(text, 2, 1) + expect(result.stack.length).toBeGreaterThanOrEqual(2) + expect(result.next).toBe("SELECT 3") + }) + + it("should return query at cursor as nextSql when cursor is on it", () => { + const text = "SELECT 1\nSELECT 2" + // Cursor on line 0, column 5 (inside first query) + const result = getQueries(text, 0, 5) + expect(result.next).toBe("SELECT 1") + }) + }) + + describe("start position filtering", () => { + it("should filter queries before start position", () => { + const text = "SELECT 1;\nSELECT 2;\nSELECT 3" + // Start from line 1, cursor at end + const result = getQueries(text, 2, 9, 1, 1) + // SELECT 1 should be excluded since it's before start + const allQueries = [ + ...result.stack, + ...(result.next ? [result.next] : []), + ] + expect(allQueries).not.toContain("SELECT 1") + expect(allQueries.length).toBe(2) + }) + }) + + describe("position correctness", () => { + it("should have correct row/col for multi-line queries", () => { + const text = "SELECT 1\nSELECT 2" + const result = _getQueriesFromText(text, { + row: 1, + column: 9, + }) + + // First query (in stack): row 0, col 1 + if (result.sqlTextStack.length > 0) { + const first = result.sqlTextStack[0] + expect(first.row).toBe(0) + expect(first.col).toBe(1) + expect(first.position).toBe(0) + } + + // Second query (nextSql): row 1 + if (result.nextSql) { + expect(result.nextSql.row).toBe(1) + expect(result.nextSql.position).toBe(9) + } + }) + + it("should have correct endRow/endCol", () => { + const text = "SELECT 1\nSELECT 2" + const result = _getQueriesFromText(text, { + row: 1, + column: 9, + }) + + if (result.sqlTextStack.length > 0) { + const first = result.sqlTextStack[0] + expect(first.endRow).toBe(0) + // endCol should be at position of '1' + expect(first.limit).toBe(8) // "SELECT 1" is 8 chars + } + }) + }) +}) diff --git a/src/scenes/Editor/Monaco/utils.ts b/src/scenes/Editor/Monaco/utils.ts index 45a7a4212..cfb61236f 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 { parse } from "@questdb/sql-parser" type IStandaloneCodeEditor = editor.IStandaloneCodeEditor @@ -152,7 +153,388 @@ export const getQueriesToRun = ( return requests.filter(Boolean) as Request[] } -export const getQueriesFromPosition = ( +// ============================================================================= +// Parser-based query identification +// ============================================================================= + +type CSTNode = { + children?: Record + image?: string + startOffset?: number + endOffset?: number + startLine?: number + endLine?: number + startColumn?: number + endColumn?: number +} + +type StatementBoundary = { + startOffset: number + endOffset: number + startLine: number + endLine: number + startColumn: number + endColumn: number +} + +/** + * Find the first (leftmost) token in a CST node by depth-first traversal. + */ +const findFirstToken = (node: CSTNode): CSTNode | null => { + if (!node || typeof node !== "object") return null + if (node.image !== undefined && node.startOffset !== undefined) return node + if (node.children) { + let earliest: CSTNode | null = null + for (const key of Object.keys(node.children)) { + const children = node.children[key] + if (Array.isArray(children)) { + for (const child of children) { + const token = findFirstToken(child) + if ( + token && + (earliest === null || token.startOffset! < earliest.startOffset!) + ) { + earliest = token + } + } + } + } + return earliest + } + return null +} + +/** + * Find the last (rightmost) token in a CST node by depth-first traversal. + */ +const findLastToken = (node: CSTNode): CSTNode | null => { + if (!node || typeof node !== "object") return null + if (node.image !== undefined && node.endOffset !== undefined) return node + if (node.children) { + let latest: CSTNode | null = null + for (const key of Object.keys(node.children)) { + const children = node.children[key] + if (Array.isArray(children)) { + for (const child of children) { + const token = findLastToken(child) + if ( + token && + (latest === null || token.endOffset! > latest.endOffset!) + ) { + latest = token + } + } + } + } + return latest + } + return null +} + +/** + * Convert text positions (offsets) to row/column for SqlTextItem. + * Row is 0-based, column is 1-based. + */ +const offsetToRowCol = ( + text: string, + offset: number, +): { row: number; col: number } => { + let row = 0 + let lastNewline = -1 + for (let i = 0; i < offset && i < text.length; i++) { + if (text[i] === "\n") { + row++ + lastNewline = i + } + } + const col = offset - lastNewline // 1-based since lastNewline starts at -1 + return { row, col } +} + +/** + * Extract statement boundaries from a parse result (with recovery enabled). + * + * The parser uses Chevrotain's error recovery (`recoveryEnabled: true`), + * which means it can produce a partial CST even when some statements have + * syntax errors. Failed statements are marked with `recoveredNode: true`. + * + * This function: + * 1. Extracts boundaries from each CST statement node + * 2. Merges consecutive recovered (error) nodes into a single statement + * 3. Extends recovered regions to cover any tokens skipped during re-sync + */ +const extractStatements = ( + text: string, + result: ReturnType, +): StatementBoundary[] => { + const stmts: (CSTNode & { recoveredNode?: boolean })[] = + (result.cst as CSTNode)?.children?.statement ?? [] + + if (stmts.length === 0) return [] + + // Extract raw boundaries with recovery flag + const raw = stmts + .map((stmt) => { + const first = findFirstToken(stmt) + const last = findLastToken(stmt) + if (first && last) { + return { + startOffset: first.startOffset!, + endOffset: last.endOffset ?? last.startOffset!, + startLine: first.startLine!, + endLine: last.endLine!, + startColumn: first.startColumn!, + endColumn: last.endColumn!, + recovered: !!stmt.recoveredNode, + } + } + return null + }) + .filter((s): s is StatementBoundary & { recovered: boolean } => s !== null) + + if (raw.length === 0) return [] + + // Merge consecutive recovered nodes and fill gaps to next clean statement + const merged: StatementBoundary[] = [] + let i = 0 + while (i < raw.length) { + if (!raw[i].recovered) { + merged.push(raw[i]) + i++ + } else { + // Merge consecutive recovered nodes + const startOffset = raw[i].startOffset + let endOffset = raw[i].endOffset + const startLine = raw[i].startLine + const startColumn = raw[i].startColumn + let endLine = raw[i].endLine + let endColumn = raw[i].endColumn + + while (i + 1 < raw.length && raw[i + 1].recovered) { + i++ + endOffset = Math.max(endOffset, raw[i].endOffset) + if ( + raw[i].endLine > endLine || + (raw[i].endLine === endLine && raw[i].endColumn > endColumn) + ) { + endLine = raw[i].endLine + endColumn = raw[i].endColumn + } + } + + // Extend to cover gap before next clean statement + if (i + 1 < raw.length) { + const gapEnd = raw[i + 1].startOffset - 1 + if (gapEnd > endOffset) { + // Trim trailing whitespace from the gap + let trimEnd = gapEnd + while (trimEnd > endOffset && /\s/.test(text[trimEnd])) { + trimEnd-- + } + if (trimEnd > endOffset) { + endOffset = trimEnd + const endPos = offsetToRowCol(text, endOffset) + endLine = endPos.row + 1 // 1-based for StatementBoundary + endColumn = endPos.col + } + } + } + + merged.push({ + startOffset, + endOffset, + startLine, + endLine, + startColumn, + endColumn, + }) + i++ + } + } + + return merged +} + +/** + * Get all statement boundaries from text using the parser with error recovery. + * + * The parser uses Chevrotain's built-in error recovery, which means: + * - Valid SQL: statements are extracted directly from the CST + * - Invalid SQL: the parser recovers by skipping bad tokens and continues, + * producing a partial CST with `recoveredNode` markers + * + * No hardcoded keyword lists or manual splitting heuristics needed. + */ +let _boundariesCache: { text: string; result: StatementBoundary[] } | null = + null + +let _parseRange: { startOffset: number; endOffset: number } | null = null + +export const setParseRange = (startOffset: number, endOffset: number) => { + _parseRange = { startOffset, endOffset } +} + +const getStatementBoundariesForRange = ( + text: string, + startOffset: number, + endOffset: number, +): StatementBoundary[] => { + const substring = text.substring(startOffset, endOffset) + if (!substring.trim()) return [] + + const atDocStart = startOffset === 0 + const atDocEnd = endOffset >= text.length + + const raw = extractStatements(substring, parse(substring)) + + // Shift offsets to be document-relative and drop recovered edge statements + const shifted: StatementBoundary[] = [] + for (let i = 0; i < raw.length; i++) { + const b = raw[i] + const touchesStart = b.startOffset === 0 + const touchesEnd = b.endOffset >= substring.length - 1 + + // Drop statements that touch a cut edge (not at document boundary) + // and are the first/last statement — these are likely truncated + if (touchesStart && !atDocStart && i === 0) { + continue + } + if (touchesEnd && !atDocEnd && i === raw.length - 1) { + continue + } + + shifted.push({ + startOffset: b.startOffset + startOffset, + endOffset: b.endOffset + startOffset, + startLine: b.startLine, + endLine: b.endLine, + startColumn: b.startColumn, + endColumn: b.endColumn, + }) + } + + return shifted +} + +const getStatementBoundaries = (text: string): StatementBoundary[] => { + if (!text.trim()) return [] + + // Viewport-scoped parsing + if (_parseRange) { + const result = getStatementBoundariesForRange( + text, + _parseRange.startOffset, + _parseRange.endOffset, + ) + + return result + } + + // Full parse (fallback when no viewport is set) + if (_boundariesCache && _boundariesCache.text === text) { + return _boundariesCache.result + } + const result = extractStatements(text, parse(text)) + _boundariesCache = { text, result } + return result +} + +/** + * Pure function to identify queries in text using the parser. + * Returns the same format as the legacy getQueriesFromPosition. + * + * @param text - The full editor text + * @param position - Cursor position (0-based row, 1-based column) + * @param start - Optional start position to filter from (0-based row, 1-based column) + */ +export const _getQueriesFromText = ( + text: string, + position: { row: number; column: number }, + start?: { row: number; column: number }, +): { sqlTextStack: SqlTextItem[]; nextSql: SqlTextItem | null } => { + if (!text || !stripSQLComments(text)) { + return { sqlTextStack: [], nextSql: null } + } + + // Get all statement boundaries from the parser + const boundaries = getStatementBoundaries(text) + + // Convert cursor position to offset + const lines = text.split("\n") + let cursorOffset = 0 + for (let i = 0; i < position.row && i < lines.length; i++) { + cursorOffset += lines[i].length + 1 // +1 for \n + } + if (position.row < lines.length) { + cursorOffset += position.column - 1 // column is 1-based + } + + // Convert start position to offset (if provided) + let startOffset = 0 + if (start) { + for (let i = 0; i < start.row && i < lines.length; i++) { + startOffset += lines[i].length + 1 + } + if (start.row < lines.length) { + startOffset += start.column - 1 + } + } + + // Convert boundaries to SqlTextItems, filtering by start position + const items: SqlTextItem[] = boundaries + .filter((b) => b.endOffset >= startOffset) + .map((b) => { + const startPos = offsetToRowCol(text, b.startOffset) + const endPos = offsetToRowCol(text, b.endOffset) + return { + row: startPos.row, + col: startPos.col, + position: b.startOffset, + endRow: endPos.row, + endCol: endPos.col, + limit: b.endOffset + 1, // limit is exclusive (for text.substring) + } + }) + .filter( + (item) => + item.row !== item.endRow || + item.col !== item.endCol || + item.limit >= item.position + 1, + ) + + // Split into sqlTextStack (before cursor) and nextSql (at/after cursor). + // + // The semantics match the legacy function: + // - Queries whose end is strictly before the cursor go into sqlTextStack + // - The first query that the cursor is within or on goes into nextSql + // - If the cursor is past all queries, the last query becomes nextSql + // + // "Cursor is within a query" means: item.position <= cursorOffset < item.limit + // (position is inclusive start offset, limit is exclusive end offset) + + // Find the index of the query the cursor is on or the first one after it + let nextSqlIndex = -1 + for (let i = 0; i < items.length; i++) { + const item = items[i] + // Cursor is before or within this query (item hasn't ended before cursor) + if (cursorOffset < item.limit) { + nextSqlIndex = i + break + } + } + + // If no query contains/follows cursor, the last query is nextSql + if (nextSqlIndex === -1 && items.length > 0) { + nextSqlIndex = items.length - 1 + } + + const sqlTextStack = nextSqlIndex > 0 ? items.slice(0, nextSqlIndex) : [] + const nextSql = nextSqlIndex >= 0 ? items[nextSqlIndex] : null + + return { sqlTextStack, nextSql } +} + +export const _legacy_getQueriesFromPosition = ( editor: IStandaloneCodeEditor, editorPosition: IPosition, startPosition?: IPosition, @@ -419,6 +801,29 @@ export const getQueriesFromPosition = ( return { sqlTextStack: filteredSqlTextStack, nextSql: filteredNextSql } } +export const getQueriesFromPosition = ( + editor: IStandaloneCodeEditor, + editorPosition: IPosition, + startPosition?: IPosition, +): { sqlTextStack: SqlTextItem[]; nextSql: SqlTextItem | null } => { + const text = editor.getValue({ preserveBOM: false, lineEnding: "\n" }) + + if (!text || !stripSQLComments(text)) { + return { sqlTextStack: [], nextSql: null } + } + + const position = { + row: editorPosition.lineNumber - 1, + column: editorPosition.column, + } + + const start = startPosition + ? { row: startPosition.lineNumber - 1, column: startPosition.column } + : undefined + + return _getQueriesFromText(text, position, start) +} + export const getQueryFromCursor = ( editor: IStandaloneCodeEditor, ): Request | undefined => { @@ -1167,18 +1572,18 @@ export const validateQueryAtOffset = ( const totalLength = model.getValueLength() if (offset < 0 || offset >= totalLength) return false - const offsetPosition = model.getPositionAt(offset) - - const queryInEditor = getQueriesInRange( - editor, - offsetPosition, - offsetPosition, - )[0] - if (!queryInEditor) return false + const normalizedQuery = normalizeQueryText(queryText) + const startPos = model.getPositionAt(offset) + const endOffset = Math.min(offset + normalizedQuery.length, totalLength) + const endPos = model.getPositionAt(endOffset) + const textAtOffset = model.getValueInRange({ + startLineNumber: startPos.lineNumber, + startColumn: startPos.column, + endLineNumber: endPos.lineNumber, + endColumn: endPos.column, + }) - return ( - normalizeQueryText(queryInEditor.query) === normalizeQueryText(queryText) - ) + return normalizeQueryText(textAtOffset) === normalizedQuery } export const createQueryKeyFromRequest = ( diff --git a/yarn.lock b/yarn.lock index 38c93f92d..eda395405 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@file:../questdb-sql-parser::locator=%40questdb%2Fweb-console%40workspace%3A.": + version: 0.1.0 + resolution: "@questdb/sql-parser@file:../questdb-sql-parser#../questdb-sql-parser::hash=30b2f2&locator=%40questdb%2Fweb-console%40workspace%3A." + dependencies: + chevrotain: "npm:^11.1.1" + checksum: 10/515d9fceb8af78edfd7c0675f41e0da81c00be489f6a68267debfeae44e61d7d7e2dd3734daf808f7d074fc29b387ffb1aff87c661b18921c0e6af7bc9401c65 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": "file:../questdb-sql-parser" "@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" @@ -5167,6 +5211,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" @@ -8385,6 +8443,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" From f280d411e8f4f784c08279e41274d2506c6d9dd2 Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 16 Feb 2026 20:28:17 +0300 Subject: [PATCH 02/19] cleanup in monaco utils, remove viewport scoping from run all flow, fix issues on notification handling, avoid full-document parsing --- src/scenes/Editor/Monaco/index.tsx | 156 +++---- src/scenes/Editor/Monaco/utils.test.ts | 53 ++- src/scenes/Editor/Monaco/utils.ts | 624 +++++++------------------ yarn.lock | 4 +- 4 files changed, 287 insertions(+), 550 deletions(-) diff --git a/src/scenes/Editor/Monaco/index.tsx b/src/scenes/Editor/Monaco/index.tsx index a99686ff5..07a9fdb6d 100644 --- a/src/scenes/Editor/Monaco/index.tsx +++ b/src/scenes/Editor/Monaco/index.tsx @@ -70,7 +70,6 @@ import { getQueryStartOffset, getQueriesToRun, getQueriesStartingFromLine, - setParseRange, } from "./utils" import { toast } from "../../../components/Toast" import ButtonBar from "../ButtonBar" @@ -366,26 +365,6 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { void updateBuffer(activeBuffer.id as number, { value }) } - const updateParseRange = () => { - const editorInstance = editorRef.current - const model = editorInstance?.getModel() - if (!editorInstance || !model) return - - const visibleRanges = editorInstance.getVisibleRanges() - if (visibleRanges.length > 0) { - const totalLines = model.getLineCount() - const startLine = Math.max(1, visibleRanges[0].startLineNumber - 150) - const endLine = Math.min(totalLines, visibleRanges[0].endLineNumber + 150) - setParseRange( - model.getOffsetAt({ lineNumber: startLine, column: 1 }), - model.getOffsetAt({ - lineNumber: endLine, - column: model.getLineMaxColumn(endLine), - }), - ) - } - } - // Set the initial line number width in chars based on the number of lines in the active buffer const [lineNumbersMinChars, setLineNumbersMinChars] = useState( (canUseAI ? 7 : 5) + @@ -465,7 +444,10 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { endColumn: endPosition.column, }) } else { - const queryInCursor = getQueryFromCursor(editor) + const queryInCursor = getQueryFromCursor( + editor, + activeBufferRef.current.id as number, + ) if ( queryInCursor && createQueryKeyFromRequest(editor, queryInCursor) === @@ -566,7 +548,12 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { } if ( - !validateQueryAtOffset(editor, pending.queryText, pending.startOffset) + !validateQueryAtOffset( + editor, + pending.queryText, + pending.startOffset, + activeBufferRef.current.id as number, + ) ) { return } @@ -605,8 +592,8 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { return } - const queryAtCursor = getQueryFromCursor(editor) const activeBufferId = activeBufferRef.current.id as number + const queryAtCursor = getQueryFromCursor(editor, activeBufferId) const lineMarkingDecorations: editor.IModelDeltaDecoration[] = [] const bufferExecutions = @@ -693,15 +680,17 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { if (!editorValue || !model) { return } + + const activeBufferId = activeBufferRef.current.id as number let queries: Request[] = [] const visibleLines = visibleLinesRef.current if (!visibleLines) { - queries = getAllQueries(editor) + queries = getAllQueries(editor, activeBufferId) } else { const totalLines = model.getLineCount() - const bufferSize = 150 + const bufferSize = 500 const startLine = Math.max(1, visibleLines.startLine - bufferSize) const endLine = Math.min(totalLines, visibleLines.endLine + bufferSize) @@ -712,11 +701,14 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { column: editor.getModel()?.getLineMaxColumn(endLine) ?? 1, } - queries = getQueriesInRange(editor, startPosition, endPosition) + queries = getQueriesInRange( + editor, + startPosition, + endPosition, + activeBufferId, + ) } - const activeBufferId = activeBufferRef.current.id as number - const allQueryOffsets: { startOffset: number; endOffset: number }[] = [] const newGlyphWidgetIds = new Map() const newGlyphWidgetLineNumbers = new Set() @@ -913,6 +905,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { const queriesToRun = getQueriesToRun( editor, queryOffsetsRef.current ?? [], + activeBufferRef.current.id as number, ) queriesToRunRef.current = queriesToRun dispatch(actions.query.setQueriesToRun(queriesToRun)) @@ -928,8 +921,6 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { const model = editor.getModel() if (!model) return - updateParseRange() - const lineCount = model.getLineCount() if (lineCount) { setLineNumbersMinChars( @@ -951,8 +942,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 } = @@ -970,36 +959,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 }) => { @@ -1046,25 +1022,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) { @@ -1097,6 +1064,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { const queriesToRun = getQueriesToRun( editor, queryOffsetsRef.current ?? [], + activeBufferId, ) queriesToRunRef.current = queriesToRun dispatch(actions.query.setQueriesToRun(queriesToRun)) @@ -1107,9 +1075,16 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { editor.onDidChangeModel(() => { 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) @@ -1144,7 +1119,6 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { visibleLinesRef.current = newVisibleLines if (startLineDiff > 100 || endLineDiff > 100) { - updateParseRange() if (monacoRef.current && editorRef.current) { applyGlyphsAndLineMarkings( monacoRef.current, @@ -1192,12 +1166,13 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { } } - // Set initial parse range for viewport-scoped parsing - updateParseRange() - // Initial decoration setup applyGlyphsAndLineMarkings(monaco, editor) - const queriesToRun = getQueriesToRun(editor, queryOffsetsRef.current ?? []) + const queriesToRun = getQueriesToRun( + editor, + queryOffsetsRef.current ?? [], + activeBufferRef.current.id as number, + ) queriesToRunRef.current = queriesToRun dispatch(actions.query.setQueriesToRun(queriesToRun)) @@ -1450,7 +1425,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { isRunningScriptRef.current = true setTabsDisabled(true) - const queries = queriesToRun ?? getAllQueries(editor) + const queries = queriesToRun ?? getAllQueries(editor, activeBufferId) const individualQueryResults: Array = [] editor.updateOptions({ readOnly: true }) @@ -1668,7 +1643,10 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { editorRef.current, aiSuggestionRequestRef.current, ) - : getQueryRequestFromEditor(editorRef.current) + : getQueryRequestFromEditor( + editorRef.current, + activeBufferRef.current.id as number, + ) const isRunningExplain = running === RunningType.EXPLAIN const isAISuggestion = diff --git a/src/scenes/Editor/Monaco/utils.test.ts b/src/scenes/Editor/Monaco/utils.test.ts index 9f6290651..833d5f157 100644 --- a/src/scenes/Editor/Monaco/utils.test.ts +++ b/src/scenes/Editor/Monaco/utils.test.ts @@ -1,8 +1,38 @@ import { describe, it, expect } from "vitest" -import { _getQueriesFromText } from "./utils" +import { getQueriesFromModel } from "./utils" /** - * Helper to extract query text strings from _getQueriesFromText result. + * Minimal mock of Monaco's ITextModel for testing getQueriesFromModel + * without a real Monaco editor instance. + */ +const createMockModel = (text: string) => { + // Build line-start offsets: lineStarts[i] = offset of first char of line i + const lineStarts = [0] + for (let i = 0; i < text.length; i++) { + if (text[i] === "\n") lineStarts.push(i + 1) + } + + return { + getValue: () => text, + getOffsetAt: (pos: { lineNumber: number; column: number }) => { + const lineIndex = Math.min(pos.lineNumber - 1, lineStarts.length - 1) + return lineStarts[lineIndex] + (pos.column - 1) + }, + getPositionAt: (offset: number) => { + let lo = 0 + let hi = lineStarts.length - 1 + while (lo < hi) { + const mid = (lo + hi + 1) >> 1 + if (lineStarts[mid] <= offset) lo = mid + else hi = mid - 1 + } + return { lineNumber: lo + 1, column: offset - lineStarts[lo] + 1 } + }, + } as Parameters[0] +} + +/** + * Helper to extract query text strings from getQueriesFromModel result. */ const getQueries = ( text: string, @@ -11,8 +41,9 @@ const getQueries = ( startRow?: number, startCol?: number, ) => { - const result = _getQueriesFromText( - text, + const model = createMockModel(text) + const result = getQueriesFromModel( + model, { row: cursorRow, column: cursorCol }, startRow !== undefined && startCol !== undefined ? { row: startRow, column: startCol } @@ -31,7 +62,7 @@ const getQueries = ( } } -describe("_getQueriesFromText", () => { +describe("getQueriesFromModel", () => { describe("basic query identification without semicolons", () => { it("should identify two queries without semicolons", () => { const text = "SELECT 1\nSELECT 2" @@ -399,10 +430,8 @@ ORDER BY describe("position correctness", () => { it("should have correct row/col for multi-line queries", () => { const text = "SELECT 1\nSELECT 2" - const result = _getQueriesFromText(text, { - row: 1, - column: 9, - }) + const model = createMockModel(text) + const result = getQueriesFromModel(model, { row: 1, column: 9 }) // First query (in stack): row 0, col 1 if (result.sqlTextStack.length > 0) { @@ -421,10 +450,8 @@ ORDER BY it("should have correct endRow/endCol", () => { const text = "SELECT 1\nSELECT 2" - const result = _getQueriesFromText(text, { - row: 1, - column: 9, - }) + const model = createMockModel(text) + const result = getQueriesFromModel(model, { row: 1, column: 9 }) if (result.sqlTextStack.length > 0) { const first = result.sqlTextStack[0] diff --git a/src/scenes/Editor/Monaco/utils.ts b/src/scenes/Editor/Monaco/utils.ts index cfb61236f..e8d962dfb 100644 --- a/src/scenes/Editor/Monaco/utils.ts +++ b/src/scenes/Editor/Monaco/utils.ts @@ -28,6 +28,7 @@ import { hashString } from "../../../utils" import { parse } from "@questdb/sql-parser" type IStandaloneCodeEditor = editor.IStandaloneCodeEditor +type ITextModel = editor.ITextModel export const QuestDBLanguageName: string = "questdb-sql" @@ -78,6 +79,7 @@ export const getSelectedText = ( export const getQueriesToRun = ( editor: IStandaloneCodeEditor, queryOffsets: { startOffset: number; endOffset: number }[], + bufferId?: number, ): Request[] => { const model = editor.getModel() if (!model) return [] @@ -85,7 +87,7 @@ export const getQueriesToRun = ( const selection = editor.getSelection() const selectedText = selection ? model.getValueInRange(selection) : undefined if (!selection || !selectedText) { - const queryInCursor = getQueryFromCursor(editor) + const queryInCursor = getQueryFromCursor(editor, bufferId) if (queryInCursor) { return [queryInCursor] } @@ -116,6 +118,7 @@ export const getQueriesToRun = ( editor, model.getPositionAt(firstQueryOffsets.startOffset), model.getPositionAt(lastQueryOffsets.endOffset), + bufferId, ) const requests = queries.map((query) => { const clampedSelection = clampRange(model, selection, { @@ -153,10 +156,6 @@ export const getQueriesToRun = ( return requests.filter(Boolean) as Request[] } -// ============================================================================= -// Parser-based query identification -// ============================================================================= - type CSTNode = { children?: Record image?: string @@ -171,84 +170,48 @@ type CSTNode = { type StatementBoundary = { startOffset: number endOffset: number - startLine: number - endLine: number - startColumn: number - endColumn: number } -/** - * Find the first (leftmost) token in a CST node by depth-first traversal. - */ -const findFirstToken = (node: CSTNode): CSTNode | null => { - if (!node || typeof node !== "object") return null - if (node.image !== undefined && node.startOffset !== undefined) return node +let _boundariesCache: { + bufferId: number + version: number + rangeKey: string + boundaries: StatementBoundary[] +} | null = null + +const getTokenBoundaries = ( + node: CSTNode, +): { first: CSTNode | null; last: CSTNode | null } => { + if (!node || typeof node !== "object") return { first: null, last: null } + if (node.image !== undefined && node.startOffset !== undefined) { + return { first: node, last: node } + } if (node.children) { let earliest: CSTNode | null = null + let latest: CSTNode | null = null for (const key of Object.keys(node.children)) { const children = node.children[key] if (Array.isArray(children)) { for (const child of children) { - const token = findFirstToken(child) + const { first, last } = getTokenBoundaries(child) if ( - token && - (earliest === null || token.startOffset! < earliest.startOffset!) + first && + (earliest === null || first.startOffset! < earliest.startOffset!) ) { - earliest = token + earliest = first } - } - } - } - return earliest - } - return null -} - -/** - * Find the last (rightmost) token in a CST node by depth-first traversal. - */ -const findLastToken = (node: CSTNode): CSTNode | null => { - if (!node || typeof node !== "object") return null - if (node.image !== undefined && node.endOffset !== undefined) return node - if (node.children) { - let latest: CSTNode | null = null - for (const key of Object.keys(node.children)) { - const children = node.children[key] - if (Array.isArray(children)) { - for (const child of children) { - const token = findLastToken(child) if ( - token && - (latest === null || token.endOffset! > latest.endOffset!) + last && + (latest === null || last.endOffset! > latest.endOffset!) ) { - latest = token + latest = last } } } } - return latest + return { first: earliest, last: latest } } - return null -} - -/** - * Convert text positions (offsets) to row/column for SqlTextItem. - * Row is 0-based, column is 1-based. - */ -const offsetToRowCol = ( - text: string, - offset: number, -): { row: number; col: number } => { - let row = 0 - let lastNewline = -1 - for (let i = 0; i < offset && i < text.length; i++) { - if (text[i] === "\n") { - row++ - lastNewline = i - } - } - const col = offset - lastNewline // 1-based since lastNewline starts at -1 - return { row, col } + return { first: null, last: null } } /** @@ -275,22 +238,22 @@ const extractStatements = ( // Extract raw boundaries with recovery flag const raw = stmts .map((stmt) => { - const first = findFirstToken(stmt) - const last = findLastToken(stmt) + const { first, last } = getTokenBoundaries(stmt) if (first && last) { return { startOffset: first.startOffset!, endOffset: last.endOffset ?? last.startOffset!, - startLine: first.startLine!, - endLine: last.endLine!, - startColumn: first.startColumn!, - endColumn: last.endColumn!, recovered: !!stmt.recoveredNode, } } return null }) - .filter((s): s is StatementBoundary & { recovered: boolean } => s !== null) + .filter( + ( + s, + ): s is { startOffset: number; endOffset: number; recovered: boolean } => + s !== null, + ) if (raw.length === 0) return [] @@ -305,21 +268,10 @@ const extractStatements = ( // Merge consecutive recovered nodes const startOffset = raw[i].startOffset let endOffset = raw[i].endOffset - const startLine = raw[i].startLine - const startColumn = raw[i].startColumn - let endLine = raw[i].endLine - let endColumn = raw[i].endColumn while (i + 1 < raw.length && raw[i + 1].recovered) { i++ endOffset = Math.max(endOffset, raw[i].endOffset) - if ( - raw[i].endLine > endLine || - (raw[i].endLine === endLine && raw[i].endColumn > endColumn) - ) { - endLine = raw[i].endLine - endColumn = raw[i].endColumn - } } // Extend to cover gap before next clean statement @@ -333,21 +285,11 @@ const extractStatements = ( } if (trimEnd > endOffset) { endOffset = trimEnd - const endPos = offsetToRowCol(text, endOffset) - endLine = endPos.row + 1 // 1-based for StatementBoundary - endColumn = endPos.col } } } - merged.push({ - startOffset, - endOffset, - startLine, - endLine, - startColumn, - endColumn, - }) + merged.push({ startOffset, endOffset }) i++ } } @@ -365,13 +307,27 @@ const extractStatements = ( * * No hardcoded keyword lists or manual splitting heuristics needed. */ -let _boundariesCache: { text: string; result: StatementBoundary[] } | null = - null +type ParseRange = { startOffset: number; endOffset: number } -let _parseRange: { startOffset: number; endOffset: number } | null = null +export const computeParseRange = ( + editor: IStandaloneCodeEditor, +): ParseRange | null => { + const model = editor.getModel() + if (!model) return null + + const visibleRanges = editor.getVisibleRanges() + if (visibleRanges.length === 0) return null -export const setParseRange = (startOffset: number, endOffset: number) => { - _parseRange = { startOffset, endOffset } + const totalLines = model.getLineCount() + const startLine = Math.max(1, visibleRanges[0].startLineNumber - 500) + const endLine = Math.min(totalLines, visibleRanges[0].endLineNumber + 500) + return { + startOffset: model.getOffsetAt({ lineNumber: startLine, column: 1 }), + endOffset: model.getOffsetAt({ + lineNumber: endLine, + column: model.getLineMaxColumn(endLine), + }), + } } const getStatementBoundariesForRange = ( @@ -406,92 +362,113 @@ const getStatementBoundariesForRange = ( shifted.push({ startOffset: b.startOffset + startOffset, endOffset: b.endOffset + startOffset, - startLine: b.startLine, - endLine: b.endLine, - startColumn: b.startColumn, - endColumn: b.endColumn, }) } return shifted } -const getStatementBoundaries = (text: string): StatementBoundary[] => { +const getStatementBoundaries = ( + text: string, + parseRange?: ParseRange | null, + modelVersion?: number, + bufferId?: number, +): StatementBoundary[] => { if (!text.trim()) return [] + const rangeKey = parseRange + ? `${parseRange.startOffset}-${parseRange.endOffset}` + : "full" + + if ( + modelVersion !== undefined && + bufferId !== undefined && + _boundariesCache && + _boundariesCache.bufferId === bufferId && + _boundariesCache.version === modelVersion && + _boundariesCache.rangeKey === rangeKey + ) { + return _boundariesCache.boundaries + } + // Viewport-scoped parsing - if (_parseRange) { - const result = getStatementBoundariesForRange( + let boundaries: StatementBoundary[] + if (parseRange) { + boundaries = getStatementBoundariesForRange( text, - _parseRange.startOffset, - _parseRange.endOffset, + parseRange.startOffset, + parseRange.endOffset, ) - - return result + } else { + // Full parse (fallback when no viewport is set) + boundaries = extractStatements(text, parse(text)) } - // Full parse (fallback when no viewport is set) - if (_boundariesCache && _boundariesCache.text === text) { - return _boundariesCache.result + if (modelVersion !== undefined && bufferId !== undefined) { + _boundariesCache = { bufferId, version: modelVersion, rangeKey, boundaries } } - const result = extractStatements(text, parse(text)) - _boundariesCache = { text, result } - return result + + return boundaries } /** - * Pure function to identify queries in text using the parser. - * Returns the same format as the legacy getQueriesFromPosition. + * Identify queries in text using the parser, splitting them relative to a cursor position. * - * @param text - The full editor text + * @param model - Monaco text model (used for offset↔position conversions) * @param position - Cursor position (0-based row, 1-based column) * @param start - Optional start position to filter from (0-based row, 1-based column) */ -export const _getQueriesFromText = ( - text: string, +export const getQueriesFromModel = ( + model: ITextModel, position: { row: number; column: number }, start?: { row: number; column: number }, + parseRange?: ParseRange | null, + bufferId?: number, ): { sqlTextStack: SqlTextItem[]; nextSql: SqlTextItem | null } => { - if (!text || !stripSQLComments(text)) { + const text = model.getValue() + if (!text.trim()) { return { sqlTextStack: [], nextSql: null } } - // Get all statement boundaries from the parser - const boundaries = getStatementBoundaries(text) + // Get all statement boundaries from the parser (cached by model version + buffer id) + const boundaries = getStatementBoundaries( + text, + parseRange, + model.getVersionId(), + bufferId, + ) - // Convert cursor position to offset - const lines = text.split("\n") - let cursorOffset = 0 - for (let i = 0; i < position.row && i < lines.length; i++) { - cursorOffset += lines[i].length + 1 // +1 for \n - } - if (position.row < lines.length) { - cursorOffset += position.column - 1 // column is 1-based + if (boundaries.length === 0) { + return { sqlTextStack: [], nextSql: null } } + // Convert cursor position to offset (row is 0-based, lineNumber is 1-based) + const cursorOffset = model.getOffsetAt({ + lineNumber: position.row + 1, + column: position.column, + }) + // Convert start position to offset (if provided) let startOffset = 0 if (start) { - for (let i = 0; i < start.row && i < lines.length; i++) { - startOffset += lines[i].length + 1 - } - if (start.row < lines.length) { - startOffset += start.column - 1 - } + startOffset = model.getOffsetAt({ + lineNumber: start.row + 1, + column: start.column, + }) } // Convert boundaries to SqlTextItems, filtering by start position const items: SqlTextItem[] = boundaries .filter((b) => b.endOffset >= startOffset) .map((b) => { - const startPos = offsetToRowCol(text, b.startOffset) - const endPos = offsetToRowCol(text, b.endOffset) + const startPos = model.getPositionAt(b.startOffset) + const endPos = model.getPositionAt(b.endOffset) return { - row: startPos.row, - col: startPos.col, + row: startPos.lineNumber - 1, + col: startPos.column, position: b.startOffset, - endRow: endPos.row, - endCol: endPos.col, + endRow: endPos.lineNumber - 1, + endCol: endPos.column, limit: b.endOffset + 1, // limit is exclusive (for text.substring) } }) @@ -504,20 +481,13 @@ export const _getQueriesFromText = ( // Split into sqlTextStack (before cursor) and nextSql (at/after cursor). // - // The semantics match the legacy function: // - Queries whose end is strictly before the cursor go into sqlTextStack // - The first query that the cursor is within or on goes into nextSql // - If the cursor is past all queries, the last query becomes nextSql - // - // "Cursor is within a query" means: item.position <= cursorOffset < item.limit - // (position is inclusive start offset, limit is exclusive end offset) - // Find the index of the query the cursor is on or the first one after it let nextSqlIndex = -1 for (let i = 0; i < items.length; i++) { - const item = items[i] - // Cursor is before or within this query (item hasn't ended before cursor) - if (cursorOffset < item.limit) { + if (cursorOffset < items[i].limit) { nextSqlIndex = i break } @@ -534,281 +504,14 @@ export const _getQueriesFromText = ( return { sqlTextStack, nextSql } } -export const _legacy_getQueriesFromPosition = ( - editor: IStandaloneCodeEditor, - editorPosition: IPosition, - startPosition?: IPosition, -): { sqlTextStack: SqlTextItem[]; nextSql: SqlTextItem | null } => { - const text = editor.getValue({ preserveBOM: false, lineEnding: "\n" }) - - if (!text || !stripSQLComments(text)) { - return { sqlTextStack: [], nextSql: null } - } - - const position = { - row: editorPosition.lineNumber - 1, - column: editorPosition.column, - } - - // Calculate starting position - default to beginning if not provided - const start = startPosition - ? { - row: startPosition.lineNumber - 1, - column: startPosition.column, - } - : { row: 0, column: 1 } - - // Convert start position to character index - let startCharIndex = 0 - if (startPosition) { - const lines = text.split("\n") - const maxRow = Math.min(start.row, lines.length - 1) - for (let i = 0; i < maxRow; i++) { - if (lines[i] !== undefined) { - startCharIndex += lines[i].length + 1 // +1 for newline character - } - } - if (lines[maxRow] !== undefined) { - startCharIndex += Math.min(start.column - 1, lines[maxRow].length) - } - } - - let row = start.row - let column = start.column - const sqlTextStack = [] - let startRow = start.row - let startCol = start.column - let startPos = startCharIndex - 1 - let nextSql = null - let inQuote = false - let singleLineCommentStack: number[] = [] - let multiLineCommentStack: number[] = [] - let inSingleLineComment = false - let inMultiLineComment = false - - while ( - startCharIndex < text.length && - (text[startCharIndex] === "\n" || text[startCharIndex] === " ") - ) { - if (text[startCharIndex] === "\n") { - row++ - startRow++ - column = 1 - startCol = 1 - } else { - column++ - startCol++ - } - startCharIndex++ - } - startPos = startCharIndex - - let i = startCharIndex - for (; i < text.length; i++) { - if (nextSql !== null) { - break - } - - const char = text[i] - - switch (char) { - case ";": { - if (inQuote || inSingleLineComment || inMultiLineComment) { - column++ - break - } - - if ( - row < position.row || - (row === position.row && column < position.column) - ) { - sqlTextStack.push({ - row: startRow, - col: startCol, - position: startPos, - endRow: row, - endCol: column, - limit: i, - }) - startRow = row - startCol = column + 1 - startPos = i + 1 - column++ - } else { - nextSql = { - row: startRow, - col: startCol, - position: startPos, - endRow: row, - endCol: column, - limit: i, - } - } - break - } - - case " ": - case "\t": { - if (startPos === i) { - startRow = row - startCol = column + 1 - startPos = i + 1 - } - - column++ - break - } - - case "\n": { - if (inSingleLineComment) { - inSingleLineComment = false - if (startPos === i - 1) { - startPos = i - startRow = row - startCol = column - } - } - row++ - column = 1 - if (startPos === i) { - startRow = row - startCol = column - startPos = i + 1 - } - break - } - - case "'": { - if (!inMultiLineComment && !inSingleLineComment) { - inQuote = !inQuote - } - column++ - break - } - - case "-": { - if (!inMultiLineComment && !inQuote) { - singleLineCommentStack.push(i) - if (singleLineCommentStack.length === 2) { - if (singleLineCommentStack[0] + 1 === singleLineCommentStack[1]) { - if (startPos === i - 1) { - startPos = i - startRow = row - startCol = column - } - singleLineCommentStack = [] - inSingleLineComment = true - } else { - singleLineCommentStack.shift() - } - } - } - column++ - break - } - - case "/": { - if (!inMultiLineComment && !inSingleLineComment && !inQuote) { - if (multiLineCommentStack.length === 0) { - multiLineCommentStack.push(i) - } else { - multiLineCommentStack = [i] - } - } - if (inMultiLineComment) { - if ( - multiLineCommentStack.length === 1 && - multiLineCommentStack[0] + 1 === i - ) { - if (startPos === i - 1) { - startPos = i + 1 - startRow = row - startCol = column + 1 - } - multiLineCommentStack = [] - inMultiLineComment = false - } - } - column++ - break - } - - case "*": { - if (!inMultiLineComment && !inSingleLineComment) { - if ( - multiLineCommentStack.length === 1 && - multiLineCommentStack[0] + 1 === i - ) { - if (startPos === i - 1) { - startPos = i - startRow = row - startCol = column - } - multiLineCommentStack = [] - inMultiLineComment = true - } else if (multiLineCommentStack.length > 0) { - multiLineCommentStack = [] - } - } - if (inMultiLineComment) { - multiLineCommentStack = [i] - } - column++ - break - } - - default: { - column++ - break - } - } - if ((inSingleLineComment || inMultiLineComment) && startPos === i - 1) { - startPos = i - startRow = row - startCol = column - } - } - - // lastStackItem is the last query that is completed before the current cursor position. - // nextSql is the next query that is not completed before the current cursor position, or started after the current cursor position. - if (!nextSql) { - const sqlText = - startPos === -1 - ? text.substring(startCharIndex) - : text.substring(startPos) - if (sqlText.length > 0) { - nextSql = { - row: startRow, - col: startCol, - position: startPos === -1 ? startCharIndex : startPos, - endRow: row, - endCol: column, - limit: i, - } - } - } - - const filteredSqlTextStack = sqlTextStack.filter((item) => { - return item.row !== item.endRow || item.col !== item.endCol - }) - - const filteredNextSql = - nextSql && - (nextSql.row !== nextSql.endRow || nextSql.col !== nextSql.endCol) - ? nextSql - : null - - return { sqlTextStack: filteredSqlTextStack, nextSql: filteredNextSql } -} - export const getQueriesFromPosition = ( editor: IStandaloneCodeEditor, editorPosition: IPosition, startPosition?: IPosition, + bufferId?: number, ): { sqlTextStack: SqlTextItem[]; nextSql: SqlTextItem | null } => { - const text = editor.getValue({ preserveBOM: false, lineEnding: "\n" }) - - if (!text || !stripSQLComments(text)) { + const model = editor.getModel() + if (!model) { return { sqlTextStack: [], nextSql: null } } @@ -821,20 +524,32 @@ export const getQueriesFromPosition = ( ? { row: startPosition.lineNumber - 1, column: startPosition.column } : undefined - return _getQueriesFromText(text, position, start) + return getQueriesFromModel( + model, + position, + start, + computeParseRange(editor), + bufferId, + ) } export const getQueryFromCursor = ( editor: IStandaloneCodeEditor, + bufferId?: number, ): Request | undefined => { const position = editor.getPosition() const text = editor.getValue({ preserveBOM: false, lineEnding: "\n" }) - if (!text || !stripSQLComments(text) || !position) { + if (!text.trim() || !position) { return } - const { sqlTextStack, nextSql } = getQueriesFromPosition(editor, position) + const { sqlTextStack, nextSql } = getQueriesFromPosition( + editor, + position, + undefined, + bufferId, + ) const normalizedCurrentRow = position.lineNumber - 1 const lastStackItem = @@ -878,9 +593,9 @@ export const getQueryFromCursor = ( endColumn: nextSql!.endCol, } } else if (isInLastStackItemRowRange && isInNextSqlRowRange) { - const lastStackItemEndCol = lastStackItem!.endCol + const nextSqlStartCol = nextSql!.col const normalizedCurrentCol = position.column - if (normalizedCurrentCol > lastStackItemEndCol) { + if (normalizedCurrentCol >= nextSqlStartCol) { return { query: text.substring(nextSql!.position, nextSql!.limit), row: nextSql!.row, @@ -899,15 +614,26 @@ export const getQueryFromCursor = ( } } -export const getAllQueries = (editor: IStandaloneCodeEditor): Request[] => { +export const getAllQueries = ( + editor: IStandaloneCodeEditor, + bufferId?: number, +): Request[] => { + const model = editor.getModel() const position = getLastPosition(editor) const text = editor.getValue({ preserveBOM: false, lineEnding: "\n" }) - if (!text || !stripSQLComments(text) || !position) { + if (!model || !text.trim() || !position) { return [] } - const { sqlTextStack, nextSql } = getQueriesFromPosition(editor, position) + // Full document parse — no viewport scoping + const { sqlTextStack, nextSql } = getQueriesFromModel( + model, + { row: position.lineNumber - 1, column: position.column }, + undefined, + null, + bufferId, + ) const stackQueries = sqlTextStack.map((item) => ({ query: text.substring(item.position, item.limit), row: item.row, @@ -931,9 +657,10 @@ export const getQueriesInRange = ( editor: IStandaloneCodeEditor, startPosition: IPosition, endPosition: IPosition, + bufferId?: number, ): Request[] => { const text = editor.getValue({ preserveBOM: false, lineEnding: "\n" }) - if (!text || !stripSQLComments(text) || !startPosition || !endPosition) { + if (!text.trim() || !startPosition || !endPosition) { return [] } @@ -941,6 +668,7 @@ export const getQueriesInRange = ( editor, endPosition, startPosition, + bufferId, ) const stackQueries = sqlTextStack.map((item) => ({ @@ -982,7 +710,7 @@ export const getQueriesStartingFromLine = ( startLineNumber: startPosition.lineNumber, startColumn: startPosition.column, endLineNumber: endPosition.lineNumber, - endColumn: endPosition.column, + endColumn: endPosition.column + 1, }) queries.push({ @@ -1000,6 +728,7 @@ export const getQueriesStartingFromLine = ( export const getQueryFromSelection = ( editor: IStandaloneCodeEditor, + bufferId?: number, ): Request | undefined => { const model = editor.getModel() if (!model) return @@ -1024,7 +753,7 @@ export const getQueryFromSelection = ( startColumn: startPos.column, endColumn: endPos.column, }) - const parentQuery = getQueryFromCursor(editor) + const parentQuery = getQueryFromCursor(editor, bufferId) if (parentQuery) { return { ...parentQuery, @@ -1041,6 +770,7 @@ export const getQueryFromSelection = ( export const getQueryRequestFromEditor = ( editor: IStandaloneCodeEditor, + bufferId?: number, ): Request | undefined => { let request: Request | undefined const selectedText = getSelectedText(editor) @@ -1049,9 +779,9 @@ export const getQueryRequestFromEditor = ( : undefined if (strippedNormalizedSelectedText) { - request = getQueryFromSelection(editor) + request = getQueryFromSelection(editor, bufferId) } else { - request = getQueryFromCursor(editor) + request = getQueryFromCursor(editor, bufferId) } if (!request) return @@ -1565,6 +1295,7 @@ export const validateQueryAtOffset = ( editor: IStandaloneCodeEditor, queryText: string, offset: number, + bufferId?: number, ): boolean => { const model = editor.getModel() if (!model) return false @@ -1572,18 +1303,19 @@ export const validateQueryAtOffset = ( const totalLength = model.getValueLength() if (offset < 0 || offset >= totalLength) return false - const normalizedQuery = normalizeQueryText(queryText) - const startPos = model.getPositionAt(offset) - const endOffset = Math.min(offset + normalizedQuery.length, totalLength) - const endPos = model.getPositionAt(endOffset) - const textAtOffset = model.getValueInRange({ - startLineNumber: startPos.lineNumber, - startColumn: startPos.column, - endLineNumber: endPos.lineNumber, - endColumn: endPos.column, - }) + const offsetPosition = model.getPositionAt(offset) - return normalizeQueryText(textAtOffset) === normalizedQuery + const queryInEditor = getQueriesInRange( + editor, + offsetPosition, + offsetPosition, + bufferId, + )[0] + if (!queryInEditor) return false + + return ( + normalizeQueryText(queryInEditor.query) === normalizeQueryText(queryText) + ) } export const createQueryKeyFromRequest = ( diff --git a/yarn.lock b/yarn.lock index eda395405..e9908ad6e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2493,10 +2493,10 @@ __metadata: "@questdb/sql-parser@file:../questdb-sql-parser::locator=%40questdb%2Fweb-console%40workspace%3A.": version: 0.1.0 - resolution: "@questdb/sql-parser@file:../questdb-sql-parser#../questdb-sql-parser::hash=30b2f2&locator=%40questdb%2Fweb-console%40workspace%3A." + resolution: "@questdb/sql-parser@file:../questdb-sql-parser#../questdb-sql-parser::hash=daedc3&locator=%40questdb%2Fweb-console%40workspace%3A." dependencies: chevrotain: "npm:^11.1.1" - checksum: 10/515d9fceb8af78edfd7c0675f41e0da81c00be489f6a68267debfeae44e61d7d7e2dd3734daf808f7d074fc29b387ffb1aff87c661b18921c0e6af7bc9401c65 + checksum: 10/c0b40e1582d016b33822aa715022668515fccb457cafed694490b39b23653b2f1d484cb6f72f1ff8d5c84d98a059acb85d79866de335e59d20487edbded57950 languageName: node linkType: hard From 8779b99667ad4917ff2d1c44639a33f1c7218981 Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 17 Feb 2026 02:26:52 +0300 Subject: [PATCH 03/19] add JIT query validation and fix dot qualified autocompletion --- src/scenes/Editor/Monaco/index.tsx | 43 +++++++ .../createSchemaCompletionProvider.ts | 47 ++++--- src/scenes/Editor/Monaco/utils.ts | 119 ++++++++++++++++++ yarn.lock | 4 +- 4 files changed, 194 insertions(+), 19 deletions(-) diff --git a/src/scenes/Editor/Monaco/index.tsx b/src/scenes/Editor/Monaco/index.tsx index 07a9fdb6d..ffdae9ef3 100644 --- a/src/scenes/Editor/Monaco/index.tsx +++ b/src/scenes/Editor/Monaco/index.tsx @@ -50,7 +50,9 @@ import { createSchemaCompletionProvider } from "./questdb-sql" import { Request } from "./utils" import { appendQuery, + applyValidationMarkers, clearModelMarkers, + clearValidationMarkers, findMatches, getErrorRange, getQueryFromCursor, @@ -66,6 +68,7 @@ import { parseQueryKey, createQueryKeyFromRequest, validateQueryAtOffset, + validateQueryJIT, setErrorMarkerForQuery, getQueryStartOffset, getQueriesToRun, @@ -341,6 +344,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 @@ -1071,6 +1075,24 @@ 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(() => { + if (monacoRef.current && editorRef.current) { + const currentBufferId = activeBufferRef.current.id as number + validateQueryJIT( + monacoRef.current, + editorRef.current, + currentBufferId, + () => executionRefs.current[currentBufferId.toString()] || {}, + (q) => quest.validateQuery(q), + ) + } + validationTimeoutRef.current = null + }, 300) }) editor.onDidChangeModel(() => { @@ -1210,6 +1232,16 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { | null = null dispatch(actions.query.setResult(undefined)) + // Clear JIT validation markers and cache — execution result takes over. + // Also cancels any in-flight validation for this buffer. + 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, @@ -1956,6 +1988,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]) @@ -2030,6 +2069,10 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { window.clearTimeout(notificationTimeoutRef.current) } + if (validationTimeoutRef.current) { + window.clearTimeout(validationTimeoutRef.current) + } + glyphWidgetsRef.current.forEach((widget) => { editorRef.current?.removeGlyphMarginWidget(widget) }) diff --git a/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts b/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts index f06d3ba2b..6169419dc 100644 --- a/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts +++ b/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts @@ -62,21 +62,23 @@ const convertToSchemaInfo = ( const toCompletionItem = ( suggestion: Suggestion, range: languages.CompletionItem["range"], -): languages.CompletionItem => ({ - label: - suggestion.detail != null || suggestion.description != null - ? { - label: suggestion.label, - detail: suggestion.detail, - description: suggestion.description, - } - : suggestion.label, - kind: KIND_MAP[suggestion.kind], - insertText: suggestion.insertText, - filterText: suggestion.filterText, - sortText: PRIORITY_MAP[suggestion.priority], - range, -}) +): languages.CompletionItem => { + return { + label: + suggestion.detail != null || suggestion.description != null + ? { + label: suggestion.label, + detail: suggestion.detail, + description: suggestion.description, + } + : suggestion.label, + kind: KIND_MAP[suggestion.kind], + insertText: suggestion.insertText, + filterText: suggestion.filterText, + sortText: PRIORITY_MAP[suggestion.priority], + range, + } +} export const createSchemaCompletionProvider = ( editor: editor.IStandaloneCodeEditor, @@ -136,14 +138,25 @@ export const createSchemaCompletionProvider = ( // don't replace it — insert after it instead. const isOperatorWord = word.word.length > 0 && !/[a-zA-Z0-9_]/.test(word.word[0]) + + // 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(".") + const startColumn = isOperatorWord + ? position.column + : dotIndex >= 0 + ? word.startColumn + dotIndex + 1 + : word.startColumn + const range = { startLineNumber: position.lineNumber, endLineNumber: position.lineNumber, - startColumn: isOperatorWord ? position.column : word.startColumn, + startColumn, endColumn: word.endColumn, } - // Convert parser suggestions to Monaco completion items return { suggestions: suggestions.map((s) => toCompletionItem(s, range)), } diff --git a/src/scenes/Editor/Monaco/utils.ts b/src/scenes/Editor/Monaco/utils.ts index e8d962dfb..2bb0f3be0 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" import { parse } from "@questdb/sql-parser" type IStandaloneCodeEditor = editor.IStandaloneCodeEditor @@ -1386,6 +1387,124 @@ export const setErrorMarkerForQuery = ( monaco.editor.setModelMarkers(model, QuestDBLanguageName, markers) } +const ValidationOwner = "questdb-validation" + +// Per-buffer validation state, persists across tab switches +const validationRefs: Record< + string, + { markers: editor.IMarkerData[]; queryText: string; version: number } +> = {} + +export const clearValidationMarkers = ( + monaco: Monaco, + editor: IStandaloneCodeEditor, + bufferId?: number, +) => { + const model = editor.getModel() + if (model) { + monaco.editor.setModelMarkers(model, ValidationOwner, []) + } + if (bufferId !== undefined) { + delete validationRefs[bufferId.toString()] + } +} + +export const applyValidationMarkers = ( + monaco: Monaco, + editor: IStandaloneCodeEditor, + bufferId: number, +) => { + const model = editor.getModel() + if (!model) return + + const cached = validationRefs[bufferId.toString()] + if (cached) { + monaco.editor.setModelMarkers(model, ValidationOwner, cached.markers) + } +} + +export const validateQueryJIT = ( + monaco: Monaco, + editor: IStandaloneCodeEditor, + bufferId: number, + getBufferExecutions: () => Record, + validateQuery: (query: string) => Promise, +) => { + const model = editor.getModel() + if (!model) return + + const bufferKey = bufferId.toString() + const queryAtCursor = getQueryFromCursor(editor, bufferId) + + if (!queryAtCursor) { + 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 + } + + // Skip if execution result already exists for this query + const queryKey = createQueryKeyFromRequest(editor, queryAtCursor) + const bufferExecutions = getBufferExecutions() + if (bufferExecutions[queryKey]) { + monaco.editor.setModelMarkers(model, ValidationOwner, []) + delete validationRefs[bufferKey] + return + } + + validateQuery(queryText) + .then((result: ValidateQueryResult) => { + const currentModel = editor.getModel() + if (!currentModel || 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(() => { + // Network error — silently ignore + }) +} + // Creates a QueryKey for schema explanation conversations // Uses DDL hash so same schema = same queryKey = cached conversation export const createSchemaQueryKey = ( diff --git a/yarn.lock b/yarn.lock index e9908ad6e..8a13d38d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2493,10 +2493,10 @@ __metadata: "@questdb/sql-parser@file:../questdb-sql-parser::locator=%40questdb%2Fweb-console%40workspace%3A.": version: 0.1.0 - resolution: "@questdb/sql-parser@file:../questdb-sql-parser#../questdb-sql-parser::hash=daedc3&locator=%40questdb%2Fweb-console%40workspace%3A." + resolution: "@questdb/sql-parser@file:../questdb-sql-parser#../questdb-sql-parser::hash=0bd86a&locator=%40questdb%2Fweb-console%40workspace%3A." dependencies: chevrotain: "npm:^11.1.1" - checksum: 10/c0b40e1582d016b33822aa715022668515fccb457cafed694490b39b23653b2f1d484cb6f72f1ff8d5c84d98a059acb85d79866de335e59d20487edbded57950 + checksum: 10/2fb160065ce9c8b4d5c6cc787945684e6a783304e10012121285c16a1d831ce2ac7eba5c6000533a772ef1b737abd38a6caf446d9a9f6430c91dbf3e23e53887 languageName: node linkType: hard From dfbdf7f918b158281524cbdb7dd6c1bc1f04475c Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 17 Feb 2026 02:27:13 +0300 Subject: [PATCH 04/19] submodule --- e2e/questdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/questdb b/e2e/questdb index 3ebc75733..2e4afb25b 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 3ebc75733a4ecdaeef2064b93126ec74d78fda7b +Subproject commit 2e4afb25b126292e7355c058a72e05eceffba959 From e2a26b201ea9ce28f24fe4ede698f8f410592af6 Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 17 Feb 2026 11:31:54 +0300 Subject: [PATCH 05/19] use dot as a commit character --- .../Monaco/questdb-sql/createSchemaCompletionProvider.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts b/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts index 6169419dc..a783a16b2 100644 --- a/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts +++ b/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts @@ -77,6 +77,9 @@ const toCompletionItem = ( filterText: suggestion.filterText, sortText: PRIORITY_MAP[suggestion.priority], range, + ...(suggestion.kind === SuggestionKind.Table && { + commitCharacters: ["."], + }), } } From 3a0b868e6e20f619b6c60825f01a039550591079 Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 23 Feb 2026 16:10:29 +0300 Subject: [PATCH 06/19] fix query separation issue, handle double quotes, adapt to new parser --- e2e/questdb | 2 +- package.json | 2 +- src/scenes/Editor/Monaco/index.tsx | 56 +- .../createSchemaCompletionProvider.ts | 177 +++-- src/scenes/Editor/Monaco/utils.test.ts | 464 ------------ src/scenes/Editor/Monaco/utils.ts | 688 ++++++++---------- src/utils/questdb/client.ts | 6 +- yarn.lock | 10 +- 8 files changed, 444 insertions(+), 961 deletions(-) delete mode 100644 src/scenes/Editor/Monaco/utils.test.ts diff --git a/e2e/questdb b/e2e/questdb index 2e4afb25b..fc051e69e 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 2e4afb25b126292e7355c058a72e05eceffba959 +Subproject commit fc051e69e67284ceb351a0191798306e96c76f6f diff --git a/package.json b/package.json index 9744099d4..d0b0f98bd 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-parser": "file:../questdb-sql-parser", + "@questdb/sql-parser": "0.1.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", diff --git a/src/scenes/Editor/Monaco/index.tsx b/src/scenes/Editor/Monaco/index.tsx index ffdae9ef3..e07171561 100644 --- a/src/scenes/Editor/Monaco/index.tsx +++ b/src/scenes/Editor/Monaco/index.tsx @@ -448,10 +448,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { endColumn: endPosition.column, }) } else { - const queryInCursor = getQueryFromCursor( - editor, - activeBufferRef.current.id as number, - ) + const queryInCursor = getQueryFromCursor(editor) if ( queryInCursor && createQueryKeyFromRequest(editor, queryInCursor) === @@ -552,12 +549,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { } if ( - !validateQueryAtOffset( - editor, - pending.queryText, - pending.startOffset, - activeBufferRef.current.id as number, - ) + !validateQueryAtOffset(editor, pending.queryText, pending.startOffset) ) { return } @@ -596,8 +588,8 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { return } + const queryAtCursor = getQueryFromCursor(editor) const activeBufferId = activeBufferRef.current.id as number - const queryAtCursor = getQueryFromCursor(editor, activeBufferId) const lineMarkingDecorations: editor.IModelDeltaDecoration[] = [] const bufferExecutions = @@ -684,14 +676,12 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { if (!editorValue || !model) { return } - - const activeBufferId = activeBufferRef.current.id as number let queries: Request[] = [] const visibleLines = visibleLinesRef.current if (!visibleLines) { - queries = getAllQueries(editor, activeBufferId) + queries = getAllQueries(editor) } else { const totalLines = model.getLineCount() const bufferSize = 500 @@ -705,14 +695,11 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { column: editor.getModel()?.getLineMaxColumn(endLine) ?? 1, } - queries = getQueriesInRange( - editor, - startPosition, - endPosition, - activeBufferId, - ) + queries = getQueriesInRange(editor, startPosition, endPosition) } + const activeBufferId = activeBufferRef.current.id as number + const allQueryOffsets: { startOffset: number; endOffset: number }[] = [] const newGlyphWidgetIds = new Map() const newGlyphWidgetLineNumbers = new Set() @@ -909,7 +896,6 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { const queriesToRun = getQueriesToRun( editor, queryOffsetsRef.current ?? [], - activeBufferRef.current.id as number, ) queriesToRunRef.current = queriesToRun dispatch(actions.query.setQueriesToRun(queriesToRun)) @@ -1068,7 +1054,6 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { const queriesToRun = getQueriesToRun( editor, queryOffsetsRef.current ?? [], - activeBufferId, ) queriesToRunRef.current = queriesToRun dispatch(actions.query.setQueriesToRun(queriesToRun)) @@ -1088,7 +1073,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { editorRef.current, currentBufferId, () => executionRefs.current[currentBufferId.toString()] || {}, - (q) => quest.validateQuery(q), + (q, signal) => quest.validateQuery(q, signal), ) } validationTimeoutRef.current = null @@ -1190,11 +1175,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { // Initial decoration setup applyGlyphsAndLineMarkings(monaco, editor) - const queriesToRun = getQueriesToRun( - editor, - queryOffsetsRef.current ?? [], - activeBufferRef.current.id as number, - ) + const queriesToRun = getQueriesToRun(editor, queryOffsetsRef.current ?? []) queriesToRunRef.current = queriesToRun dispatch(actions.query.setQueriesToRun(queriesToRun)) @@ -1232,8 +1213,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { | null = null dispatch(actions.query.setResult(undefined)) - // Clear JIT validation markers and cache — execution result takes over. - // Also cancels any in-flight validation for this buffer. + // Clear JIT validation markers — execution result takes over. if (monacoRef.current) { clearValidationMarkers(monacoRef.current, editor, activeBufferId) } @@ -1457,7 +1437,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { isRunningScriptRef.current = true setTabsDisabled(true) - const queries = queriesToRun ?? getAllQueries(editor, activeBufferId) + const queries = queriesToRun ?? getAllQueries(editor) const individualQueryResults: Array = [] editor.updateOptions({ readOnly: true }) @@ -1660,6 +1640,15 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { ) { if (monacoRef?.current) { clearModelMarkers(monacoRef.current, editorRef.current) + clearValidationMarkers( + monacoRef.current, + editorRef.current, + activeBufferRef.current.id as number, + ) + } + if (validationTimeoutRef.current) { + window.clearTimeout(validationTimeoutRef.current) + validationTimeoutRef.current = null } if (monacoRef?.current && editorRef?.current) { @@ -1675,10 +1664,7 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { editorRef.current, aiSuggestionRequestRef.current, ) - : getQueryRequestFromEditor( - editorRef.current, - activeBufferRef.current.id as number, - ) + : getQueryRequestFromEditor(editorRef.current) const isRunningExplain = running === RunningType.EXPLAIN const isAISuggestion = diff --git a/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts b/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts index a783a16b2..ea65095ad 100644 --- a/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts +++ b/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts @@ -1,11 +1,11 @@ import { Table, InformationSchemaColumn } from "../../../../utils" import type { editor, languages } from "monaco-editor" import { CompletionItemKind, CompletionItemPriority } from "./types" -import { findMatches, getQueryFromCursor } from "../utils" import { createAutocompleteProvider, SuggestionKind, SuggestionPriority, + tokenize, type SchemaInfo, type Suggestion, } from "@questdb/sql-parser" @@ -77,12 +77,47 @@ const toCompletionItem = ( filterText: suggestion.filterText, sortText: PRIORITY_MAP[suggestion.priority], range, - ...(suggestion.kind === SuggestionKind.Table && { - commitCharacters: ["."], - }), } } +/** + * Check if cursor is inside a line comment (--) or block comment. + * The parser already handles string literals via its own guard, + * but comments are invisible to the lexer (Lexer.SKIPPED). + */ +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 so quotes inside comments don't confuse us + if (ch === "'") { + i++ + while (i < text.length && text[i] !== "'") i++ + i++ // skip closing quote + continue + } + i++ + } + return false +} + export const createSchemaCompletionProvider = ( editor: editor.IStandaloneCodeEditor, tables: Table[] = [], @@ -98,75 +133,87 @@ export const createSchemaCompletionProvider = ( provideCompletionItems(model, position) { const word = model.getWordUntilPosition(position) + const cursorOffset = model.getOffsetAt(position) + const fullText = model.getValue() - // Get text value in the current line - const textInLine = model.getValueInRange({ - startLineNumber: position.lineNumber, - startColumn: 1, - endLineNumber: position.lineNumber, - endColumn: position.column, - }) - - const isWhitespaceOnly = /^\s*$/.test(textInLine) - const isLineComment = /(-- |--|\/\/ |\/\/)$/gim.test(textInLine) - - if (isWhitespaceOnly || isLineComment) { + // Suppress suggestions inside comments (the parser handles strings itself) + if (isCursorInComment(fullText, cursorOffset)) { return null } - const queryAtCursor = getQueryFromCursor(editor) - - if (queryAtCursor) { - const matches = findMatches(model, queryAtCursor.query) - if (matches.length > 0) { - const cursorMatch = matches.find( - (m) => m.range.startLineNumber === queryAtCursor.row + 1, - ) - - // Calculate cursor offset within the current query - const queryStartOffset = model.getOffsetAt({ - lineNumber: cursorMatch?.range.startLineNumber ?? 1, - column: cursorMatch?.range.startColumn ?? 1, - }) - const cursorOffset = model.getOffsetAt(position) - const relativeCursorOffset = cursorOffset - queryStartOffset - - // Get suggestions from the parser-based provider - const suggestions = autocompleteProvider.getSuggestions( - queryAtCursor.query, - relativeCursorOffset, - ) - - // When the "word" at cursor is an operator (e.g. :: from type cast), - // don't replace it — insert after it instead. - const isOperatorWord = - word.word.length > 0 && !/[a-zA-Z0-9_]/.test(word.word[0]) - - // 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(".") - const startColumn = isOperatorWord - ? position.column - : dotIndex >= 0 - ? word.startColumn + dotIndex + 1 - : word.startColumn - - const range = { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn, - endColumn: word.endColumn, - } - - return { - suggestions: suggestions.map((s) => toCompletionItem(s, range)), + // 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 + + 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 } } } - return null + // 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 + } + + // 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) + + const relativeCursorOffset = cursorOffset - queryStartOffset + + // Get suggestions from the parser-based provider + const suggestions = autocompleteProvider.getSuggestions( + query, + relativeCursorOffset, + ) + + // When the "word" at cursor is an operator (e.g. :: from type cast), + // don't replace it — insert after it instead. + const isOperatorWord = + word.word.length > 0 && !/[a-zA-Z0-9_]/.test(word.word[0]) + + // 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(".") + const startColumn = isOperatorWord + ? position.column + : dotIndex >= 0 + ? word.startColumn + dotIndex + 1 + : word.startColumn + + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn, + endColumn: word.endColumn, + } + + return { + incomplete: true, + suggestions: suggestions.map((s) => toCompletionItem(s, range)), + } }, } diff --git a/src/scenes/Editor/Monaco/utils.test.ts b/src/scenes/Editor/Monaco/utils.test.ts deleted file mode 100644 index 833d5f157..000000000 --- a/src/scenes/Editor/Monaco/utils.test.ts +++ /dev/null @@ -1,464 +0,0 @@ -import { describe, it, expect } from "vitest" -import { getQueriesFromModel } from "./utils" - -/** - * Minimal mock of Monaco's ITextModel for testing getQueriesFromModel - * without a real Monaco editor instance. - */ -const createMockModel = (text: string) => { - // Build line-start offsets: lineStarts[i] = offset of first char of line i - const lineStarts = [0] - for (let i = 0; i < text.length; i++) { - if (text[i] === "\n") lineStarts.push(i + 1) - } - - return { - getValue: () => text, - getOffsetAt: (pos: { lineNumber: number; column: number }) => { - const lineIndex = Math.min(pos.lineNumber - 1, lineStarts.length - 1) - return lineStarts[lineIndex] + (pos.column - 1) - }, - getPositionAt: (offset: number) => { - let lo = 0 - let hi = lineStarts.length - 1 - while (lo < hi) { - const mid = (lo + hi + 1) >> 1 - if (lineStarts[mid] <= offset) lo = mid - else hi = mid - 1 - } - return { lineNumber: lo + 1, column: offset - lineStarts[lo] + 1 } - }, - } as Parameters[0] -} - -/** - * Helper to extract query text strings from getQueriesFromModel result. - */ -const getQueries = ( - text: string, - cursorRow: number, - cursorCol: number, - startRow?: number, - startCol?: number, -) => { - const model = createMockModel(text) - const result = getQueriesFromModel( - model, - { row: cursorRow, column: cursorCol }, - startRow !== undefined && startCol !== undefined - ? { row: startRow, column: startCol } - : undefined, - ) - return { - stack: result.sqlTextStack.map((item) => - text.substring(item.position, item.limit), - ), - next: result.nextSql - ? text.substring(result.nextSql.position, result.nextSql.limit) - : null, - // Raw items for position verification - rawStack: result.sqlTextStack, - rawNext: result.nextSql, - } -} - -describe("getQueriesFromModel", () => { - describe("basic query identification without semicolons", () => { - it("should identify two queries without semicolons", () => { - const text = "SELECT 1\nSELECT 2" - // Cursor at end of text - const result = getQueries(text, 1, 9) - expect(result.stack).toEqual(["SELECT 1"]) - expect(result.next).toBe("SELECT 2") - }) - - it("should identify two queries with semicolons", () => { - const text = "SELECT 1;\nSELECT 2" - const result = getQueries(text, 1, 9) - expect(result.stack).toEqual(["SELECT 1"]) - expect(result.next).toBe("SELECT 2") - }) - - it("should identify three queries without semicolons", () => { - const text = - "SELECT * FROM trades\nCREATE TABLE t1 AS (SELECT * FROM t2)\nSELECT count() FROM orders" - // Cursor at the very end - const result = getQueries(text, 2, 27) - expect(result.stack.length).toBe(2) - expect(result.stack[0]).toBe("SELECT * FROM trades") - expect(result.stack[1]).toBe("CREATE TABLE t1 AS (SELECT * FROM t2)") - expect(result.next).toBe("SELECT count() FROM orders") - }) - - it("should identify DROP TABLE and SELECT without semicolons", () => { - const text = "DROP TABLE IF EXISTS t1\nSELECT * FROM t2" - const result = getQueries(text, 1, 17) - expect(result.stack.length + (result.next ? 1 : 0)).toBe(2) - }) - }) - - describe("incomplete SQL during editing", () => { - it("should treat incomplete SQL as a single statement", () => { - const text = "SELECT * FROM " - const result = getQueries(text, 0, 15) - expect(result.next).toContain("SELECT") - expect(result.next).toContain("FROM") - }) - - it("should handle just SELECT keyword", () => { - const text = "SELECT " - const result = getQueries(text, 0, 8) - expect(result.next).toContain("SELECT") - }) - - it("should handle incomplete WHERE clause", () => { - const text = "SELECT * FROM trades WHERE " - const result = getQueries(text, 0, 27) - const allText = [ - ...result.stack, - ...(result.next ? [result.next] : []), - ].join("") - expect(allText).toContain("SELECT") - expect(allText).toContain("WHERE") - }) - - it("should split valid + incomplete query with semicolons", () => { - const text = "SELECT 1;\nSELECT * FROM " - const result = getQueries(text, 1, 15) - expect(result.stack).toEqual(["SELECT 1"]) - expect(result.next).toContain("SELECT * FROM") - }) - - it("should split valid + incomplete query without semicolons", () => { - const text = "SELECT 1\nSELECT * FROM " - // Cursor at end of text - const result = getQueries(text, 1, 15) - expect(result.stack).toEqual(["SELECT 1"]) - expect(result.next).toContain("SELECT * FROM") - }) - }) - - describe("incomplete SQL mixed with valid queries", () => { - it("should handle three queries with middle incomplete (semicolons)", () => { - const text = "SELECT 1;\nSELECT * FROM ;\nSELECT 2" - // Cursor at end - const result = getQueries(text, 2, 9) - const allQueries = [ - ...result.stack, - ...(result.next ? [result.next] : []), - ] - expect(allQueries.length).toBe(3) - expect(allQueries[0]).toBe("SELECT 1") - expect(allQueries[1]).toContain("SELECT * FROM") - expect(allQueries[2]).toBe("SELECT 2") - }) - - it("should handle incomplete WHERE + valid query (semicolons)", () => { - const text = "SELECT * FROM trades WHERE ;\nSELECT 2" - const result = getQueries(text, 1, 9) - const allQueries = [ - ...result.stack, - ...(result.next ? [result.next] : []), - ] - expect(allQueries.length).toBe(2) - expect(allQueries[0]).toContain("WHERE") - expect(allQueries[1]).toBe("SELECT 2") - }) - - it("should handle incomplete WHERE + valid query (no semicolons)", () => { - const text = "SELECT * FROM trades WHERE \nSELECT 2" - const result = getQueries(text, 1, 9) - const allQueries = [ - ...result.stack, - ...(result.next ? [result.next] : []), - ] - expect(allQueries.length).toBe(2) - expect(allQueries[0]).toContain("WHERE") - expect(allQueries[1]).toBe("SELECT 2") - }) - - it("should handle valid, incomplete, valid (no semicolons)", () => { - const text = "SELECT 1\nSELECT * FROM \nSELECT 2" - // Cursor at end - const result = getQueries(text, 2, 9) - const allQueries = [ - ...result.stack, - ...(result.next ? [result.next] : []), - ] - expect(allQueries.length).toBe(3) - expect(allQueries[0]).toBe("SELECT 1") - expect(allQueries[1]).toContain("SELECT * FROM") - expect(allQueries[2]).toBe("SELECT 2") - }) - }) - - describe("CREATE TABLE AS SELECT (nested SELECT)", () => { - it("should treat CREATE TABLE AS SELECT as a single statement", () => { - const text = "CREATE TABLE t1 AS (\nSELECT * FROM t2\n)" - const result = getQueries(text, 2, 2) - const allQueries = [ - ...result.stack, - ...(result.next ? [result.next] : []), - ] - expect(allQueries.length).toBe(1) - expect(allQueries[0]).toContain("CREATE TABLE") - expect(allQueries[0]).toContain("SELECT * FROM t2") - }) - - it("should handle CREATE TABLE AS SELECT + another query", () => { - const text = "CREATE TABLE t1 AS (\nSELECT * FROM t2\n)\nSELECT 1" - const result = getQueries(text, 3, 9) - const allQueries = [ - ...result.stack, - ...(result.next ? [result.next] : []), - ] - expect(allQueries.length).toBe(2) - expect(allQueries[0]).toContain("CREATE TABLE") - expect(allQueries[1]).toBe("SELECT 1") - }) - }) - - describe("comments", () => { - it("should handle comments between queries", () => { - const text = "SELECT 1\n-- this is a comment\nSELECT 2" - const result = getQueries(text, 2, 9) - const allQueries = [ - ...result.stack, - ...(result.next ? [result.next] : []), - ] - expect(allQueries.length).toBe(2) - expect(allQueries[0]).toBe("SELECT 1") - expect(allQueries[1]).toBe("SELECT 2") - }) - }) - - describe("edge cases", () => { - it("should return empty for empty input", () => { - const result = getQueries("", 0, 1) - expect(result.stack).toEqual([]) - expect(result.next).toBeNull() - }) - - it("should return empty for whitespace-only input", () => { - const result = getQueries(" \n\n ", 1, 1) - expect(result.stack).toEqual([]) - expect(result.next).toBeNull() - }) - - it("should handle double semicolons", () => { - const text = "SELECT 1;;\nSELECT 2" - const result = getQueries(text, 1, 9) - const allQueries = [ - ...result.stack, - ...(result.next ? [result.next] : []), - ] - expect(allQueries.length).toBe(2) - expect(allQueries[0]).toBe("SELECT 1") - expect(allQueries[1]).toBe("SELECT 2") - }) - - it("should handle semicolons in strings", () => { - const text = "SELECT 'hello;world' FROM t1\nSELECT 2" - const result = getQueries(text, 1, 9) - const allQueries = [ - ...result.stack, - ...(result.next ? [result.next] : []), - ] - expect(allQueries.length).toBe(2) - expect(allQueries[0]).toBe("SELECT 'hello;world' FROM t1") - expect(allQueries[1]).toBe("SELECT 2") - }) - - it("should handle a single complete query", () => { - const text = "SELECT * FROM trades" - const result = getQueries(text, 0, 21) - const allQueries = [ - ...result.stack, - ...(result.next ? [result.next] : []), - ] - expect(allQueries.length).toBe(1) - expect(allQueries[0]).toBe("SELECT * FROM trades") - }) - }) - - describe("unparseable syntax (DECLARE with array subscripts)", () => { - it("should split DECLARE + SELECT into two statements", () => { - const text = `DECLARE - @level := insertion_point(bids[2], bid_volume), - @price := bids[1:4][@level] -SELECT - md.timestamp market_time, - @level level, - @price market_price, - cp.timestamp core_time, - cp.bid_price core_price -FROM ( - core_price - WHERE timestamp IN today() - AND symbol = 'GBPUSD' - LIMIT -6 -) cp --- Match the bid to its nearest price within one second. -ASOF JOIN market_data md -ON symbol TOLERANCE 1s - -SELECT * -FROM trades -WHERE symbol IN ('BTC-USDT', 'ETH-USDT') -LATEST ON timestamp PARTITION BY symbol` - - // Cursor on the last line - const lastLineIndex = text.split("\n").length - 1 - const result = getQueries(text, lastLineIndex, 40) - const allQueries = [ - ...result.stack, - ...(result.next ? [result.next] : []), - ] - expect(allQueries.length).toBe(2) - // First statement is the DECLARE + SELECT (parser now handles array subscripts) - expect(allQueries[0]).toContain("DECLARE") - expect(allQueries[0]).toContain("ASOF JOIN") - // Second statement is the standalone SELECT - expect(allQueries[1]).toContain("SELECT *") - expect(allQueries[1]).toContain("LATEST ON timestamp") - }) - }) - - describe("DECLARE + WITH subquery comparison (full SQL)", () => { - it("should split into 2 statements: DECLARE+SELECT and WITH+SELECT", () => { - const text = `DECLARE - @prices := asks[1], - @volumes := asks[2], - @best_price := @prices[1], - @multiplier := 1.01, - @target_price := @multiplier * @best_price, - @rel_vol := @volumes[ - 1:insertion_point(@prices, @target_price) - ] -SELECT timestamp, array_sum(@rel_vol) total_volume -FROM market_data -WHERE timestamp > dateadd('m', -1, now()) -AND symbol='EURUSD' - -WITH yesterday_range AS ( - SELECT - dateadd('d', -1, date_trunc('day', now())) as start_time, - date_trunc('day', now()) as end_time -), -aggregated_data AS ( - SELECT - timestamp, - sum(price * amount) / sum(amount) as weighted_avg_price, - sum(amount) as interval_volume, - sum(price * amount) as interval_notional - FROM - trades - WHERE - symbol = 'BTC-USDT' - AND timestamp >= ( - SELECT - start_time - FROM - yesterday_range - ) - AND timestamp < ( - SELECT - end_time - FROM - yesterday_range - ) SAMPLE BY 10m -) -SELECT - timestamp, - weighted_avg_price, - cumulative_notional / cumulative_volume as cumulative_weighted_avg -FROM - aggregated_data -ORDER BY - timestamp` - - const lastLineIndex = text.split("\n").length - 1 - const result = getQueries(text, lastLineIndex, 40) - const allQueries = [ - ...result.stack, - ...(result.next ? [result.next] : []), - ] - expect(allQueries.length).toBe(2) - // First: DECLARE + SELECT - expect(allQueries[0]).toContain("DECLARE") - expect(allQueries[0]).toContain("array_sum") - expect(allQueries[0]).toContain("EURUSD") - // Second: WITH + SELECT (with subquery comparisons) - expect(allQueries[1]).toContain("WITH") - expect(allQueries[1]).toContain("timestamp >=") - expect(allQueries[1]).toContain("ORDER BY") - }) - }) - - describe("cursor position handling", () => { - it("should put query before cursor in stack", () => { - const text = "SELECT 1;\nSELECT 2;\nSELECT 3" - // Cursor at line 2 (0-based row=2) - const result = getQueries(text, 2, 1) - expect(result.stack.length).toBeGreaterThanOrEqual(2) - expect(result.next).toBe("SELECT 3") - }) - - it("should return query at cursor as nextSql when cursor is on it", () => { - const text = "SELECT 1\nSELECT 2" - // Cursor on line 0, column 5 (inside first query) - const result = getQueries(text, 0, 5) - expect(result.next).toBe("SELECT 1") - }) - }) - - describe("start position filtering", () => { - it("should filter queries before start position", () => { - const text = "SELECT 1;\nSELECT 2;\nSELECT 3" - // Start from line 1, cursor at end - const result = getQueries(text, 2, 9, 1, 1) - // SELECT 1 should be excluded since it's before start - const allQueries = [ - ...result.stack, - ...(result.next ? [result.next] : []), - ] - expect(allQueries).not.toContain("SELECT 1") - expect(allQueries.length).toBe(2) - }) - }) - - describe("position correctness", () => { - it("should have correct row/col for multi-line queries", () => { - const text = "SELECT 1\nSELECT 2" - const model = createMockModel(text) - const result = getQueriesFromModel(model, { row: 1, column: 9 }) - - // First query (in stack): row 0, col 1 - if (result.sqlTextStack.length > 0) { - const first = result.sqlTextStack[0] - expect(first.row).toBe(0) - expect(first.col).toBe(1) - expect(first.position).toBe(0) - } - - // Second query (nextSql): row 1 - if (result.nextSql) { - expect(result.nextSql.row).toBe(1) - expect(result.nextSql.position).toBe(9) - } - }) - - it("should have correct endRow/endCol", () => { - const text = "SELECT 1\nSELECT 2" - const model = createMockModel(text) - const result = getQueriesFromModel(model, { row: 1, column: 9 }) - - if (result.sqlTextStack.length > 0) { - const first = result.sqlTextStack[0] - expect(first.endRow).toBe(0) - // endCol should be at position of '1' - expect(first.limit).toBe(8) // "SELECT 1" is 8 chars - } - }) - }) -}) diff --git a/src/scenes/Editor/Monaco/utils.ts b/src/scenes/Editor/Monaco/utils.ts index 2bb0f3be0..638b81332 100644 --- a/src/scenes/Editor/Monaco/utils.ts +++ b/src/scenes/Editor/Monaco/utils.ts @@ -26,10 +26,8 @@ import type { Monaco } from "@monaco-editor/react" import type { ErrorResult } from "../../../utils" import { hashString } from "../../../utils" import type { ValidateQueryResult } from "../../../utils/questdb" -import { parse } from "@questdb/sql-parser" type IStandaloneCodeEditor = editor.IStandaloneCodeEditor -type ITextModel = editor.ITextModel export const QuestDBLanguageName: string = "questdb-sql" @@ -80,7 +78,6 @@ export const getSelectedText = ( export const getQueriesToRun = ( editor: IStandaloneCodeEditor, queryOffsets: { startOffset: number; endOffset: number }[], - bufferId?: number, ): Request[] => { const model = editor.getModel() if (!model) return [] @@ -88,7 +85,7 @@ export const getQueriesToRun = ( const selection = editor.getSelection() const selectedText = selection ? model.getValueInRange(selection) : undefined if (!selection || !selectedText) { - const queryInCursor = getQueryFromCursor(editor, bufferId) + const queryInCursor = getQueryFromCursor(editor) if (queryInCursor) { return [queryInCursor] } @@ -119,7 +116,6 @@ export const getQueriesToRun = ( editor, model.getPositionAt(firstQueryOffsets.startOffset), model.getPositionAt(lastQueryOffsets.endOffset), - bufferId, ) const requests = queries.map((query) => { const clampedSelection = clampRange(model, selection, { @@ -157,400 +153,308 @@ export const getQueriesToRun = ( return requests.filter(Boolean) as Request[] } -type CSTNode = { - children?: Record - image?: string - startOffset?: number - endOffset?: number - startLine?: number - endLine?: number - startColumn?: number - endColumn?: number -} - -type StatementBoundary = { - startOffset: number - endOffset: number -} +export const getQueriesFromPosition = ( + editor: IStandaloneCodeEditor, + editorPosition: IPosition, + startPosition?: IPosition, +): { sqlTextStack: SqlTextItem[]; nextSql: SqlTextItem | null } => { + const text = editor.getValue({ preserveBOM: false, lineEnding: "\n" }) -let _boundariesCache: { - bufferId: number - version: number - rangeKey: string - boundaries: StatementBoundary[] -} | null = null - -const getTokenBoundaries = ( - node: CSTNode, -): { first: CSTNode | null; last: CSTNode | null } => { - if (!node || typeof node !== "object") return { first: null, last: null } - if (node.image !== undefined && node.startOffset !== undefined) { - return { first: node, last: node } - } - if (node.children) { - let earliest: CSTNode | null = null - let latest: CSTNode | null = null - for (const key of Object.keys(node.children)) { - const children = node.children[key] - if (Array.isArray(children)) { - for (const child of children) { - const { first, last } = getTokenBoundaries(child) - if ( - first && - (earliest === null || first.startOffset! < earliest.startOffset!) - ) { - earliest = first - } - if ( - last && - (latest === null || last.endOffset! > latest.endOffset!) - ) { - latest = last - } - } - } - } - return { first: earliest, last: latest } + if (!text || !stripSQLComments(text)) { + return { sqlTextStack: [], nextSql: null } } - return { first: null, last: null } -} -/** - * Extract statement boundaries from a parse result (with recovery enabled). - * - * The parser uses Chevrotain's error recovery (`recoveryEnabled: true`), - * which means it can produce a partial CST even when some statements have - * syntax errors. Failed statements are marked with `recoveredNode: true`. - * - * This function: - * 1. Extracts boundaries from each CST statement node - * 2. Merges consecutive recovered (error) nodes into a single statement - * 3. Extends recovered regions to cover any tokens skipped during re-sync - */ -const extractStatements = ( - text: string, - result: ReturnType, -): StatementBoundary[] => { - const stmts: (CSTNode & { recoveredNode?: boolean })[] = - (result.cst as CSTNode)?.children?.statement ?? [] - - if (stmts.length === 0) return [] - - // Extract raw boundaries with recovery flag - const raw = stmts - .map((stmt) => { - const { first, last } = getTokenBoundaries(stmt) - if (first && last) { - return { - startOffset: first.startOffset!, - endOffset: last.endOffset ?? last.startOffset!, - recovered: !!stmt.recoveredNode, - } - } - return null - }) - .filter( - ( - s, - ): s is { startOffset: number; endOffset: number; recovered: boolean } => - s !== null, - ) - - if (raw.length === 0) return [] - - // Merge consecutive recovered nodes and fill gaps to next clean statement - const merged: StatementBoundary[] = [] - let i = 0 - while (i < raw.length) { - if (!raw[i].recovered) { - merged.push(raw[i]) - i++ - } else { - // Merge consecutive recovered nodes - const startOffset = raw[i].startOffset - let endOffset = raw[i].endOffset + const position = { + row: editorPosition.lineNumber - 1, + column: editorPosition.column, + } - while (i + 1 < raw.length && raw[i + 1].recovered) { - i++ - endOffset = Math.max(endOffset, raw[i].endOffset) + // Calculate starting position - default to beginning if not provided + const start = startPosition + ? { + row: startPosition.lineNumber - 1, + column: startPosition.column, } - - // Extend to cover gap before next clean statement - if (i + 1 < raw.length) { - const gapEnd = raw[i + 1].startOffset - 1 - if (gapEnd > endOffset) { - // Trim trailing whitespace from the gap - let trimEnd = gapEnd - while (trimEnd > endOffset && /\s/.test(text[trimEnd])) { - trimEnd-- - } - if (trimEnd > endOffset) { - endOffset = trimEnd - } - } + : { row: 0, column: 1 } + + // Convert start position to character index + let startCharIndex = 0 + if (startPosition) { + const lines = text.split("\n") + const maxRow = Math.min(start.row, lines.length - 1) + for (let i = 0; i < maxRow; i++) { + if (lines[i] !== undefined) { + startCharIndex += lines[i].length + 1 // +1 for newline character } - - merged.push({ startOffset, endOffset }) - i++ + } + if (lines[maxRow] !== undefined) { + startCharIndex += Math.min(start.column - 1, lines[maxRow].length) } } - return merged -} - -/** - * Get all statement boundaries from text using the parser with error recovery. - * - * The parser uses Chevrotain's built-in error recovery, which means: - * - Valid SQL: statements are extracted directly from the CST - * - Invalid SQL: the parser recovers by skipping bad tokens and continues, - * producing a partial CST with `recoveredNode` markers - * - * No hardcoded keyword lists or manual splitting heuristics needed. - */ -type ParseRange = { startOffset: number; endOffset: number } - -export const computeParseRange = ( - editor: IStandaloneCodeEditor, -): ParseRange | null => { - const model = editor.getModel() - if (!model) return null - - const visibleRanges = editor.getVisibleRanges() - if (visibleRanges.length === 0) return null - - const totalLines = model.getLineCount() - const startLine = Math.max(1, visibleRanges[0].startLineNumber - 500) - const endLine = Math.min(totalLines, visibleRanges[0].endLineNumber + 500) - return { - startOffset: model.getOffsetAt({ lineNumber: startLine, column: 1 }), - endOffset: model.getOffsetAt({ - lineNumber: endLine, - column: model.getLineMaxColumn(endLine), - }), + let row = start.row + let column = start.column + const sqlTextStack = [] + let startRow = start.row + let startCol = start.column + let startPos = startCharIndex - 1 + let nextSql = null + let inSingleQuote = false + let inDoubleQuote = false + let singleLineCommentStack: number[] = [] + let multiLineCommentStack: number[] = [] + let inSingleLineComment = false + let inMultiLineComment = false + + while ( + startCharIndex < text.length && + (text[startCharIndex] === "\n" || text[startCharIndex] === " ") + ) { + if (text[startCharIndex] === "\n") { + row++ + startRow++ + column = 1 + startCol = 1 + } else { + column++ + startCol++ + } + startCharIndex++ } -} + startPos = startCharIndex -const getStatementBoundariesForRange = ( - text: string, - startOffset: number, - endOffset: number, -): StatementBoundary[] => { - const substring = text.substring(startOffset, endOffset) - if (!substring.trim()) return [] - - const atDocStart = startOffset === 0 - const atDocEnd = endOffset >= text.length - - const raw = extractStatements(substring, parse(substring)) - - // Shift offsets to be document-relative and drop recovered edge statements - const shifted: StatementBoundary[] = [] - for (let i = 0; i < raw.length; i++) { - const b = raw[i] - const touchesStart = b.startOffset === 0 - const touchesEnd = b.endOffset >= substring.length - 1 - - // Drop statements that touch a cut edge (not at document boundary) - // and are the first/last statement — these are likely truncated - if (touchesStart && !atDocStart && i === 0) { - continue - } - if (touchesEnd && !atDocEnd && i === raw.length - 1) { - continue + let i = startCharIndex + for (; i < text.length; i++) { + if (nextSql !== null) { + break } - shifted.push({ - startOffset: b.startOffset + startOffset, - endOffset: b.endOffset + startOffset, - }) - } - - return shifted -} + const char = text[i] + + switch (char) { + case ";": { + if ( + inSingleQuote || + inDoubleQuote || + inSingleLineComment || + inMultiLineComment + ) { + column++ + break + } -const getStatementBoundaries = ( - text: string, - parseRange?: ParseRange | null, - modelVersion?: number, - bufferId?: number, -): StatementBoundary[] => { - if (!text.trim()) return [] - - const rangeKey = parseRange - ? `${parseRange.startOffset}-${parseRange.endOffset}` - : "full" - - if ( - modelVersion !== undefined && - bufferId !== undefined && - _boundariesCache && - _boundariesCache.bufferId === bufferId && - _boundariesCache.version === modelVersion && - _boundariesCache.rangeKey === rangeKey - ) { - return _boundariesCache.boundaries - } + if ( + row < position.row || + (row === position.row && column < position.column) + ) { + sqlTextStack.push({ + row: startRow, + col: startCol, + position: startPos, + endRow: row, + endCol: column, + limit: i, + }) + startRow = row + startCol = column + 1 + startPos = i + 1 + column++ + } else { + nextSql = { + row: startRow, + col: startCol, + position: startPos, + endRow: row, + endCol: column, + limit: i, + } + } + break + } - // Viewport-scoped parsing - let boundaries: StatementBoundary[] - if (parseRange) { - boundaries = getStatementBoundariesForRange( - text, - parseRange.startOffset, - parseRange.endOffset, - ) - } else { - // Full parse (fallback when no viewport is set) - boundaries = extractStatements(text, parse(text)) - } + case " ": + case "\t": { + if (startPos === i) { + startRow = row + startCol = column + 1 + startPos = i + 1 + } - if (modelVersion !== undefined && bufferId !== undefined) { - _boundariesCache = { bufferId, version: modelVersion, rangeKey, boundaries } - } + column++ + break + } - return boundaries -} + case "\n": { + if (inSingleLineComment) { + inSingleLineComment = false + if (startPos === i - 1) { + startPos = i + startRow = row + startCol = column + } + } + row++ + column = 1 + if (startPos === i) { + startRow = row + startCol = column + startPos = i + 1 + } + break + } -/** - * Identify queries in text using the parser, splitting them relative to a cursor position. - * - * @param model - Monaco text model (used for offset↔position conversions) - * @param position - Cursor position (0-based row, 1-based column) - * @param start - Optional start position to filter from (0-based row, 1-based column) - */ -export const getQueriesFromModel = ( - model: ITextModel, - position: { row: number; column: number }, - start?: { row: number; column: number }, - parseRange?: ParseRange | null, - bufferId?: number, -): { sqlTextStack: SqlTextItem[]; nextSql: SqlTextItem | null } => { - const text = model.getValue() - if (!text.trim()) { - return { sqlTextStack: [], nextSql: null } - } + case "'": { + if (!inMultiLineComment && !inSingleLineComment && !inDoubleQuote) { + inSingleQuote = !inSingleQuote + } + column++ + break + } - // Get all statement boundaries from the parser (cached by model version + buffer id) - const boundaries = getStatementBoundaries( - text, - parseRange, - model.getVersionId(), - bufferId, - ) + case '"': { + if (!inMultiLineComment && !inSingleLineComment && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote + } + column++ + break + } - if (boundaries.length === 0) { - return { sqlTextStack: [], nextSql: null } - } + case "-": { + if (!inMultiLineComment && !inSingleQuote && !inDoubleQuote) { + singleLineCommentStack.push(i) + if (singleLineCommentStack.length === 2) { + if (singleLineCommentStack[0] + 1 === singleLineCommentStack[1]) { + if (startPos === i - 1) { + startPos = i + startRow = row + startCol = column + } + singleLineCommentStack = [] + inSingleLineComment = true + } else { + singleLineCommentStack.shift() + } + } + } + column++ + break + } - // Convert cursor position to offset (row is 0-based, lineNumber is 1-based) - const cursorOffset = model.getOffsetAt({ - lineNumber: position.row + 1, - column: position.column, - }) + case "/": { + if ( + !inMultiLineComment && + !inSingleLineComment && + !inSingleQuote && + !inDoubleQuote + ) { + if (multiLineCommentStack.length === 0) { + multiLineCommentStack.push(i) + } else { + multiLineCommentStack = [i] + } + } + if (inMultiLineComment) { + if ( + multiLineCommentStack.length === 1 && + multiLineCommentStack[0] + 1 === i + ) { + if (startPos === i - 1) { + startPos = i + 1 + startRow = row + startCol = column + 1 + } + multiLineCommentStack = [] + inMultiLineComment = false + } + } + column++ + break + } - // Convert start position to offset (if provided) - let startOffset = 0 - if (start) { - startOffset = model.getOffsetAt({ - lineNumber: start.row + 1, - column: start.column, - }) - } + case "*": { + if ( + !inMultiLineComment && + !inSingleLineComment && + !inSingleQuote && + !inDoubleQuote + ) { + if ( + multiLineCommentStack.length === 1 && + multiLineCommentStack[0] + 1 === i + ) { + if (startPos === i - 1) { + startPos = i + startRow = row + startCol = column + } + multiLineCommentStack = [] + inMultiLineComment = true + } else if (multiLineCommentStack.length > 0) { + multiLineCommentStack = [] + } + } + if (inMultiLineComment) { + multiLineCommentStack = [i] + } + column++ + break + } - // Convert boundaries to SqlTextItems, filtering by start position - const items: SqlTextItem[] = boundaries - .filter((b) => b.endOffset >= startOffset) - .map((b) => { - const startPos = model.getPositionAt(b.startOffset) - const endPos = model.getPositionAt(b.endOffset) - return { - row: startPos.lineNumber - 1, - col: startPos.column, - position: b.startOffset, - endRow: endPos.lineNumber - 1, - endCol: endPos.column, - limit: b.endOffset + 1, // limit is exclusive (for text.substring) + default: { + column++ + break } - }) - .filter( - (item) => - item.row !== item.endRow || - item.col !== item.endCol || - item.limit >= item.position + 1, - ) - - // Split into sqlTextStack (before cursor) and nextSql (at/after cursor). - // - // - Queries whose end is strictly before the cursor go into sqlTextStack - // - The first query that the cursor is within or on goes into nextSql - // - If the cursor is past all queries, the last query becomes nextSql - - let nextSqlIndex = -1 - for (let i = 0; i < items.length; i++) { - if (cursorOffset < items[i].limit) { - nextSqlIndex = i - break + } + if ((inSingleLineComment || inMultiLineComment) && startPos === i - 1) { + startPos = i + startRow = row + startCol = column } } - // If no query contains/follows cursor, the last query is nextSql - if (nextSqlIndex === -1 && items.length > 0) { - nextSqlIndex = items.length - 1 - } - - const sqlTextStack = nextSqlIndex > 0 ? items.slice(0, nextSqlIndex) : [] - const nextSql = nextSqlIndex >= 0 ? items[nextSqlIndex] : null - - return { sqlTextStack, nextSql } -} - -export const getQueriesFromPosition = ( - editor: IStandaloneCodeEditor, - editorPosition: IPosition, - startPosition?: IPosition, - bufferId?: number, -): { sqlTextStack: SqlTextItem[]; nextSql: SqlTextItem | null } => { - const model = editor.getModel() - if (!model) { - return { sqlTextStack: [], nextSql: null } + // lastStackItem is the last query that is completed before the current cursor position. + // nextSql is the next query that is not completed before the current cursor position, or started after the current cursor position. + if (!nextSql) { + const sqlText = + startPos === -1 + ? text.substring(startCharIndex) + : text.substring(startPos) + if (sqlText.length > 0) { + nextSql = { + row: startRow, + col: startCol, + position: startPos === -1 ? startCharIndex : startPos, + endRow: row, + endCol: column, + limit: i, + } + } } - const position = { - row: editorPosition.lineNumber - 1, - column: editorPosition.column, - } + const filteredSqlTextStack = sqlTextStack.filter((item) => { + return item.row !== item.endRow || item.col !== item.endCol + }) - const start = startPosition - ? { row: startPosition.lineNumber - 1, column: startPosition.column } - : undefined + const filteredNextSql = + nextSql && + (nextSql.row !== nextSql.endRow || nextSql.col !== nextSql.endCol) + ? nextSql + : null - return getQueriesFromModel( - model, - position, - start, - computeParseRange(editor), - bufferId, - ) + return { sqlTextStack: filteredSqlTextStack, nextSql: filteredNextSql } } export const getQueryFromCursor = ( editor: IStandaloneCodeEditor, - bufferId?: number, ): Request | undefined => { const position = editor.getPosition() const text = editor.getValue({ preserveBOM: false, lineEnding: "\n" }) - if (!text.trim() || !position) { + if (!text || !stripSQLComments(text) || !position) { return } - const { sqlTextStack, nextSql } = getQueriesFromPosition( - editor, - position, - undefined, - bufferId, - ) + const { sqlTextStack, nextSql } = getQueriesFromPosition(editor, position) const normalizedCurrentRow = position.lineNumber - 1 const lastStackItem = @@ -594,9 +498,9 @@ export const getQueryFromCursor = ( endColumn: nextSql!.endCol, } } else if (isInLastStackItemRowRange && isInNextSqlRowRange) { - const nextSqlStartCol = nextSql!.col + const lastStackItemEndCol = lastStackItem!.endCol const normalizedCurrentCol = position.column - if (normalizedCurrentCol >= nextSqlStartCol) { + if (normalizedCurrentCol > lastStackItemEndCol) { return { query: text.substring(nextSql!.position, nextSql!.limit), row: nextSql!.row, @@ -615,26 +519,15 @@ export const getQueryFromCursor = ( } } -export const getAllQueries = ( - editor: IStandaloneCodeEditor, - bufferId?: number, -): Request[] => { - const model = editor.getModel() +export const getAllQueries = (editor: IStandaloneCodeEditor): Request[] => { const position = getLastPosition(editor) const text = editor.getValue({ preserveBOM: false, lineEnding: "\n" }) - if (!model || !text.trim() || !position) { + if (!text || !stripSQLComments(text) || !position) { return [] } - // Full document parse — no viewport scoping - const { sqlTextStack, nextSql } = getQueriesFromModel( - model, - { row: position.lineNumber - 1, column: position.column }, - undefined, - null, - bufferId, - ) + const { sqlTextStack, nextSql } = getQueriesFromPosition(editor, position) const stackQueries = sqlTextStack.map((item) => ({ query: text.substring(item.position, item.limit), row: item.row, @@ -658,10 +551,9 @@ export const getQueriesInRange = ( editor: IStandaloneCodeEditor, startPosition: IPosition, endPosition: IPosition, - bufferId?: number, ): Request[] => { const text = editor.getValue({ preserveBOM: false, lineEnding: "\n" }) - if (!text.trim() || !startPosition || !endPosition) { + if (!text || !stripSQLComments(text) || !startPosition || !endPosition) { return [] } @@ -669,7 +561,6 @@ export const getQueriesInRange = ( editor, endPosition, startPosition, - bufferId, ) const stackQueries = sqlTextStack.map((item) => ({ @@ -729,7 +620,6 @@ export const getQueriesStartingFromLine = ( export const getQueryFromSelection = ( editor: IStandaloneCodeEditor, - bufferId?: number, ): Request | undefined => { const model = editor.getModel() if (!model) return @@ -754,7 +644,7 @@ export const getQueryFromSelection = ( startColumn: startPos.column, endColumn: endPos.column, }) - const parentQuery = getQueryFromCursor(editor, bufferId) + const parentQuery = getQueryFromCursor(editor) if (parentQuery) { return { ...parentQuery, @@ -771,7 +661,6 @@ export const getQueryFromSelection = ( export const getQueryRequestFromEditor = ( editor: IStandaloneCodeEditor, - bufferId?: number, ): Request | undefined => { let request: Request | undefined const selectedText = getSelectedText(editor) @@ -780,9 +669,9 @@ export const getQueryRequestFromEditor = ( : undefined if (strippedNormalizedSelectedText) { - request = getQueryFromSelection(editor, bufferId) + request = getQueryFromSelection(editor) } else { - request = getQueryFromCursor(editor, bufferId) + request = getQueryFromCursor(editor) } if (!request) return @@ -1296,7 +1185,6 @@ export const validateQueryAtOffset = ( editor: IStandaloneCodeEditor, queryText: string, offset: number, - bufferId?: number, ): boolean => { const model = editor.getModel() if (!model) return false @@ -1310,7 +1198,6 @@ export const validateQueryAtOffset = ( editor, offsetPosition, offsetPosition, - bufferId, )[0] if (!queryInEditor) return false @@ -1389,12 +1276,13 @@ export const setErrorMarkerForQuery = ( const ValidationOwner = "questdb-validation" -// Per-buffer validation state, persists across tab switches const validationRefs: Record< string, { markers: editor.IMarkerData[]; queryText: string; version: number } > = {} +const validationControllers: Record = {} + export const clearValidationMarkers = ( monaco: Monaco, editor: IStandaloneCodeEditor, @@ -1405,7 +1293,10 @@ export const clearValidationMarkers = ( monaco.editor.setModelMarkers(model, ValidationOwner, []) } if (bufferId !== undefined) { - delete validationRefs[bufferId.toString()] + const bufferKey = bufferId.toString() + delete validationRefs[bufferKey] + validationControllers[bufferKey]?.abort() + delete validationControllers[bufferKey] } } @@ -1428,15 +1319,20 @@ export const validateQueryJIT = ( editor: IStandaloneCodeEditor, bufferId: number, getBufferExecutions: () => Record, - validateQuery: (query: string) => Promise, + validateQuery: ( + query: string, + signal: AbortSignal, + ) => Promise, ) => { const model = editor.getModel() if (!model) return const bufferKey = bufferId.toString() - const queryAtCursor = getQueryFromCursor(editor, bufferId) + const queryAtCursor = getQueryFromCursor(editor) if (!queryAtCursor) { + validationControllers[bufferKey]?.abort() + delete validationControllers[bufferKey] monaco.editor.setModelMarkers(model, ValidationOwner, []) delete validationRefs[bufferKey] return @@ -1455,13 +1351,24 @@ export const validateQueryJIT = ( 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 } - validateQuery(queryText) + // 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) { + delete validationControllers[bufferKey] + } + const currentModel = editor.getModel() if (!currentModel || currentModel.getVersionId() !== version) return @@ -1501,7 +1408,10 @@ export const validateQueryJIT = ( } }) .catch(() => { - // Network error — silently ignore + // Abort or network error — silently ignore + if (validationControllers[bufferKey] === controller) { + delete validationControllers[bufferKey] + } }) } 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 13b64515a..bc79eca70 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2491,12 +2491,12 @@ __metadata: languageName: node linkType: hard -"@questdb/sql-parser@file:../questdb-sql-parser::locator=%40questdb%2Fweb-console%40workspace%3A.": - version: 0.1.0 - resolution: "@questdb/sql-parser@file:../questdb-sql-parser#../questdb-sql-parser::hash=0bd86a&locator=%40questdb%2Fweb-console%40workspace%3A." +"@questdb/sql-parser@npm:0.1.1": + version: 0.1.1 + resolution: "@questdb/sql-parser@npm:0.1.1" dependencies: chevrotain: "npm:^11.1.1" - checksum: 10/2fb160065ce9c8b4d5c6cc787945684e6a783304e10012121285c16a1d831ce2ac7eba5c6000533a772ef1b737abd38a6caf446d9a9f6430c91dbf3e23e53887 + checksum: 10/cd2812fc596e47d4a25b1ca04a4acfe5c49c2fb3ac66cb67663e6afde20cab2b2e7f7defa06fa996c3606437908a7934389256b2c50aea125cdb36df7455b3ba languageName: node linkType: hard @@ -2518,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-parser": "file:../questdb-sql-parser" + "@questdb/sql-parser": "npm:0.1.1" "@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" From 751cff74d61b368c67c99a7e31fae91708f2d0c3 Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 23 Feb 2026 16:16:59 +0300 Subject: [PATCH 07/19] increase bundlewatch limit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d0b0f98bd..e1b8a8acf 100644 --- a/package.json +++ b/package.json @@ -178,7 +178,7 @@ }, { "path": "dist/assets/index-*.js", - "maxSize": "2.5MB", + "maxSize": "3MB", "compression": "none" }, { From 04d88daf4e383b5eca0b32b3b54ae9435f413816 Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 23 Feb 2026 16:17:38 +0300 Subject: [PATCH 08/19] submodule --- e2e/questdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/questdb b/e2e/questdb index fc051e69e..2a00bb425 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit fc051e69e67284ceb351a0191798306e96c76f6f +Subproject commit 2a00bb42532039f2bc9881b4c6d2c44d5eb53887 From d1b3623b3e108c5d9a476f78266166ce90e32e0f Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 23 Feb 2026 17:22:47 +0300 Subject: [PATCH 09/19] remove leftover index calculation --- src/scenes/Editor/Monaco/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scenes/Editor/Monaco/utils.ts b/src/scenes/Editor/Monaco/utils.ts index 638b81332..25e965b2d 100644 --- a/src/scenes/Editor/Monaco/utils.ts +++ b/src/scenes/Editor/Monaco/utils.ts @@ -602,7 +602,7 @@ export const getQueriesStartingFromLine = ( startLineNumber: startPosition.lineNumber, startColumn: startPosition.column, endLineNumber: endPosition.lineNumber, - endColumn: endPosition.column + 1, + endColumn: endPosition.column, }) queries.push({ From c96e4f3a3074b8c4bb6721ccb94df933a201c28f Mon Sep 17 00:00:00 2001 From: emrberk Date: Mon, 23 Feb 2026 18:16:00 +0300 Subject: [PATCH 10/19] autocomplete test fixes --- e2e/tests/console/editor.spec.js | 23 +++++++--------- .../createSchemaCompletionProvider.ts | 26 ++++++++++++++++--- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/e2e/tests/console/editor.spec.js b/e2e/tests/console/editor.spec.js index 31e073662..8898b5706 100644 --- a/e2e/tests/console/editor.spec.js +++ b/e2e/tests/console/editor.spec.js @@ -654,7 +654,7 @@ describe("autocomplete", () => { const assertFrom = () => cy.getAutocomplete().within(() => { cy.getMonacoListRow() - .should("have.length", 4) + .should("have.length", 1) .eq(0) .should("contain", "FROM") }) @@ -681,34 +681,31 @@ 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.getAutocomplete().should("be.visible") + cy.typeQuery("{enter}") cy.typeQuery(", public") - cy.getAutocomplete().should("contain", "public").eq(0).click() + cy.getAutocomplete().should("be.visible") + cy.typeQuery("{enter}") cy.typeQuery(" ") - cy.getAutocomplete().should("not.be.visible") + cy.getAutocomplete().should("contain", "FROM") + cy.clearEditor() }) it("should suggest correct columns on 'where' filter", () => { cy.typeQuery("select * from my_secrets where ") - cy.getAutocomplete() - .should("contain", "secret") - .should("not.contain", "public") - .clearEditor() + 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", "my_publics.public") - .should("contain", "my_secrets.secret") + .should("contain", "public") + .should("contain", "secret") .clearEditor() }) diff --git a/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts b/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts index ea65095ad..f99ccd69a 100644 --- a/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts +++ b/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts @@ -59,21 +59,35 @@ const convertToSchemaInfo = ( * For columns, uses CompletionItemLabel to show table names inline * and data type on the right side. */ +const UPPERCASE_KINDS = new Set([ + SuggestionKind.Keyword, + SuggestionKind.Operator, + SuggestionKind.DataType, +]) + const toCompletionItem = ( suggestion: Suggestion, range: languages.CompletionItem["range"], ): 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 + return { label: suggestion.detail != null || suggestion.description != null ? { - label: suggestion.label, + label, detail: suggestion.detail, description: suggestion.description, } - : suggestion.label, + : label, kind: KIND_MAP[suggestion.kind], - insertText: suggestion.insertText, + insertText, filterText: suggestion.filterText, sortText: PRIORITY_MAP[suggestion.priority], range, @@ -141,6 +155,12 @@ export const createSchemaCompletionProvider = ( return null } + const charBeforeCursor = + cursorOffset > 0 ? fullText[cursorOffset - 1] : "" + if (charBeforeCursor === "(") { + return null + } + // 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 From 03b2376f2cd0e9268f47118b51c5a61d92cd79df Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 24 Feb 2026 01:12:40 +0300 Subject: [PATCH 11/19] restrict suggestions to words, remove newline autocomplete trigger and parantheses from word definition --- e2e/questdb | 2 +- src/scenes/Editor/Monaco/questdb-sql/conf.ts | 2 +- .../questdb-sql/createSchemaCompletionProvider.ts | 15 +++++++++------ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/e2e/questdb b/e2e/questdb index 2a00bb425..3c1232493 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 2a00bb42532039f2bc9881b4c6d2c44d5eb53887 +Subproject commit 3c123249379dac926e15fc786bf7b3741a2ac1d0 diff --git a/src/scenes/Editor/Monaco/questdb-sql/conf.ts b/src/scenes/Editor/Monaco/questdb-sql/conf.ts index 5c9227857..17284adba 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 f99ccd69a..17cb31786 100644 --- a/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts +++ b/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts @@ -143,7 +143,7 @@ export const createSchemaCompletionProvider = ( const completionProvider: languages.CompletionItemProvider = { triggerCharacters: - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\n .":'.split(""), + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz .":'.split(""), provideCompletionItems(model, position) { const word = model.getWordUntilPosition(position) @@ -201,11 +201,14 @@ export const createSchemaCompletionProvider = ( const relativeCursorOffset = cursorOffset - queryStartOffset - // Get suggestions from the parser-based provider - const suggestions = autocompleteProvider.getSuggestions( - query, - relativeCursorOffset, - ) + // Get suggestions from the parser-based provider. + // Filter out punctuation-only suggestions (e.g. "(", ")", ";") — + // the parser may suggest structural tokens as the next expected symbol, + // but autocompleting them causes issues (e.g. accepting "(" via Enter + // when the user just wants a newline). + const suggestions = autocompleteProvider + .getSuggestions(query, relativeCursorOffset) + .filter((s) => /[a-zA-Z0-9_]/.test(s.insertText)) // When the "word" at cursor is an operator (e.g. :: from type cast), // don't replace it — insert after it instead. From aedc5728727f35867ba82e8fd21f29b731b3624e Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 24 Feb 2026 02:50:43 +0300 Subject: [PATCH 12/19] add web console autocomplete tests --- e2e/tests/console/editor.spec.js | 75 ++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 8 deletions(-) diff --git a/e2e/tests/console/editor.spec.js b/e2e/tests/console/editor.spec.js index 8898b5706..3c44bc749 100644 --- a/e2e/tests/console/editor.spec.js +++ b/e2e/tests/console/editor.spec.js @@ -709,6 +709,73 @@ describe("autocomplete", () => { .clearEditor() }) + 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 tables in second statement of multi-statement buffer", () => { + cy.typeQuery("select * from my_secrets;{enter}select * from ") + cy.getAutocomplete() + .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) => { @@ -727,14 +794,6 @@ 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) From e7e6868ef590c30ebaee39adb155c664ba22cf7c Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 24 Feb 2026 02:50:58 +0300 Subject: [PATCH 13/19] submodule --- e2e/questdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/questdb b/e2e/questdb index 3c1232493..f35396454 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 3c123249379dac926e15fc786bf7b3741a2ac1d0 +Subproject commit f353964544677e0b85cf6106ab3a08d1e5b7b6fc From ebb1bbc9bd84b69e7ace992389ae648927df20ba Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 24 Feb 2026 03:15:10 +0300 Subject: [PATCH 14/19] include table details test fix --- e2e/tests/console/tableDetails.spec.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/e2e/tests/console/tableDetails.spec.js b/e2e/tests/console/tableDetails.spec.js index 0cc29e8cc..b4824ec29 100644 --- a/e2e/tests/console/tableDetails.spec.js +++ b/e2e/tests/console/tableDetails.spec.js @@ -694,7 +694,8 @@ describe("TableDetailsDrawer", () => { "contain", "AI Assistant is not configured", ) - cy.realPress("Escape") + cy.getByDataHook("table-details-tab-monitoring").realHover() + cy.wait(200) cy.getByDataHook("table-details-warning-ask-ai").should("be.disabled") cy.getByDataHook("table-details-warning-ask-ai").realHover() @@ -743,7 +744,8 @@ describe("TableDetailsDrawer", () => { "contain", "Schema access is not granted to this model", ) - cy.realPress("Escape") + cy.getByDataHook("table-details-tab-monitoring").realHover() + cy.wait(200) cy.getByDataHook("table-details-warning-ask-ai").should("be.disabled") cy.getByDataHook("table-details-warning-ask-ai").realHover() From cd4c08a5e4f4721adb0f982c9fb2bf3dc3c7ed16 Mon Sep 17 00:00:00 2001 From: emrberk Date: Wed, 25 Feb 2026 15:36:56 +0300 Subject: [PATCH 15/19] bump parser version --- e2e/questdb | 2 +- package.json | 2 +- yarn.lock | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/e2e/questdb b/e2e/questdb index f35396454..8ab362f2d 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit f353964544677e0b85cf6106ab3a08d1e5b7b6fc +Subproject commit 8ab362f2d269f699bdd855870144468aa6e7e5d2 diff --git a/package.json b/package.json index e1b8a8acf..e10ef6c0a 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-parser": "0.1.1", + "@questdb/sql-parser": "0.1.2", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", diff --git a/yarn.lock b/yarn.lock index bc79eca70..1138bf1e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2491,12 +2491,12 @@ __metadata: languageName: node linkType: hard -"@questdb/sql-parser@npm:0.1.1": - version: 0.1.1 - resolution: "@questdb/sql-parser@npm:0.1.1" +"@questdb/sql-parser@npm:0.1.2": + version: 0.1.2 + resolution: "@questdb/sql-parser@npm:0.1.2" dependencies: chevrotain: "npm:^11.1.1" - checksum: 10/cd2812fc596e47d4a25b1ca04a4acfe5c49c2fb3ac66cb67663e6afde20cab2b2e7f7defa06fa996c3606437908a7934389256b2c50aea125cdb36df7455b3ba + checksum: 10/b18452dcac57290ec337529378112841d1a450031b3f81a6b14a86e6a3dfa0f223ce6fe2b7cfabc3e999468b911a25d26df354af16f3a07829a3f7265de802db languageName: node linkType: hard @@ -2518,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-parser": "npm:0.1.1" + "@questdb/sql-parser": "npm:0.1.2" "@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" From 51754d5f9f877d7f99404438939450fd3f0b0a62 Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 3 Mar 2026 16:46:41 +0300 Subject: [PATCH 16/19] add horizon join units support --- src/scenes/Editor/Monaco/questdb-sql/language.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scenes/Editor/Monaco/questdb-sql/language.ts b/src/scenes/Editor/Monaco/questdb-sql/language.ts index 1f6d57a35..72ecf72c1 100644 --- a/src/scenes/Editor/Monaco/questdb-sql/language.ts +++ b/src/scenes/Editor/Monaco/questdb-sql/language.ts @@ -126,7 +126,7 @@ export const language: languages.IMonarchLanguage = { ], ], numbers: [ - [/\b(\d+)([utsmhdwmy])\b/i, "number"], // sampling rate + [/[+-]?\d+[utsmhdwmy]\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 From 617952c0059b4905824bb35376551f0ab75bad56 Mon Sep 17 00:00:00 2001 From: emrberk Date: Tue, 3 Mar 2026 16:47:31 +0300 Subject: [PATCH 17/19] submodule --- e2e/questdb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/questdb b/e2e/questdb index 8ab362f2d..fc24a7f86 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 8ab362f2d269f699bdd855870144468aa6e7e5d2 +Subproject commit fc24a7f86e070c0989fef26e5e26b7a2bf05aac5 From 3f8809f06fee35c225804b63cc9ae0f407987dd5 Mon Sep 17 00:00:00 2001 From: emrberk Date: Wed, 4 Mar 2026 18:05:06 +0300 Subject: [PATCH 18/19] bump parser to 0.1.3, prevent suggesting the same word, auto-trigger new suggestion on tab complete, add nano unit to highlighter --- e2e/questdb | 2 +- package.json | 2 +- src/scenes/Editor/Monaco/index.tsx | 1 + .../createSchemaCompletionProvider.ts | 20 +++++++++++++++++-- .../Editor/Monaco/questdb-sql/language.ts | 2 +- yarn.lock | 10 +++++----- 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/e2e/questdb b/e2e/questdb index fc24a7f86..5c98ed51f 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit fc24a7f86e070c0989fef26e5e26b7a2bf05aac5 +Subproject commit 5c98ed51fc33687d30bd49163fe2d5f7a812ee42 diff --git a/package.json b/package.json index 198841957..56f83f9c8 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-parser": "0.1.2", + "@questdb/sql-parser": "0.1.3", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", diff --git a/src/scenes/Editor/Monaco/index.tsx b/src/scenes/Editor/Monaco/index.tsx index b68d8a8ed..a3396df96 100644 --- a/src/scenes/Editor/Monaco/index.tsx +++ b/src/scenes/Editor/Monaco/index.tsx @@ -2110,6 +2110,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/createSchemaCompletionProvider.ts b/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts index 17cb31786..e9cfc1df2 100644 --- a/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts +++ b/src/scenes/Editor/Monaco/questdb-sql/createSchemaCompletionProvider.ts @@ -87,10 +87,14 @@ const toCompletionItem = ( } : label, kind: KIND_MAP[suggestion.kind], - insertText, + insertText: insertText + " ", filterText: suggestion.filterText, sortText: PRIORITY_MAP[suggestion.priority], range, + command: { + id: "editor.action.triggerSuggest", + title: "Re-trigger suggestions", + }, } } @@ -233,9 +237,21 @@ export const createSchemaCompletionProvider = ( 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 = isOperatorWord + ? "" + : dotIndex >= 0 + ? word.word.substring(dotIndex + 1) + : word.word + + const filtered = suggestions.filter( + (s) => s.insertText.toUpperCase() !== currentWord.toUpperCase(), + ) + return { incomplete: true, - suggestions: suggestions.map((s) => toCompletionItem(s, range)), + suggestions: filtered.map((s) => toCompletionItem(s, range)), } }, } diff --git a/src/scenes/Editor/Monaco/questdb-sql/language.ts b/src/scenes/Editor/Monaco/questdb-sql/language.ts index 72ecf72c1..2fbc2608c 100644 --- a/src/scenes/Editor/Monaco/questdb-sql/language.ts +++ b/src/scenes/Editor/Monaco/questdb-sql/language.ts @@ -126,7 +126,7 @@ export const language: languages.IMonarchLanguage = { ], ], numbers: [ - [/[+-]?\d+[utsmhdwmy]\b/i, "number"], // sampling rate/ horizons + [/[+-]?\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/yarn.lock b/yarn.lock index 72b97507f..367fad1f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2491,12 +2491,12 @@ __metadata: languageName: node linkType: hard -"@questdb/sql-parser@npm:0.1.2": - version: 0.1.2 - resolution: "@questdb/sql-parser@npm:0.1.2" +"@questdb/sql-parser@npm:0.1.3": + version: 0.1.3 + resolution: "@questdb/sql-parser@npm:0.1.3" dependencies: chevrotain: "npm:^11.1.1" - checksum: 10/b18452dcac57290ec337529378112841d1a450031b3f81a6b14a86e6a3dfa0f223ce6fe2b7cfabc3e999468b911a25d26df354af16f3a07829a3f7265de802db + checksum: 10/dfa079c6789153b6b188b926efa75d04d24a45bc2055369aa3b96de2928c7f4ab75ac76c99f179f90bb1a7744c37b0a7dc70579b6eabd1c5ef5bdf3ee84b38cd languageName: node linkType: hard @@ -2518,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-parser": "npm:0.1.2" + "@questdb/sql-parser": "npm:0.1.3" "@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" From f7f11ef794a3b7cfafc18f7cb4f4f7d637f73c27 Mon Sep 17 00:00:00 2001 From: emrberk Date: Thu, 12 Mar 2026 18:39:22 +0300 Subject: [PATCH 19/19] update tests --- e2e/questdb | 2 +- e2e/tests/console/editor.spec.js | 36 +++++++++++++++++++++++++++----- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/e2e/questdb b/e2e/questdb index 5c98ed51f..c5dc7a1eb 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 5c98ed51fc33687d30bd49163fe2d5f7a812ee42 +Subproject commit c5dc7a1eb1533a6971322e69515278e711ec4ec7 diff --git a/e2e/tests/console/editor.spec.js b/e2e/tests/console/editor.spec.js index a0bf7b179..980316f39 100644 --- a/e2e/tests/console/editor.spec.js +++ b/e2e/tests/console/editor.spec.js @@ -658,10 +658,10 @@ describe("autocomplete", () => { .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() }) @@ -685,13 +685,12 @@ describe("autocomplete", () => { }) it("should suggest columns on SELECT only when applicable", () => { - cy.typeQuery("select secret") + cy.typeQuery("select secre") cy.getAutocomplete().should("be.visible") cy.typeQuery("{enter}") - cy.typeQuery(", public") + cy.typeQuery(", publi") cy.getAutocomplete().should("be.visible") cy.typeQuery("{enter}") - cy.typeQuery(" ") cy.getAutocomplete().should("contain", "FROM") cy.clearEditor() }) @@ -726,6 +725,33 @@ describe("autocomplete", () => { 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()