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]

Reply via email to