This is an automated email from the ASF dual-hosted git repository.
capistrant pushed a commit to branch 34.0.0
in repository https://gitbox.apache.org/repos/asf/druid.git
The following commit(s) were added to refs/heads/34.0.0 by this push:
new 2e2574cf487 Query for functions (#18214) (#18269)
2e2574cf487 is described below
commit 2e2574cf4873d141d21e49539647db1ae85e7aa0
Author: Lucas Capistrant <[email protected]>
AuthorDate: Thu Jul 17 09:16:35 2025 -0500
Query for functions (#18214) (#18269)
Co-authored-by: Vadim Ogievetsky <[email protected]>
---
web-console/script/create-sql-docs.mjs | 48 +-
web-console/src/ace-completions/make-doc-html.ts | 4 +-
web-console/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 +++--
.../sql-functions-context.tsx} | 35 +-
web-console/src/helpers/capabilities.ts | 72 ++-
.../components/control-pane/expression-menu.tsx | 1 +
.../measure-dialog/measure-dialog.tsx | 1 +
.../components/sql-input/sql-input.tsx | 187 ++++----
.../flexible-query-input/flexible-query-input.tsx | 479 ++++++++++---------
.../views/workbench-view/query-tab/query-tab.tsx | 2 +-
14 files changed, 952 insertions(+), 822 deletions(-)
diff --git a/web-console/script/create-sql-docs.mjs
b/web-console/script/create-sql-docs.mjs
index b14055341a0..23abc737771 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<string, [runtime: string, description:
string]> = ${JSON.stringify(
- dataTypeDocs,
- null,
- 2,
- )};
+export const SQL_DATA_TYPES = new Map<string, [runtime: string, description:
string]>(Object.entries(${JSON.stringify(
+ dataTypeDocs,
+ null,
+ 2,
+)}));
// prettier-ignore
-export const SQL_FUNCTIONS: Record<string, [args: string, description:
string][]> = ${JSON.stringify(
- functionDocs,
- null,
- 2,
- )};
+export const SQL_FUNCTIONS = new Map<string, [args: string, description:
string]>(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 c5603f4408d..f9d96cce490 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(
`<div class="doc-name">${item.name}</div>`,
item.syntax ? `<div class="doc-syntax">${escape(item.syntax)}</div>` :
undefined,
- `<div class="doc-description">${item.description}</div>`,
+ item.description ? `<div
class="doc-description">${item.description}</div>` : undefined,
).join('\n');
}
diff --git a/web-console/src/ace-completions/sql-completions.ts
b/web-console/src/ace-completions/sql-completions.ts
index b8383097b6e..2cea55183ba 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<string, boolean> = {
),
...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 bad6582f539..9361a6ef7f2 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 1c58a5c7945..e7e48ede0f0 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 1d8ea64d06a..e062d7efdae 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 94fc3a0d5f2..1af1795b5e7 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<null, Capabilities>;
+ 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 (
<HotkeysProvider>
- <HashRouter hashType="noslash">
- <div className="console-application">
- <Switch>
- {capabilities.hasCoordinatorAccess() && (
- <Route path="/data-loader"
component={this.wrappedDataLoaderView} />
- )}
- {capabilities.hasCoordinatorAccess() && (
+ <SqlFunctionsProvider availableSqlFunctions={availableSqlFunctions}>
+ <HashRouter hashType="noslash">
+ <div className="console-application">
+ <Switch>
+ {capabilities.hasCoordinatorAccess() && (
+ <Route path="/data-loader"
component={this.wrappedDataLoaderView} />
+ )}
+ {capabilities.hasCoordinatorAccess() && (
+ <Route
+ path="/streaming-data-loader"
+ component={this.wrappedStreamingDataLoaderView}
+ />
+ )}
+ {capabilities.hasCoordinatorAccess() && (
+ <Route
+ path="/classic-batch-data-loader"
+ component={this.wrappedClassicBatchDataLoaderView}
+ />
+ )}
+ {capabilities.hasCoordinatorAccess() &&
capabilities.hasMultiStageQueryTask() && (
+ <Route path="/sql-data-loader"
component={this.wrappedSqlDataLoaderView} />
+ )}
+
<Route
- path="/streaming-data-loader"
- component={this.wrappedStreamingDataLoaderView}
+ path={pathWithFilter('supervisors')}
+ component={this.wrappedSupervisorsView}
/>
- )}
- {capabilities.hasCoordinatorAccess() && (
+ <Route path={pathWithFilter('tasks')}
component={this.wrappedTasksView} />
+ <Route path="/ingestion">
+ <Redirect to="/tasks" />
+ </Route>
+
<Route
- path="/classic-batch-data-loader"
- component={this.wrappedClassicBatchDataLoaderView}
+ path={pathWithFilter('datasources')}
+ component={this.wrappedDatasourcesView}
/>
- )}
- {capabilities.hasCoordinatorAccess() &&
capabilities.hasMultiStageQueryTask() && (
- <Route path="/sql-data-loader"
component={this.wrappedSqlDataLoaderView} />
- )}
-
- <Route path={pathWithFilter('supervisors')}
component={this.wrappedSupervisorsView} />
- <Route path={pathWithFilter('tasks')}
component={this.wrappedTasksView} />
- <Route path="/ingestion">
- <Redirect to="/tasks" />
- </Route>
-
- <Route path={pathWithFilter('datasources')}
component={this.wrappedDatasourcesView} />
- <Route path={pathWithFilter('segments')}
component={this.wrappedSegmentsView} />
- <Route path={pathWithFilter('services')}
component={this.wrappedServicesView} />
-
- <Route path="/query">
- <Redirect to="/workbench" />
- </Route>
- <Route
- path={['/workbench/:tabId', '/workbench']}
- component={this.wrappedWorkbenchView}
- />
-
- {capabilities.hasCoordinatorAccess() && (
- <Route path={pathWithFilter('lookups')}
component={this.wrappedLookupsView} />
- )}
-
- {capabilities.hasSql() && (
+ <Route path={pathWithFilter('segments')}
component={this.wrappedSegmentsView} />
+ <Route path={pathWithFilter('services')}
component={this.wrappedServicesView} />
+
+ <Route path="/query">
+ <Redirect to="/workbench" />
+ </Route>
<Route
- path="/explore"
- component={() => <ExploreView capabilities={capabilities} />}
+ path={['/workbench/:tabId', '/workbench']}
+ component={this.wrappedWorkbenchView}
/>
- )}
- <Route component={this.wrappedHomeView} />
- </Switch>
- </div>
- </HashRouter>
+ {capabilities.hasCoordinatorAccess() && (
+ <Route path={pathWithFilter('lookups')}
component={this.wrappedLookupsView} />
+ )}
+
+ {capabilities.hasSql() && (
+ <Route
+ path="/explore"
+ component={() => <ExploreView capabilities={capabilities}
/>}
+ />
+ )}
+
+ <Route component={this.wrappedHomeView} />
+ </Switch>
+ </div>
+ </HashRouter>
+ </SqlFunctionsProvider>
</HotkeysProvider>
);
}
diff --git a/web-console/src/ace-completions/make-doc-html.ts
b/web-console/src/contexts/sql-functions-context.tsx
similarity index 53%
copy from web-console/src/ace-completions/make-doc-html.ts
copy to web-console/src/contexts/sql-functions-context.tsx
index c5603f4408d..8a906263de7 100644
--- a/web-console/src/ace-completions/make-doc-html.ts
+++ b/web-console/src/contexts/sql-functions-context.tsx
@@ -16,20 +16,29 @@
* limitations under the License.
*/
-import escape from 'lodash.escape';
+import type React from 'react';
+import { createContext, useContext } from 'react';
-import { assemble } from '../utils';
+import type { AvailableFunctions } from '../helpers';
-export interface ItemDescription {
- name: string;
- syntax?: string;
- description: string;
-}
+const SqlFunctionsContext = createContext<AvailableFunctions |
undefined>(undefined);
-export function makeDocHtml(item: ItemDescription) {
- return assemble(
- `<div class="doc-name">${item.name}</div>`,
- item.syntax ? `<div class="doc-syntax">${escape(item.syntax)}</div>` :
undefined,
- `<div class="doc-description">${item.description}</div>`,
- ).join('\n');
+export interface SqlFunctionsProviderProps {
+ availableSqlFunctions?: AvailableFunctions;
+ children: React.ReactNode;
}
+
+export const SqlFunctionsProvider: React.FC<SqlFunctionsProviderProps> = ({
+ availableSqlFunctions,
+ children,
+}) => {
+ return (
+ <SqlFunctionsContext.Provider value={availableSqlFunctions}>
+ {children}
+ </SqlFunctionsContext.Provider>
+ );
+};
+
+export const useAvailableSqlFunctions = () => {
+ return useContext(SqlFunctionsContext);
+};
diff --git a/web-console/src/helpers/capabilities.ts
b/web-console/src/helpers/capabilities.ts
index 22e840b2745..4c33f723cfd 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<string, FunctionsDefinition>;
+
+function functionRowsToMap(functionRows: FunctionRow[]): AvailableFunctions {
+ return new Map<string, FunctionsDefinition>(
+ 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<QueryType | undefined> {
// Check SQL endpoint
try {
@@ -194,6 +220,38 @@ export class Capabilities {
});
}
+ static async detectAvailableSqlFunctions(
+ capabilities: Capabilities,
+ ): Promise<AvailableFunctions | undefined> {
+ if (!capabilities.hasSql()) return;
+
+ try {
+ return functionRowsToMap(
+ (
+ await Api.instance.post<FunctionRow[]>(
+ '/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 5c6639929ca..8e947b39e9b 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
/>
</div>
<FormGroup label="Name">
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 5299a5dca2e..bdd923cac2b 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
/>
</FormGroup>
</div>
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 f8af1b52487..7a9dca68135 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<SqlInputProps> {
- 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<Ace.Editor | undefined>();
- 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 (
- <AceEditor
- mode="dsql"
- theme={SqlInput.aceTheme}
- className="sql-input placeholder-padding"
- // 'react-ace' types are incomplete. Completion options can accept
completers array.
- enableBasicAutocompletion={cmp as any}
- enableLiveAutocompletion={cmp as any}
- name="ace-editor"
- onChange={this.handleChange}
- focus
- fontSize={12}
- width="100%"
- height={editorHeight ? `${editorHeight}px` : '100%'}
- showGutter={Boolean(showGutter)}
- showPrintMargin={false}
- tabSize={2}
- value={value}
- readOnly={!onValueChange}
- editorProps={{
- $blockScrolling: Infinity,
- }}
- setOptions={{
- showLineNumbers: true,
- newLineMode: 'unix' as any, // This type is specified incorrectly in
AceEditor
- }}
- placeholder={placeholder || 'SELECT * FROM ...'}
- onLoad={(editor: Ace.Editor) => {
- 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 (
+ <AceEditor
+ mode="dsql"
+ theme={ACE_THEME}
+ className="sql-input placeholder-padding"
+ // 'react-ace' types are incomplete. Completion options can accept an
array of completers.
+ enableBasicAutocompletion={cmp as any}
+ enableLiveAutocompletion={cmp as any}
+ name="ace-editor"
+ onChange={handleChange}
+ focus={autoFocus}
+ fontSize={12}
+ width="100%"
+ height={editorHeight ? `${editorHeight}px` : '100%'}
+ showGutter={Boolean(showGutter)}
+ showPrintMargin={false}
+ tabSize={2}
+ value={value}
+ readOnly={!onValueChange}
+ editorProps={{
+ $blockScrolling: Infinity,
+ }}
+ setOptions={{
+ showLineNumbers: true,
+ newLineMode: 'unix' as any, // This type is specified incorrectly in
AceEditor
+ }}
+ placeholder={placeholder || 'SQL filter'}
+ onLoad={handleAceLoad}
+ />
+ );
+});
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 2966d14d079..8c67f083577 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<FlexibleQueryInputProps>) {
- 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<Ace.Editor | undefined>();
+ const lastFoundQueriesRef = React.useRef<QuerySlice[]>([]);
+ 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 (
- <AceEditor
- mode={jsonMode ? 'hjson' : 'dsql'}
- theme={FlexibleQueryInput.aceTheme}
- className={classNames(
- 'placeholder-padding',
- this.props.leaveBackground ? undefined : 'no-background',
- )}
- // 'react-ace' types are incomplete. Completion options can accept
completers array.
- enableBasicAutocompletion={cmp as any}
- enableLiveAutocompletion={cmp as any}
- name="ace-editor"
- onChange={this.handleChange}
- focus
- fontSize={12}
- width="100%"
- height={editorHeight + 'px'}
- showGutter={showGutter}
- showPrintMargin={false}
- tabSize={2}
- value={queryString}
- readOnly={!onQueryStringChange}
- editorProps={{
- $blockScrolling: Infinity,
- }}
- setOptions={{
- showLineNumbers: true,
- newLineMode: 'unix' as any, // This type is specified incorrectly in
AceEditor
- }}
- placeholder={placeholder || 'SELECT * FROM ...'}
- onLoad={(editor: Ace.Editor) => {
- 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 (
- <div className="flexible-query-input">
- <ResizeSensor onResize={this.handleAceContainerResize}>
- <div
- className={classNames('ace-container', running ? 'query-running' :
'query-idle')}
- onClick={e => {
- 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 (
+ <div className="flexible-query-input">
+ <ResizeSensor onResize={handleAceContainerResize}>
+ <div
+ className={classNames('ace-container', running ? 'query-running' :
'query-idle')}
+ onClick={handleContainerClick}
+ onMouseOver={handleContainerMouseOver}
+ onMouseOut={handleContainerMouseOut}
+ >
+ <AceEditor
+ mode={jsonMode ? 'hjson' : 'dsql'}
+ theme={ACE_THEME}
+ className={classNames(
+ 'placeholder-padding',
+ leaveBackground ? undefined : 'no-background',
+ )}
+ // 'react-ace' types are incomplete. Completion options can accept
completers array.
+ enableBasicAutocompletion={cmp as any}
+ enableLiveAutocompletion={cmp as any}
+ name="ace-editor"
+ onChange={handleChange}
+ focus
+ fontSize={12}
+ width="100%"
+ height={editorHeight + 'px'}
+ showGutter={showGutter}
+ showPrintMargin={false}
+ tabSize={2}
+ value={queryString}
+ readOnly={!onQueryStringChange}
+ editorProps={{
+ $blockScrolling: Infinity,
}}
- onMouseOut={() => {
- 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()}
- </div>
- </ResizeSensor>
- </div>
- );
- }
-}
+ placeholder={placeholder || 'SELECT * FROM ...'}
+ onLoad={handleAceLoad}
+ />
+ </div>
+ </ResizeSensor>
+ </div>
+ );
+});
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 ee15ec0ceae..d07cdef58a2 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<FlexibleQueryInput | null>(null);
+ const queryInputRef = useRef<{ goToPosition: (rowColumn: RowColumn) => void
} | null>(null);
const cachedExecutionState = ExecutionStateCache.getState(id);
const currentRunningPromise = WorkbenchRunningPromises.getPromise(id);
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]