From 52cb4052cea297f53f8c3e359747f6c7a08d3cbe Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Wed, 9 Jul 2025 22:33:32 +0100 Subject: [PATCH] Query for functions (#18214) --- web-console/script/create-sql-docs.mjs | 48 +- .../src/ace-completions/make-doc-html.ts | 4 +- .../src/ace-completions/sql-completions.ts | 50 +- web-console/src/ace-modes/dsql.ts | 231 ++++---- web-console/src/ace-modes/hjson.ts | 532 +++++++++--------- web-console/src/bootstrap/ace.ts | 2 - web-console/src/console-application.tsx | 130 +++-- .../src/contexts/sql-functions-context.tsx | 44 ++ web-console/src/helpers/capabilities.ts | 72 ++- .../control-pane/expression-menu.tsx | 1 + .../measure-dialog/measure-dialog.tsx | 1 + .../components/sql-input/sql-input.tsx | 187 +++--- .../flexible-query-input.tsx | 479 ++++++++-------- .../workbench-view/query-tab/query-tab.tsx | 2 +- 14 files changed, 974 insertions(+), 809 deletions(-) create mode 100644 web-console/src/contexts/sql-functions-context.tsx diff --git a/web-console/script/create-sql-docs.mjs b/web-console/script/create-sql-docs.mjs index b14055341a0d..23abc737771b 100755 --- a/web-console/script/create-sql-docs.mjs +++ b/web-console/script/create-sql-docs.mjs @@ -27,23 +27,17 @@ const MINIMUM_EXPECTED_NUMBER_OF_FUNCTIONS = 198; const MINIMUM_EXPECTED_NUMBER_OF_DATA_TYPES = 15; const initialFunctionDocs = { - TABLE: [['external', convertMarkdownToHtml('Defines a logical table from an external.')]], - EXTERN: [ - ['inputSource, inputFormat, rowSignature?', convertMarkdownToHtml('Reads external data.')], - ], + TABLE: ['external', convertMarkdownToHtml('Defines a logical table from an external.')], + EXTERN: ['inputSource, inputFormat, rowSignature?', convertMarkdownToHtml('Reads external data.')], TYPE: [ - [ - 'nativeType', - convertMarkdownToHtml( - 'A purely type system modification function what wraps a Druid native type to make it into a SQL type.', - ), - ], + 'nativeType', + convertMarkdownToHtml( + 'A purely type system modification function what wraps a Druid native type to make it into a SQL type.', + ), ], UNNEST: [ - [ - 'arrayExpression', - convertMarkdownToHtml("Unnests ARRAY typed values. The source for UNNEST can be an array type column, or an input that's been transformed into an array, such as with helper functions like `MV_TO_ARRAY` or `ARRAY`.") - ] + 'arrayExpression', + convertMarkdownToHtml("Unnests ARRAY typed values. The source for UNNEST can be an array type column, or an input that's been transformed into an array, such as with helper functions like `MV_TO_ARRAY` or `ARRAY`.") ] }; @@ -97,10 +91,8 @@ const readDoc = async () => { if (functionMatch) { const functionName = functionMatch[1]; const args = sanitizeArguments(functionMatch[2]); - const description = convertMarkdownToHtml(functionMatch[3]); - - functionDocs[functionName] = functionDocs[functionName] || []; - functionDocs[functionName].push([args, description]); + const description = convertMarkdownToHtml(functionMatch[3].trim()); + functionDocs[functionName] = [args, description]; } const dataTypeMatch = line.match(/^\|([A-Z]+)\|([A-Z]+)\|([^|]*)\|([^|]*)\|$/); @@ -146,18 +138,18 @@ const readDoc = async () => { // This file is auto generated and should not be modified // prettier-ignore -export const SQL_DATA_TYPES: Record = ${JSON.stringify( - dataTypeDocs, - null, - 2, - )}; +export const SQL_DATA_TYPES = new Map(Object.entries(${JSON.stringify( + dataTypeDocs, + null, + 2, +)})); // prettier-ignore -export const SQL_FUNCTIONS: Record = ${JSON.stringify( - functionDocs, - null, - 2, - )}; +export const SQL_FUNCTIONS = new Map(Object.entries(${JSON.stringify( + functionDocs, + null, + 2, +)})); `; // eslint-disable-next-line no-undef diff --git a/web-console/src/ace-completions/make-doc-html.ts b/web-console/src/ace-completions/make-doc-html.ts index c5603f4408d8..f9d96cce490a 100644 --- a/web-console/src/ace-completions/make-doc-html.ts +++ b/web-console/src/ace-completions/make-doc-html.ts @@ -23,13 +23,13 @@ import { assemble } from '../utils'; export interface ItemDescription { name: string; syntax?: string; - description: string; + description?: string; } export function makeDocHtml(item: ItemDescription) { return assemble( `
${item.name}
`, item.syntax ? `
${escape(item.syntax)}
` : undefined, - `
${item.description}
`, + item.description ? `
${item.description}
` : undefined, ).join('\n'); } diff --git a/web-console/src/ace-completions/sql-completions.ts b/web-console/src/ace-completions/sql-completions.ts index b8383097b6e9..2cea55183bad 100644 --- a/web-console/src/ace-completions/sql-completions.ts +++ b/web-console/src/ace-completions/sql-completions.ts @@ -22,6 +22,7 @@ import { C, filterMap, N, T } from 'druid-query-toolkit'; import { SQL_CONSTANTS, SQL_DYNAMICS, SQL_KEYWORDS } from '../../lib/keywords'; import { SQL_DATA_TYPES, SQL_FUNCTIONS } from '../../lib/sql-docs'; import { DEFAULT_SERVER_QUERY_CONTEXT } from '../druid-models'; +import type { AvailableFunctions } from '../helpers'; import type { ColumnMetadata } from '../utils'; import { lookupBy, uniq } from '../utils'; @@ -158,8 +159,8 @@ const KNOWN_SQL_PARTS: Record = { ), ...lookupBy(SQL_CONSTANTS, String, () => true), ...lookupBy(SQL_DYNAMICS, String, () => true), - ...lookupBy(Object.values(SQL_DATA_TYPES), String, () => true), - ...lookupBy(Object.values(SQL_FUNCTIONS), String, () => true), + ...lookupBy(Array.from(SQL_DATA_TYPES.keys()), String, () => true), + ...lookupBy(Array.from(SQL_FUNCTIONS.keys()), String, () => true), }; export interface GetSqlCompletionsOptions { @@ -169,6 +170,8 @@ export interface GetSqlCompletionsOptions { prefix: string; columnMetadata?: readonly ColumnMetadata[]; columns?: readonly string[]; + availableSqlFunctions?: AvailableFunctions; + skipAggregates?: boolean; } export function getSqlCompletions({ @@ -178,6 +181,8 @@ export function getSqlCompletions({ prefix, columnMetadata, columns, + availableSqlFunctions, + skipAggregates, }: GetSqlCompletionsOptions): Ace.Completion[] { // We are in a single line comment if (lineBeforePrefix.startsWith('--') || lineBeforePrefix.includes(' --')) { @@ -219,7 +224,7 @@ export function getSqlCompletions({ meta: 'keyword', })), SQL_CONSTANTS.map(v => ({ name: v, value: v, score: 11, meta: 'constant' })), - Object.entries(SQL_DATA_TYPES).map(([name, [runtime, description]]) => { + Array.from(SQL_DATA_TYPES.entries()).map(([name, [runtime, description]]) => { return { name, value: name, @@ -241,11 +246,38 @@ export function getSqlCompletions({ ) { completions = completions.concat( SQL_DYNAMICS.map(v => ({ name: v, value: v, score: 20, meta: 'dynamic' })), - Object.entries(SQL_FUNCTIONS).flatMap(([name, versions]) => { - return versions.map(([args, description]) => { + ); + + // If availableSqlFunctions map is provided, use it; otherwise fall back to static SQL_FUNCTIONS + if (availableSqlFunctions) { + completions = completions.concat( + Array.from(availableSqlFunctions.entries()).flatMap(([name, funcDef]) => { + if (skipAggregates && funcDef.isAggregate) return []; + const description = SQL_FUNCTIONS.get(name)?.[1]; + return funcDef.args.map(args => { + return { + name, + value: funcDef.args.length > 1 ? `${name}(${args})` : name, + score: 30, + meta: funcDef.isAggregate ? 'aggregate' : 'function', + docHTML: makeDocHtml({ name, description, syntax: `${name}(${args})` }), + docText: description, + completer: { + insertMatch: (editor: any, data: any) => { + editor.completer.insertMatch({ value: data.name }); + }, + }, + } as Ace.Completion; + }); + }), + ); + } else { + completions = completions.concat( + Array.from(SQL_FUNCTIONS.entries()).map(([name, argDesc]) => { + const [args, description] = argDesc; return { name, - value: versions.length > 1 ? `${name}(${args})` : name, + value: name, score: 30, meta: 'function', docHTML: makeDocHtml({ name, description, syntax: `${name}(${args})` }), @@ -256,9 +288,9 @@ export function getSqlCompletions({ }, }, } as Ace.Completion; - }); - }), - ); + }), + ); + } } } diff --git a/web-console/src/ace-modes/dsql.ts b/web-console/src/ace-modes/dsql.ts index bad6582f5399..9361a6ef7f2e 100644 --- a/web-console/src/ace-modes/dsql.ts +++ b/web-console/src/ace-modes/dsql.ts @@ -22,123 +22,138 @@ // This file was modified to make the list of keywords more closely adhere to what is found in DruidSQL import ace from 'ace-builds/src-noconflict/ace'; +import { dedupe } from 'druid-query-toolkit'; import { SQL_CONSTANTS, SQL_DYNAMICS, SQL_KEYWORDS } from '../../lib/keywords'; import { SQL_DATA_TYPES, SQL_FUNCTIONS } from '../../lib/sql-docs'; +import type { AvailableFunctions } from '../helpers'; -ace.define( - 'ace/mode/dsql_highlight_rules', - ['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'], - function (acequire: any, exports: any) { - 'use strict'; - - const oop = acequire('../lib/oop'); - const TextHighlightRules = acequire('./text_highlight_rules').TextHighlightRules; - - const SqlHighlightRules = function (this: any) { - // Stuff like: 'with|select|from|where|and|or|group|by|order|limit|having|as|case|' - const keywords = SQL_KEYWORDS.join('|').replace(/\s/g, '|'); - - // Stuff like: 'true|false' - const builtinConstants = SQL_CONSTANTS.join('|'); - - // Stuff like: 'avg|count|first|last|max|min' - const builtinFunctions = SQL_DYNAMICS.concat(Object.keys(SQL_FUNCTIONS)).join('|'); - - // Stuff like: 'int|numeric|decimal|date|varchar|char|bigint|float|double|bit|binary|text|set|timestamp' - const dataTypes = Object.keys(SQL_DATA_TYPES).join('|'); - - const keywordMapper = this.createKeywordMapper( - { - 'support.function': builtinFunctions, - 'keyword': keywords, - 'constant.language': builtinConstants, - 'storage.type': dataTypes, - }, - 'identifier', - true, - ); - - this.$rules = { - start: [ - { - token: 'comment.issue', - regex: '--:ISSUE:.*$', - }, - { - token: 'comment', - regex: '--.*$', - }, - { - token: 'comment', - start: '/\\*', - end: '\\*/', - }, - { - token: 'variable.column', // " quoted reference - regex: '".*?"', - }, - { - token: 'string', // ' string literal - regex: "'.*?'", - }, - { - token: 'constant.numeric', // float - regex: '[+-]?\\d+(?:(?:\\.\\d*)?(?:[eE][+-]?\\d+)?)?\\b', - }, - { - token: keywordMapper, - regex: '[a-zA-Z_$][a-zA-Z0-9_$]*\\b', - }, - { - token: 'keyword.operator', - regex: '\\+|\\-|\\/|\\/\\/|%|<@>|@>|<@|&|\\^|~|<|>|<=|=>|==|!=|<>|=', - }, - { - token: 'paren.lparen', - regex: '[\\(]', - }, - { - token: 'paren.rparen', - regex: '[\\)]', - }, - { - token: 'text', - regex: '\\s+', - }, - ], - }; - this.normalizeRules(); - }; +export function initAceDsqlMode(availableSqlFunctions: AvailableFunctions | undefined) { + ace.define( + 'ace/mode/dsql_highlight_rules', + ['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'], + function (acequire: any, exports: any) { + 'use strict'; + + const oop = acequire('../lib/oop'); + const TextHighlightRules = acequire('./text_highlight_rules').TextHighlightRules; - oop.inherits(SqlHighlightRules, TextHighlightRules); + const SqlHighlightRules = function (this: any) { + // Stuff like: 'with|select|from|where|and|or|group|by|order|limit|having|as|case|' + const keywords = SQL_KEYWORDS.join('|').replace(/\s/g, '|'); - exports.SqlHighlightRules = SqlHighlightRules; - }, -); + // Stuff like: 'true|false' + const builtinConstants = SQL_CONSTANTS.join('|'); -ace.define( - 'ace/mode/dsql', - ['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/dsql_highlight_rules'], - function (acequire: any, exports: any) { - 'use strict'; + // Stuff like: 'avg|count|first|last|max|min' + const builtinFunctions = dedupe([ + ...SQL_DYNAMICS, + ...Array.from(SQL_FUNCTIONS.keys()), + ...(availableSqlFunctions?.keys() || []), + ]).join('|'); - const oop = acequire('../lib/oop'); - const TextMode = acequire('./text').Mode; - const SqlHighlightRules = acequire('./dsql_highlight_rules').SqlHighlightRules; + // Stuff like: 'int|numeric|decimal|date|varchar|char|bigint|float|double|bit|binary|text|set|timestamp' + const dataTypes = Array.from(SQL_DATA_TYPES.keys()).join('|'); - const Mode = function (this: any) { - this.HighlightRules = SqlHighlightRules; - this.$behaviour = this.$defaultBehaviour; - this.$id = 'ace/mode/dsql'; + const keywordMapper = this.createKeywordMapper( + { + 'support.function': builtinFunctions, + 'keyword': keywords, + 'constant.language': builtinConstants, + 'storage.type': dataTypes, + }, + 'identifier', + true, + ); + + this.$rules = { + start: [ + { + token: 'comment.issue', + regex: '--:ISSUE:.*$', + }, + { + token: 'comment', + regex: '--.*$', + }, + { + token: 'comment', + start: '/\\*', + end: '\\*/', + }, + { + token: 'variable.column', // " quoted reference + regex: '".*?"', + }, + { + token: 'string', // ' string literal + regex: "'.*?'", + }, + { + token: 'constant.numeric', // float + regex: '[+-]?\\d+(?:(?:\\.\\d*)?(?:[eE][+-]?\\d+)?)?\\b', + }, + { + token: keywordMapper, + regex: '[a-zA-Z_$][a-zA-Z0-9_$]*\\b', + }, + { + token: 'keyword.operator', + regex: '\\+|\\-|\\/|\\/\\/|%|<@>|@>|<@|&|\\^|~|<|>|<=|=>|==|!=|<>|=', + }, + { + token: 'paren.lparen', + regex: '[\\(]', + }, + { + token: 'paren.rparen', + regex: '[\\)]', + }, + { + token: 'text', + regex: '\\s+', + }, + ], + }; + this.normalizeRules(); + }; - this.lineCommentStart = '--'; - this.getCompletions = () => { - return []; + oop.inherits(SqlHighlightRules, TextHighlightRules); + + exports.SqlHighlightRules = SqlHighlightRules; + }, + ); + + ace.define( + 'ace/mode/dsql', + [ + 'require', + 'exports', + 'module', + 'ace/lib/oop', + 'ace/mode/text', + 'ace/mode/dsql_highlight_rules', + ], + function (acequire: any, exports: any) { + 'use strict'; + + const oop = acequire('../lib/oop'); + const TextMode = acequire('./text').Mode; + const SqlHighlightRules = acequire('./dsql_highlight_rules').SqlHighlightRules; + + const Mode = function (this: any) { + this.HighlightRules = SqlHighlightRules; + this.$behaviour = this.$defaultBehaviour; + this.$id = 'ace/mode/dsql'; + + this.lineCommentStart = '--'; + this.getCompletions = () => { + return []; + }; }; - }; - oop.inherits(Mode, TextMode); + oop.inherits(Mode, TextMode); - exports.Mode = Mode; - }, -); + exports.Mode = Mode; + }, + ); +} diff --git a/web-console/src/ace-modes/hjson.ts b/web-console/src/ace-modes/hjson.ts index 1c58a5c79450..e7e48ede0f04 100644 --- a/web-console/src/ace-modes/hjson.ts +++ b/web-console/src/ace-modes/hjson.ts @@ -24,279 +24,281 @@ import ace from 'ace-builds/src-noconflict/ace'; -ace.define( - 'ace/mode/hjson_highlight_rules', - ['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'], - function (acequire: any, exports: any) { - 'use strict'; +export function initAceHjsonMode() { + ace.define( + 'ace/mode/hjson_highlight_rules', + ['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'], + function (acequire: any, exports: any) { + 'use strict'; - const oop = acequire('../lib/oop'); - const TextHighlightRules = acequire('./text_highlight_rules').TextHighlightRules; + const oop = acequire('../lib/oop'); + const TextHighlightRules = acequire('./text_highlight_rules').TextHighlightRules; - const HjsonHighlightRules = function (this: any) { - this.$rules = { - 'start': [ - { - include: '#comments', - }, - { - include: '#rootObject', - }, - { - include: '#value', - }, - ], - '#array': [ - { - token: 'paren.lparen', - regex: /\[/, - push: [ - { - token: 'paren.rparen', - regex: /\]/, - next: 'pop', - }, - { - include: '#value', - }, - { - include: '#comments', - }, - { - token: 'text', - regex: /,|$/, - }, - { - token: 'invalid.illegal', - regex: /[^\s\]]/, - }, - { - defaultToken: 'array', - }, - ], - }, - ], - '#comments': [ - { - token: ['comment.punctuation', 'comment.line'], - regex: /(#)(.*$)/, - }, - { - token: 'comment.punctuation', - regex: /\/\*/, - push: [ - { - token: 'comment.punctuation', - regex: /\*\//, - next: 'pop', - }, - { - defaultToken: 'comment.block', - }, - ], - }, - { - token: ['comment.punctuation', 'comment.line'], - regex: /(\/\/)(.*$)/, - }, - ], - '#constant': [ - { - token: 'constant', - regex: /\b(?:true|false|null)\b/, - }, - ], - '#keyname': [ - { - token: 'keyword', - regex: /(?:[^,{[}\]\s]+|"(?:[^"\\]|\\.)*")\s*(?=:)/, - }, - ], - '#mstring': [ - { - token: 'string', - regex: /'''/, - push: [ - { - token: 'string', - regex: /'''/, - next: 'pop', - }, - { - defaultToken: 'string', - }, - ], - }, - ], - '#number': [ - { - token: 'constant.numeric', - regex: /-?(?:0|[1-9]\d*)(?:(?:\.\d+)?(?:[eE][+-]?\d+)?)?/, - comment: 'handles integer and decimal numbers', - }, - ], - '#object': [ - { - token: 'paren.lparen', - regex: /\{/, - push: [ - { - token: 'paren.rparen', - regex: /\}/, - next: 'pop', - }, - { - include: '#keyname', - }, - { - include: '#value', - }, - { - token: 'text', - regex: /:/, - }, - { - token: 'text', - regex: /,/, - }, - { - defaultToken: 'paren', - }, - ], - }, - ], - '#rootObject': [ - { - token: 'paren', - regex: /(?=\s*(?:[^,{[}\]\s]+|"(?:[^"\\]|\\.)*")\s*:)/, - push: [ - { - token: 'paren.rparen', - regex: /---none---/, - next: 'pop', - }, - { - include: '#keyname', - }, - { - include: '#value', - }, - { - token: 'text', - regex: /:/, - }, - { - token: 'text', - regex: /,/, - }, - { - defaultToken: 'paren', - }, - ], - }, - ], - '#string': [ - { - token: 'string', - regex: /"/, - push: [ - { - token: 'string', - regex: /"/, - next: 'pop', - }, - { - token: 'constant.language.escape', - regex: /\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4})/, - }, - { - token: 'invalid.illegal', - regex: /\\./, - }, - { - defaultToken: 'string', - }, - ], - }, - ], - '#ustring': [ - { - token: 'string', - regex: /\b[^:,0-9\-{[}\]\s].*$/, - }, - ], - '#value': [ - { - include: '#constant', - }, - { - include: '#number', - }, - { - include: '#string', - }, - { - include: '#array', - }, - { - include: '#object', - }, - { - include: '#comments', - }, - { - include: '#mstring', - }, - { - include: '#ustring', - }, - ], - }; + const HjsonHighlightRules = function (this: any) { + this.$rules = { + 'start': [ + { + include: '#comments', + }, + { + include: '#rootObject', + }, + { + include: '#value', + }, + ], + '#array': [ + { + token: 'paren.lparen', + regex: /\[/, + push: [ + { + token: 'paren.rparen', + regex: /\]/, + next: 'pop', + }, + { + include: '#value', + }, + { + include: '#comments', + }, + { + token: 'text', + regex: /,|$/, + }, + { + token: 'invalid.illegal', + regex: /[^\s\]]/, + }, + { + defaultToken: 'array', + }, + ], + }, + ], + '#comments': [ + { + token: ['comment.punctuation', 'comment.line'], + regex: /(#)(.*$)/, + }, + { + token: 'comment.punctuation', + regex: /\/\*/, + push: [ + { + token: 'comment.punctuation', + regex: /\*\//, + next: 'pop', + }, + { + defaultToken: 'comment.block', + }, + ], + }, + { + token: ['comment.punctuation', 'comment.line'], + regex: /(\/\/)(.*$)/, + }, + ], + '#constant': [ + { + token: 'constant', + regex: /\b(?:true|false|null)\b/, + }, + ], + '#keyname': [ + { + token: 'keyword', + regex: /(?:[^,{[}\]\s]+|"(?:[^"\\]|\\.)*")\s*(?=:)/, + }, + ], + '#mstring': [ + { + token: 'string', + regex: /'''/, + push: [ + { + token: 'string', + regex: /'''/, + next: 'pop', + }, + { + defaultToken: 'string', + }, + ], + }, + ], + '#number': [ + { + token: 'constant.numeric', + regex: /-?(?:0|[1-9]\d*)(?:(?:\.\d+)?(?:[eE][+-]?\d+)?)?/, + comment: 'handles integer and decimal numbers', + }, + ], + '#object': [ + { + token: 'paren.lparen', + regex: /\{/, + push: [ + { + token: 'paren.rparen', + regex: /\}/, + next: 'pop', + }, + { + include: '#keyname', + }, + { + include: '#value', + }, + { + token: 'text', + regex: /:/, + }, + { + token: 'text', + regex: /,/, + }, + { + defaultToken: 'paren', + }, + ], + }, + ], + '#rootObject': [ + { + token: 'paren', + regex: /(?=\s*(?:[^,{[}\]\s]+|"(?:[^"\\]|\\.)*")\s*:)/, + push: [ + { + token: 'paren.rparen', + regex: /---none---/, + next: 'pop', + }, + { + include: '#keyname', + }, + { + include: '#value', + }, + { + token: 'text', + regex: /:/, + }, + { + token: 'text', + regex: /,/, + }, + { + defaultToken: 'paren', + }, + ], + }, + ], + '#string': [ + { + token: 'string', + regex: /"/, + push: [ + { + token: 'string', + regex: /"/, + next: 'pop', + }, + { + token: 'constant.language.escape', + regex: /\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4})/, + }, + { + token: 'invalid.illegal', + regex: /\\./, + }, + { + defaultToken: 'string', + }, + ], + }, + ], + '#ustring': [ + { + token: 'string', + regex: /\b[^:,0-9\-{[}\]\s].*$/, + }, + ], + '#value': [ + { + include: '#constant', + }, + { + include: '#number', + }, + { + include: '#string', + }, + { + include: '#array', + }, + { + include: '#object', + }, + { + include: '#comments', + }, + { + include: '#mstring', + }, + { + include: '#ustring', + }, + ], + }; - this.normalizeRules(); - }; + this.normalizeRules(); + }; - HjsonHighlightRules.metaData = { - fileTypes: ['hjson'], - keyEquivalent: '^~J', - name: 'Hjson', - scopeName: 'source.hjson', - }; + HjsonHighlightRules.metaData = { + fileTypes: ['hjson'], + keyEquivalent: '^~J', + name: 'Hjson', + scopeName: 'source.hjson', + }; - oop.inherits(HjsonHighlightRules, TextHighlightRules); + oop.inherits(HjsonHighlightRules, TextHighlightRules); - exports.HjsonHighlightRules = HjsonHighlightRules; - }, -); + exports.HjsonHighlightRules = HjsonHighlightRules; + }, + ); -ace.define( - 'ace/mode/hjson', - [ - 'require', - 'exports', - 'module', - 'ace/lib/oop', - 'ace/mode/text', - 'ace/mode/hjson_highlight_rules', - ], - function (acequire: any, exports: any) { - 'use strict'; + ace.define( + 'ace/mode/hjson', + [ + 'require', + 'exports', + 'module', + 'ace/lib/oop', + 'ace/mode/text', + 'ace/mode/hjson_highlight_rules', + ], + function (acequire: any, exports: any) { + 'use strict'; - const oop = acequire('../lib/oop'); - const TextMode = acequire('./text').Mode; - const HjsonHighlightRules = acequire('./hjson_highlight_rules').HjsonHighlightRules; + const oop = acequire('../lib/oop'); + const TextMode = acequire('./text').Mode; + const HjsonHighlightRules = acequire('./hjson_highlight_rules').HjsonHighlightRules; - const Mode = function (this: any) { - this.HighlightRules = HjsonHighlightRules; - }; - oop.inherits(Mode, TextMode); + const Mode = function (this: any) { + this.HighlightRules = HjsonHighlightRules; + }; + oop.inherits(Mode, TextMode); - (function (this: any) { - this.lineCommentStart = '//'; - this.blockComment = { start: '/*', end: '*/' }; - this.$id = 'ace/mode/hjson'; - }).call(Mode.prototype); + (function (this: any) { + this.lineCommentStart = '//'; + this.blockComment = { start: '/*', end: '*/' }; + this.$id = 'ace/mode/hjson'; + }).call(Mode.prototype); - exports.Mode = Mode; - }, -); + exports.Mode = Mode; + }, + ); +} diff --git a/web-console/src/bootstrap/ace.ts b/web-console/src/bootstrap/ace.ts index 1d8ea64d06ad..e062d7efdae0 100644 --- a/web-console/src/bootstrap/ace.ts +++ b/web-console/src/bootstrap/ace.ts @@ -20,7 +20,5 @@ import 'ace-builds/src-noconflict/ace'; // Import Ace editor and all the sub com import 'ace-builds/src-noconflict/ext-language_tools'; import 'ace-builds/src-noconflict/ext-searchbox'; import 'ace-builds/src-noconflict/theme-solarized_dark'; -import '../ace-modes/dsql'; -import '../ace-modes/hjson'; import './ace.scss'; diff --git a/web-console/src/console-application.tsx b/web-console/src/console-application.tsx index 94fc3a0d5f23..1af1795b5e75 100644 --- a/web-console/src/console-application.tsx +++ b/web-console/src/console-application.tsx @@ -26,8 +26,12 @@ import { Redirect } from 'react-router'; import { HashRouter, Route, Switch } from 'react-router-dom'; import type { Filter } from 'react-table'; +import { initAceDsqlMode } from './ace-modes/dsql'; +import { initAceHjsonMode } from './ace-modes/hjson'; import { HeaderBar, Loader } from './components'; +import { SqlFunctionsProvider } from './contexts/sql-functions-context'; import type { ConsoleViewId, QueryContext, QueryWithContext } from './druid-models'; +import type { AvailableFunctions } from './helpers'; import { Capabilities, maybeGetClusterCapacity } from './helpers'; import { stringToTableFilters, tableFiltersToString } from './react-table'; import { AppToaster } from './singletons'; @@ -80,6 +84,7 @@ export interface ConsoleApplicationProps { export interface ConsoleApplicationState { capabilities: Capabilities; + availableSqlFunctions?: AvailableFunctions; capabilitiesLoading: boolean; } @@ -87,7 +92,10 @@ export class ConsoleApplication extends React.PureComponent< ConsoleApplicationProps, ConsoleApplicationState > { - private readonly capabilitiesQueryManager: QueryManager; + private readonly capabilitiesQueryManager: QueryManager< + null, + [Capabilities, AvailableFunctions | undefined] + >; static shownServiceNotification() { AppToaster.show({ @@ -126,17 +134,25 @@ export class ConsoleApplication extends React.PureComponent< if (!capabilities) { ConsoleApplication.shownServiceNotification(); - return Capabilities.FULL; + return [Capabilities.FULL, undefined]; } - return await Capabilities.detectCapacity(capabilities); + return Promise.all([ + Capabilities.detectCapacity(capabilities), + Capabilities.detectAvailableSqlFunctions(capabilities), + ]); }, onStateChange: ({ data, loading, error }) => { if (error) { console.error('There was an error retrieving the capabilities', error); } + const capabilities = data?.[0] || Capabilities.FULL; + const availableSqlFunctions = data?.[1]; + initAceDsqlMode(availableSqlFunctions); + initAceHjsonMode(); this.setState({ - capabilities: data || Capabilities.FULL, + capabilities, + availableSqlFunctions, capabilitiesLoading: loading, }); }, @@ -438,7 +454,7 @@ export class ConsoleApplication extends React.PureComponent< }; render() { - const { capabilities, capabilitiesLoading } = this.state; + const { capabilities, availableSqlFunctions, capabilitiesLoading } = this.state; if (capabilitiesLoading) { return ( @@ -450,61 +466,69 @@ export class ConsoleApplication extends React.PureComponent< return ( - -
- - {capabilities.hasCoordinatorAccess() && ( - - )} - {capabilities.hasCoordinatorAccess() && ( + + +
+ + {capabilities.hasCoordinatorAccess() && ( + + )} + {capabilities.hasCoordinatorAccess() && ( + + )} + {capabilities.hasCoordinatorAccess() && ( + + )} + {capabilities.hasCoordinatorAccess() && capabilities.hasMultiStageQueryTask() && ( + + )} + - )} - {capabilities.hasCoordinatorAccess() && ( + + + + + - )} - {capabilities.hasCoordinatorAccess() && capabilities.hasMultiStageQueryTask() && ( - - )} - - - - - - - - - - - - - - - - - {capabilities.hasCoordinatorAccess() && ( - - )} - - {capabilities.hasSql() && ( + + + + + + } + path={['/workbench/:tabId', '/workbench']} + component={this.wrappedWorkbenchView} /> - )} - - -
-
+ {capabilities.hasCoordinatorAccess() && ( + + )} + + {capabilities.hasSql() && ( + } + /> + )} + + +
+
+
+
); } diff --git a/web-console/src/contexts/sql-functions-context.tsx b/web-console/src/contexts/sql-functions-context.tsx new file mode 100644 index 000000000000..8a906263de73 --- /dev/null +++ b/web-console/src/contexts/sql-functions-context.tsx @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type React from 'react'; +import { createContext, useContext } from 'react'; + +import type { AvailableFunctions } from '../helpers'; + +const SqlFunctionsContext = createContext(undefined); + +export interface SqlFunctionsProviderProps { + availableSqlFunctions?: AvailableFunctions; + children: React.ReactNode; +} + +export const SqlFunctionsProvider: React.FC = ({ + availableSqlFunctions, + children, +}) => { + return ( + + {children} + + ); +}; + +export const useAvailableSqlFunctions = () => { + return useContext(SqlFunctionsContext); +}; diff --git a/web-console/src/helpers/capabilities.ts b/web-console/src/helpers/capabilities.ts index 22e840b2745e..4c33f723cfde 100644 --- a/web-console/src/helpers/capabilities.ts +++ b/web-console/src/helpers/capabilities.ts @@ -18,6 +18,7 @@ import type { DruidEngine } from '../druid-models'; import { Api } from '../singletons'; +import { filterMap } from '../utils'; import { maybeGetClusterCapacity } from './index'; @@ -34,6 +35,37 @@ export type CapabilitiesModeExtended = export type QueryType = 'none' | 'nativeOnly' | 'nativeAndSql'; +const FUNCTION_SQL = `SELECT "ROUTINE_NAME", "SIGNATURES", "IS_AGGREGATOR" FROM "INFORMATION_SCHEMA"."ROUTINES" WHERE "SIGNATURES" IS NOT NULL`; + +type FunctionRow = [string, string, string]; + +export interface FunctionsDefinition { + args: string[]; + isAggregate: boolean; +} + +export type AvailableFunctions = Map; + +function functionRowsToMap(functionRows: FunctionRow[]): AvailableFunctions { + return new Map( + filterMap(functionRows, ([ROUTINE_NAME, SIGNATURES, IS_AGGREGATOR]) => { + if (!SIGNATURES) return; + const args = filterMap(SIGNATURES.replace(/'/g, '').split('\n'), sig => { + if (!sig.startsWith(`${ROUTINE_NAME}(`) || !sig.endsWith(')')) return; + return sig.slice(ROUTINE_NAME.length + 1, sig.length - 1); + }); + if (!args.length) return; + return [ + ROUTINE_NAME, + { + args, + isAggregate: IS_AGGREGATOR === 'YES', + }, + ]; + }), + ); +} + export interface CapabilitiesValue { queryType: QueryType; multiStageQueryTask: boolean; @@ -41,6 +73,7 @@ export interface CapabilitiesValue { coordinator: boolean; overlord: boolean; maxTaskSlots?: number; + availableSqlFunctions?: AvailableFunctions; } export class Capabilities { @@ -52,13 +85,6 @@ export class Capabilities { static OVERLORD: Capabilities; static NO_PROXY: Capabilities; - private readonly queryType: QueryType; - private readonly multiStageQueryTask: boolean; - private readonly multiStageQueryDart: boolean; - private readonly coordinator: boolean; - private readonly overlord: boolean; - private readonly maxTaskSlots?: number; - static async detectQueryType(): Promise { // Check SQL endpoint try { @@ -194,6 +220,38 @@ export class Capabilities { }); } + static async detectAvailableSqlFunctions( + capabilities: Capabilities, + ): Promise { + if (!capabilities.hasSql()) return; + + try { + return functionRowsToMap( + ( + await Api.instance.post( + '/druid/v2/sql?capabilities-functions', + { + query: FUNCTION_SQL, + resultFormat: 'array', + context: { timeout: Capabilities.STATUS_TIMEOUT }, + }, + { timeout: Capabilities.STATUS_TIMEOUT }, + ) + ).data, + ); + } catch (e) { + console.error(e); + return; + } + } + + private readonly queryType: QueryType; + private readonly multiStageQueryTask: boolean; + private readonly multiStageQueryDart: boolean; + private readonly coordinator: boolean; + private readonly overlord: boolean; + private readonly maxTaskSlots?: number; + constructor(value: CapabilitiesValue) { this.queryType = value.queryType; this.multiStageQueryTask = value.multiStageQueryTask; diff --git a/web-console/src/views/explore-view/components/control-pane/expression-menu.tsx b/web-console/src/views/explore-view/components/control-pane/expression-menu.tsx index 5c6639929ca3..8e947b39e9b1 100644 --- a/web-console/src/views/explore-view/components/control-pane/expression-menu.tsx +++ b/web-console/src/views/explore-view/components/control-pane/expression-menu.tsx @@ -154,6 +154,7 @@ export const ExpressionMenu = function ExpressionMenu(props: ExpressionMenuProps columns={columns} placeholder="SQL expression" autoFocus + includeAggregates /> diff --git a/web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.tsx b/web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.tsx index 5299a5dca2ec..bdd923cac2b3 100644 --- a/web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.tsx +++ b/web-console/src/views/explore-view/components/resource-pane/measure-dialog/measure-dialog.tsx @@ -85,6 +85,7 @@ export const MeasureDialog = React.memo(function MeasureDialog(props: MeasureDia editorHeight={400} autoFocus showGutter={false} + includeAggregates /> diff --git a/web-console/src/views/explore-view/components/sql-input/sql-input.tsx b/web-console/src/views/explore-view/components/sql-input/sql-input.tsx index f8af1b524871..7a9dca681357 100644 --- a/web-console/src/views/explore-view/components/sql-input/sql-input.tsx +++ b/web-console/src/views/explore-view/components/sql-input/sql-input.tsx @@ -18,15 +18,15 @@ import type { Ace } from 'ace-builds'; import type { Column } from 'druid-query-toolkit'; -import { C } from 'druid-query-toolkit'; import React from 'react'; import AceEditor from 'react-ace'; import { getSqlCompletions } from '../../../../ace-completions/sql-completions'; +import { useAvailableSqlFunctions } from '../../../../contexts/sql-functions-context'; import type { RowColumn } from '../../../../utils'; -import { uniq } from '../../../../utils'; const V_PADDING = 10; +const ACE_THEME = 'solarized_dark'; export interface SqlInputProps { value: string; @@ -36,109 +36,102 @@ export interface SqlInputProps { columns?: readonly Column[]; autoFocus?: boolean; showGutter?: boolean; + includeAggregates?: boolean; } -export class SqlInput extends React.PureComponent { - static aceTheme = 'solarized_dark'; +export const SqlInput = React.forwardRef< + { goToPosition: (rowColumn: RowColumn) => void } | undefined, + SqlInputProps +>(function SqlInput(props, ref) { + const { + value, + onValueChange, + placeholder, + autoFocus, + editorHeight, + showGutter, + columns, + includeAggregates, + } = props; - private aceEditor: Ace.Editor | undefined; + const availableSqlFunctions = useAvailableSqlFunctions(); + const aceEditorRef = React.useRef(); - static getCompletions(columns: readonly Column[], quote: boolean): Ace.Completion[] { - return ([] as Ace.Completion[]).concat( - uniq(columns.map(column => column.name)).map(v => ({ - value: quote ? String(C(v)) : v, - score: 50, - meta: 'column', - })), - ); - } - - constructor(props: SqlInputProps) { - super(props); - this.state = {}; - } - - componentWillUnmount() { - delete this.aceEditor; - } - - private readonly handleChange = (value: string) => { - const { onValueChange } = this.props; - if (!onValueChange) return; - onValueChange(value); - }; - - public goToPosition(rowColumn: RowColumn) { - const { aceEditor } = this; + const goToPosition = React.useCallback((rowColumn: RowColumn) => { + const aceEditor = aceEditorRef.current; if (!aceEditor) return; aceEditor.focus(); // Grab the focus aceEditor.getSelection().moveCursorTo(rowColumn.row, rowColumn.column); - // If we had an end we could also do - // aceEditor.getSelection().selectToPosition({ row: endRow, column: endColumn }); - } + }, []); - render() { - const { value, onValueChange, placeholder, autoFocus, editorHeight, showGutter } = this.props; + React.useImperativeHandle(ref, () => ({ goToPosition }), [goToPosition]); - const getColumns = () => this.props.columns?.map(column => column.name); - const cmp: Ace.Completer[] = [ - { - getCompletions: (_state, session, pos, prefix, callback) => { - const allText = session.getValue(); - const line = session.getLine(pos.row); - const charBeforePrefix = line[pos.column - prefix.length - 1]; - const lineBeforePrefix = line.slice(0, pos.column - prefix.length - 1); - callback( - null, - getSqlCompletions({ - allText, - lineBeforePrefix, - charBeforePrefix, - prefix, - columns: getColumns(), - }), - ); - }, - }, - ]; + const handleChange = React.useCallback( + (value: string) => { + if (!onValueChange) return; + onValueChange(value); + }, + [onValueChange], + ); - return ( - { - editor.renderer.setPadding(V_PADDING); - editor.renderer.setScrollMargin(V_PADDING, V_PADDING, 0, 0); - this.aceEditor = editor; + const handleAceLoad = React.useCallback((editor: Ace.Editor) => { + editor.renderer.setPadding(V_PADDING); + editor.renderer.setScrollMargin(V_PADDING, V_PADDING, 0, 0); + aceEditorRef.current = editor; + }, []); - if (autoFocus) { - editor.focus(); - } - }} - /> - ); - } -} + const getColumns = () => columns?.map(column => column.name); + const cmp: Ace.Completer[] = [ + { + getCompletions: (_state, session, pos, prefix, callback) => { + const allText = session.getValue(); + const line = session.getLine(pos.row); + const charBeforePrefix = line[pos.column - prefix.length - 1]; + const lineBeforePrefix = line.slice(0, pos.column - prefix.length - 1); + callback( + null, + getSqlCompletions({ + allText, + lineBeforePrefix, + charBeforePrefix, + prefix, + columns: getColumns(), + availableSqlFunctions, + skipAggregates: !includeAggregates, + }), + ); + }, + }, + ]; + + return ( + + ); +}); diff --git a/web-console/src/views/workbench-view/flexible-query-input/flexible-query-input.tsx b/web-console/src/views/workbench-view/flexible-query-input/flexible-query-input.tsx index 2966d14d0794..8c67f083577e 100644 --- a/web-console/src/views/workbench-view/flexible-query-input/flexible-query-input.tsx +++ b/web-console/src/views/workbench-view/flexible-query-input/flexible-query-input.tsx @@ -28,6 +28,7 @@ import AceEditor from 'react-ace'; import { getHjsonCompletions } from '../../../ace-completions/hjson-completions'; import { getSqlCompletions } from '../../../ace-completions/sql-completions'; +import { useAvailableSqlFunctions } from '../../../contexts/sql-functions-context'; import { NATIVE_JSON_QUERY_COMPLETIONS } from '../../../druid-models'; import { AppToaster } from '../../../singletons'; import { AceEditorStateCache } from '../../../singletons/ace-editor-state-cache'; @@ -37,6 +38,7 @@ import { findAllSqlQueriesInText, findMap } from '../../../utils'; import './flexible-query-input.scss'; const V_PADDING = 10; +const ACE_THEME = 'solarized_dark'; export interface FlexibleQueryInputProps { queryString: string; @@ -50,49 +52,29 @@ export interface FlexibleQueryInputProps { leaveBackground?: boolean; } -export interface FlexibleQueryInputState { - // For reasons (https://github.com/securingsincity/react-ace/issues/415) react ace editor needs an explicit height - // Since this component will grow and shrink dynamically we will measure its height and then set it. - editorHeight: number; -} - -export class FlexibleQueryInput extends React.PureComponent< - FlexibleQueryInputProps, - FlexibleQueryInputState -> { - static aceTheme = 'solarized_dark'; - - private aceEditor: Ace.Editor | undefined; - private lastFoundQueries: QuerySlice[] = []; - private highlightFoundQuery: { row: number; marker: number } | undefined; - - constructor(props: FlexibleQueryInputProps) { - super(props); - this.state = { - editorHeight: 200, - }; - } - - componentDidMount(): void { - this.markQueries(); - } - - componentDidUpdate(prevProps: Readonly) { - if (this.props.queryString !== prevProps.queryString) { - this.markQueriesDebounced(); - } - } - - componentWillUnmount() { - const { editorStateId } = this.props; - if (editorStateId && this.aceEditor) { - AceEditorStateCache.saveState(editorStateId, this.aceEditor); - } - delete this.aceEditor; - } - - private findAllQueriesByLine() { - const { queryString } = this.props; +export const FlexibleQueryInput = React.forwardRef< + { goToPosition: (rowColumn: RowColumn) => void } | undefined, + FlexibleQueryInputProps +>(function FlexibleQueryInput(props, ref) { + const { + queryString, + onQueryStringChange, + runQuerySlice, + running, + showGutter = true, + placeholder, + columnMetadata, + editorStateId, + leaveBackground, + } = props; + + const availableSqlFunctions = useAvailableSqlFunctions(); + const [editorHeight, setEditorHeight] = React.useState(200); + const aceEditorRef = React.useRef(); + const lastFoundQueriesRef = React.useRef([]); + const highlightFoundQueryRef = React.useRef<{ row: number; marker: number } | undefined>(); + + const findAllQueriesByLine = React.useCallback(() => { const found = dedupe(findAllSqlQueriesInText(queryString), ({ startRowColumn }) => String(startRowColumn.row), ); @@ -103,214 +85,237 @@ export class FlexibleQueryInput extends React.PureComponent< if (firstQuery === queryString.trim()) return found.slice(1); return found; - } + }, [queryString]); - private readonly markQueries = () => { - if (!this.props.runQuerySlice) return; - const { aceEditor } = this; + const markQueries = React.useCallback(() => { + if (!runQuerySlice) return; + const aceEditor = aceEditorRef.current; if (!aceEditor) return; const session = aceEditor.getSession(); - this.lastFoundQueries = this.findAllQueriesByLine(); + lastFoundQueriesRef.current = findAllQueriesByLine(); session.clearBreakpoints(); - this.lastFoundQueries.forEach(({ startRowColumn }) => { - // session.addGutterDecoration(startRowColumn.row, `sub-query-gutter-marker query-${i}`); + lastFoundQueriesRef.current.forEach(({ startRowColumn }) => { session.setBreakpoint( startRowColumn.row, `sub-query-gutter-marker query-${startRowColumn.row}`, ); }); - }; - - private readonly markQueriesDebounced = debounce(this.markQueries, 900, { trailing: true }); - - private readonly handleAceContainerResize = (entries: ResizeObserverEntry[]) => { - if (entries.length !== 1) return; - this.setState({ editorHeight: entries[0].contentRect.height }); - }; - - private readonly handleChange = (value: string) => { - const { onQueryStringChange } = this.props; - if (!onQueryStringChange) return; - onQueryStringChange(value); - }; + }, [runQuerySlice, findAllQueriesByLine]); + + const markQueriesDebounced = React.useMemo( + () => debounce(markQueries, 900, { trailing: true }), + [markQueries], + ); + + React.useEffect(() => { + markQueries(); + }, [markQueries]); + + React.useEffect(() => { + markQueriesDebounced(); + }, [queryString, markQueriesDebounced]); + + React.useEffect(() => { + return () => { + if (editorStateId && aceEditorRef.current) { + AceEditorStateCache.saveState(editorStateId, aceEditorRef.current); + } + }; + }, [editorStateId]); - public goToPosition(rowColumn: RowColumn) { - const { aceEditor } = this; + const goToPosition = React.useCallback((rowColumn: RowColumn) => { + const aceEditor = aceEditorRef.current; if (!aceEditor) return; aceEditor.focus(); // Grab the focus aceEditor.getSelection().moveCursorTo(rowColumn.row, rowColumn.column); - // If we had an end we could also do - // aceEditor.getSelection().selectToPosition({ row: endRow, column: endColumn }); - } - - renderAce() { - const { queryString, onQueryStringChange, showGutter, placeholder, editorStateId } = this.props; - const { editorHeight } = this.state; - - const jsonMode = queryString.trim().startsWith('{'); - - const getColumnMetadata = () => this.props.columnMetadata; - const cmp: Ace.Completer[] = [ - { - getCompletions: (_state, session, pos, prefix, callback) => { - const allText = session.getValue(); - const line = session.getLine(pos.row); - const charBeforePrefix = line[pos.column - prefix.length - 1]; - if (allText.trim().startsWith('{')) { - const lines = allText.split('\n').slice(0, pos.row + 1); - const lastLineIndex = lines.length - 1; - lines[lastLineIndex] = lines[lastLineIndex].slice(0, pos.column - prefix.length - 1); - callback( - null, - getHjsonCompletions({ - jsonCompletions: NATIVE_JSON_QUERY_COMPLETIONS, - textBefore: lines.join('\n'), - charBeforePrefix, - prefix, - }), - ); - } else { - const lineBeforePrefix = line.slice(0, pos.column - prefix.length - 1); - callback( - null, - getSqlCompletions({ - allText, - lineBeforePrefix, - charBeforePrefix, - prefix, - columnMetadata: getColumnMetadata(), - }), - ); - } - }, + }, []); + + React.useImperativeHandle(ref, () => ({ goToPosition }), [goToPosition]); + + const handleAceContainerResize = React.useCallback((entries: ResizeObserverEntry[]) => { + if (entries.length !== 1) return; + setEditorHeight(entries[0].contentRect.height); + }, []); + + const handleChange = React.useCallback( + (value: string) => { + if (!onQueryStringChange) return; + onQueryStringChange(value); + }, + [onQueryStringChange], + ); + + const handleAceLoad = React.useCallback( + (editor: Ace.Editor) => { + editor.renderer.setPadding(V_PADDING); + editor.renderer.setScrollMargin(V_PADDING, V_PADDING, 0, 0); + + if (editorStateId) { + AceEditorStateCache.applyState(editorStateId, editor); + } + + aceEditorRef.current = editor; + }, + [editorStateId], + ); + + const handleContainerClick = React.useCallback( + (e: React.MouseEvent) => { + if (!runQuerySlice) return; + const classes = [...(e.target as any).classList]; + if (!classes.includes('sub-query-gutter-marker')) return; + const row = findMap(classes, c => { + const m = /^query-(\d+)$/.exec(c); + return m ? Number(m[1]) : undefined; + }); + if (typeof row === 'undefined') return; + + const slice = lastFoundQueriesRef.current.find( + ({ startRowColumn }) => startRowColumn.row === row, + ); + if (!slice) return; + + if (running) { + AppToaster.show({ + icon: IconNames.WARNING_SIGN, + intent: Intent.WARNING, + message: `Another query is currently running`, + }); + return; + } + + runQuerySlice(slice); + }, + [runQuerySlice, running], + ); + + const handleContainerMouseOver = React.useCallback( + (e: React.MouseEvent) => { + if (!runQuerySlice) return; + const aceEditor = aceEditorRef.current; + if (!aceEditor) return; + + const classes = [...(e.target as any).classList]; + if (!classes.includes('sub-query-gutter-marker')) return; + const row = findMap(classes, c => { + const m = /^query-(\d+)$/.exec(c); + return m ? Number(m[1]) : undefined; + }); + if (typeof row === 'undefined' || highlightFoundQueryRef.current?.row === row) return; + + const slice = lastFoundQueriesRef.current.find( + ({ startRowColumn }) => startRowColumn.row === row, + ); + if (!slice) return; + const marker = aceEditor + .getSession() + .addMarker( + new ace.Range( + slice.startRowColumn.row, + slice.startRowColumn.column, + slice.endRowColumn.row, + slice.endRowColumn.column, + ), + 'sub-query-highlight', + 'text', + false, + ); + highlightFoundQueryRef.current = { row, marker }; + }, + [runQuerySlice], + ); + + const handleContainerMouseOut = React.useCallback(() => { + if (!highlightFoundQueryRef.current) return; + const aceEditor = aceEditorRef.current; + if (!aceEditor) return; + aceEditor.getSession().removeMarker(highlightFoundQueryRef.current.marker); + highlightFoundQueryRef.current = undefined; + }, []); + + const jsonMode = queryString.trim().startsWith('{'); + + const getColumnMetadata = () => columnMetadata; + const cmp: Ace.Completer[] = [ + { + getCompletions: (_state, session, pos, prefix, callback) => { + const allText = session.getValue(); + const line = session.getLine(pos.row); + const charBeforePrefix = line[pos.column - prefix.length - 1]; + if (allText.trim().startsWith('{')) { + const lines = allText.split('\n').slice(0, pos.row + 1); + const lastLineIndex = lines.length - 1; + lines[lastLineIndex] = lines[lastLineIndex].slice(0, pos.column - prefix.length - 1); + callback( + null, + getHjsonCompletions({ + jsonCompletions: NATIVE_JSON_QUERY_COMPLETIONS, + textBefore: lines.join('\n'), + charBeforePrefix, + prefix, + }), + ); + } else { + const lineBeforePrefix = line.slice(0, pos.column - prefix.length - 1); + callback( + null, + getSqlCompletions({ + allText, + lineBeforePrefix, + charBeforePrefix, + prefix, + columnMetadata: getColumnMetadata(), + availableSqlFunctions, + }), + ); + } }, - ]; - - return ( - { - editor.renderer.setPadding(V_PADDING); - editor.renderer.setScrollMargin(V_PADDING, V_PADDING, 0, 0); - - if (editorStateId) { - AceEditorStateCache.applyState(editorStateId, editor); - } - - this.aceEditor = editor; - }} - /> - ); - } - - render() { - const { runQuerySlice, running } = this.props; - - // Set the key in the AceEditor to force a rebind and prevent an error that happens otherwise - return ( -
- -
{ - if (!runQuerySlice) return; - const classes = [...(e.target as any).classList]; - if (!classes.includes('sub-query-gutter-marker')) return; - const row = findMap(classes, c => { - const m = /^query-(\d+)$/.exec(c); - return m ? Number(m[1]) : undefined; - }); - if (typeof row === 'undefined') return; - - // Gutter query marker clicked on line ${row} - const slice = this.lastFoundQueries.find( - ({ startRowColumn }) => startRowColumn.row === row, - ); - if (!slice) return; - - if (running) { - AppToaster.show({ - icon: IconNames.WARNING_SIGN, - intent: Intent.WARNING, - message: `Another query is currently running`, - }); - return; - } - - runQuerySlice(slice); - }} - onMouseOver={e => { - if (!runQuerySlice) return; - const aceEditor = this.aceEditor; - if (!aceEditor) return; - - const classes = [...(e.target as any).classList]; - if (!classes.includes('sub-query-gutter-marker')) return; - const row = findMap(classes, c => { - const m = /^query-(\d+)$/.exec(c); - return m ? Number(m[1]) : undefined; - }); - if (typeof row === 'undefined' || this.highlightFoundQuery?.row === row) return; - - const slice = this.lastFoundQueries.find( - ({ startRowColumn }) => startRowColumn.row === row, - ); - if (!slice) return; - const marker = aceEditor - .getSession() - .addMarker( - new ace.Range( - slice.startRowColumn.row, - slice.startRowColumn.column, - slice.endRowColumn.row, - slice.endRowColumn.column, - ), - 'sub-query-highlight', - 'text', - false, - ); - this.highlightFoundQuery = { row, marker }; + }, + ]; + + return ( +
+ +
+ { - if (!this.highlightFoundQuery) return; - const aceEditor = this.aceEditor; - if (!aceEditor) return; - aceEditor.getSession().removeMarker(this.highlightFoundQuery.marker); - this.highlightFoundQuery = undefined; + setOptions={{ + showLineNumbers: true, + newLineMode: 'unix' as any, // This type is specified incorrectly in AceEditor }} - > - {this.renderAce()} -
-
-
- ); - } -} + placeholder={placeholder || 'SELECT * FROM ...'} + onLoad={handleAceLoad} + /> +
+
+
+ ); +}); diff --git a/web-console/src/views/workbench-view/query-tab/query-tab.tsx b/web-console/src/views/workbench-view/query-tab/query-tab.tsx index bd3a13e671e0..463ccb5ded28 100644 --- a/web-console/src/views/workbench-view/query-tab/query-tab.tsx +++ b/web-console/src/views/workbench-view/query-tab/query-tab.tsx @@ -189,7 +189,7 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) { return Boolean(queryDuration && queryDuration < 10000); } - const queryInputRef = useRef(null); + const queryInputRef = useRef<{ goToPosition: (rowColumn: RowColumn) => void } | null>(null); const cachedExecutionState = ExecutionStateCache.getState(id); const currentRunningPromise = WorkbenchRunningPromises.getPromise(id);