This is an automated email from the ASF dual-hosted git repository.
FrankChen021 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git
The following commit(s) were added to refs/heads/master by this push:
new 1fe3fbb8f13 feat(web-console): suggest query context parameters after
SET (#20642)
1fe3fbb8f13 is described below
commit 1fe3fbb8f136c226d2f4976e85a28b46641abb3a
Author: Frank Chen <[email protected]>
AuthorDate: Wed Nov 4 10:01:55 2026 +0800
feat(web-console): suggest query context parameters after SET (#20642)
* feat(web-console): suggest query context parameters after SET
The SQL editor loads the public query context parameters from
sys.query_context_parameters and suggests them after
SET, with each parameter's type, default, description and deprecation in
the completion popup. Without CONFIG read
access it shows the declared default and notes that the Broker may override
it. If the table can't be read, such as
on an older Druid, it falls back to the previous fixed list.
* fix: note that the Broker may override a described default without CONFIG
read access
* fix: suggest only query context parameters that SQL can provide after SET
maxRowsQueuedForOrdering, maxSegmentPartitionsOrderedInMemory and
setProcessingThreadNames are read from the native
query context, which SQL passes through, so they now list SQL among their
query languages. The web console suggests
only parameters whose query languages include SQL, so parameters that SQL
queries ignore, such as bySegment, are not
offered after SET.
* Address review: load SET parameters in the background, trim tests
- Load sys.query_context_parameters without holding up the console's
loading screen
- Leave entities that are not valid code points as they are, and decode hex
entities
- Remove link handling from convertHtmlText that the outer loop made
unreachable
- Check SET with the same word lookup as the completions
- Rename ParameterCompletions fields to afterQuote / bare
- Test that relative links in parameter descriptions resolve from
docs/querying/
- Merge the default-formatting tests into one table, drop tests of internals
* Keep the original SET check, which ignores unclosed quotes
wordEndingBefore skips a token that contains the cursor, so "SET "a |" fell
back to SET and opened the
completions. Restores the tokenBefore check and tests unclosed quoted
identifiers and strings. Also corrects
comments about which docs page the parameter descriptions are in.
* Test that links to non-https URLs stay text
The javascript:alert(1) case passed even without the https check, because
the parenthesis in it already stops the
link from matching.
---
.../query/context/QueryContextParameters.java | 6 +-
.../docs/ParameterDocumentationGeneratorTest.java | 37 +++
.../SystemQueryContextParametersTableTest.java | 10 +-
.../components/code-editor/completion-source.ts | 28 +-
.../components/code-editor/doc-markdown.spec.ts | 11 +
.../src/components/code-editor/doc-markdown.ts | 15 +-
web-console/src/console-application.tsx | 22 +-
.../contexts/query-context-parameters-context.tsx | 50 ++++
.../druid-models/query-context/query-context.tsx | 26 ++
.../query-context-parameter-completions.spec.ts | 203 ++++++++++++++
.../query-context-parameter-completions.ts | 301 +++++++++++++++++++++
.../src/editor-completions/sql-completions.spec.ts | 51 +++-
.../src/editor-completions/sql-completions.ts | 28 +-
web-console/src/editor-languages/dsql.ts | 20 +-
.../src/editor-languages/editor-languages.spec.ts | 15 +
web-console/src/helpers/capabilities.ts | 34 ++-
.../components/sql-input/sql-input.tsx | 5 +-
.../flexible-query-input/flexible-query-input.tsx | 6 +-
.../workbench-history-dialog.tsx | 7 +-
19 files changed, 834 insertions(+), 41 deletions(-)
diff --git
a/processing/src/main/java/org/apache/druid/query/context/QueryContextParameters.java
b/processing/src/main/java/org/apache/druid/query/context/QueryContextParameters.java
index 4913f765e73..2f82c66933f 100644
---
a/processing/src/main/java/org/apache/druid/query/context/QueryContextParameters.java
+++
b/processing/src/main/java/org/apache/druid/query/context/QueryContextParameters.java
@@ -503,7 +503,7 @@ public final class QueryContextParameters
public static final QueryContextParameter<Boolean>
SET_PROCESSING_THREAD_NAMES =
booleanParameter("setProcessingThreadNames")
- .since("24.0.0").query(Query.JSON).engine(Engine.NATIVE)
+ .since("24.0.0").query(Query.JSON, Query.SQL).engine(Engine.NATIVE)
.defaultValue(false)
.description(
"""
@@ -1528,7 +1528,7 @@ public final class QueryContextParameters
// Public ones are documented in scan-query.md and searchquery.md.
public static final QueryContextParameter<Integer>
MAX_SEGMENT_PARTITIONS_ORDERED_IN_MEMORY =
integerParameter("maxSegmentPartitionsOrderedInMemory")
- .since("0.15.0-incubating").query(Query.JSON).engine(Engine.NATIVE)
+ .since("0.15.0-incubating").query(Query.JSON,
Query.SQL).engine(Engine.NATIVE)
.constraint(Range.closedRange(1, Integer.MAX_VALUE))
.description(
"""
@@ -2428,7 +2428,7 @@ public final class QueryContextParameters
public static final QueryContextParameter<Integer>
MAX_ROWS_QUEUED_FOR_ORDERING =
integerParameter("maxRowsQueuedForOrdering")
- .since("0.15.0-incubating").query(Query.JSON).engine(Engine.NATIVE)
+ .since("0.15.0-incubating").query(Query.JSON,
Query.SQL).engine(Engine.NATIVE)
.constraint(Range.closedRange(1, Integer.MAX_VALUE))
.description(
"""
diff --git
a/processing/src/test/java/org/apache/druid/query/context/docs/ParameterDocumentationGeneratorTest.java
b/processing/src/test/java/org/apache/druid/query/context/docs/ParameterDocumentationGeneratorTest.java
index 876f2371590..06243f0a090 100644
---
a/processing/src/test/java/org/apache/druid/query/context/docs/ParameterDocumentationGeneratorTest.java
+++
b/processing/src/test/java/org/apache/druid/query/context/docs/ParameterDocumentationGeneratorTest.java
@@ -30,6 +30,12 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -185,6 +191,37 @@ class ParameterDocumentationGeneratorTest
assertTrue(exception.getMessage().contains("Expected arguments"));
}
+ /**
+ * The web console shows parameter descriptions in SQL completions and
resolves their relative links from
+ * docs/querying/ (resolveDocsLink in
query-context-parameter-completions.ts), whichever page they are generated
+ * into, so every relative link must resolve from there.
+ */
+ @Test
+ void testRelativeLinksResolveFromQueryingDocs()
+ {
+ final Pattern link = Pattern.compile("\\]\\(([^()#:\\s]+)(?:#[^()]*)?\\)");
+ // Tests run in the processing module directory
+ final Path queryingDocs = Path.of("..", "docs", "querying");
+ final List<String> unresolved = new ArrayList<>();
+ for (final QueryContextParameter<?> parameter :
QueryContextParameters.ALL.get().values()) {
+ final ParameterDocumentation docs = parameter.getDocumentation();
+ final Stream<String> texts = Stream.of(
+ docs.getDescription(),
+ docs.getDefaultDescription().orElse(null),
+ parameter.getDeprecationMessage()
+ );
+ texts.filter(Objects::nonNull).forEach(text -> {
+ final Matcher matcher = link.matcher(text);
+ while (matcher.find()) {
+ if (!Files.exists(queryingDocs.resolve(matcher.group(1)))) {
+ unresolved.add(parameter.getName() + ": " + matcher.group(1));
+ }
+ }
+ });
+ }
+ assertEquals(List.of(), unresolved);
+ }
+
@Test
void testEscapesTableCell()
{
diff --git
a/sql/src/test/java/org/apache/druid/sql/calcite/schema/SystemQueryContextParametersTableTest.java
b/sql/src/test/java/org/apache/druid/sql/calcite/schema/SystemQueryContextParametersTableTest.java
index 010722742b5..b4e7f0f378c 100644
---
a/sql/src/test/java/org/apache/druid/sql/calcite/schema/SystemQueryContextParametersTableTest.java
+++
b/sql/src/test/java/org/apache/druid/sql/calcite/schema/SystemQueryContextParametersTableTest.java
@@ -131,8 +131,16 @@ public class SystemQueryContextParametersTableTest
// The default is the value of a runtime property
Assertions.assertEquals("${druid.query.scan.maxRowsQueuedForOrdering}",
value(row, "default_value"));
Assertions.assertNull(value(row, "default_description"));
- Assertions.assertEquals(List.of("JSON"), value(row, "query_languages"));
+ Assertions.assertEquals(List.of("JSON", "SQL"), value(row,
"query_languages"));
Assertions.assertEquals(List.of("scan"), value(row, "native_query_types"));
+ Assertions.assertEquals(List.of(), value(row, "statement_types"));
+ }
+
+ @Test
+ public void testParameterNotProvidedBySql()
+ {
+ final Object[] row =
scan().get(QueryContextParameters.BY_SEGMENT.getName());
+ Assertions.assertEquals(List.of("JSON"), value(row, "query_languages"));
// Not provided by SQL queries, so statement types don't apply.
Assertions.assertNull(value(row, "statement_types"));
}
diff --git a/web-console/src/components/code-editor/completion-source.ts
b/web-console/src/components/code-editor/completion-source.ts
index b18416f4f39..043cb9a48d0 100644
--- a/web-console/src/components/code-editor/completion-source.ts
+++ b/web-console/src/components/code-editor/completion-source.ts
@@ -47,16 +47,27 @@ export type EditorCompletionBuilder = (
word: CompletionWord,
) => readonly EditorCompletion[];
+export interface CompletionSourceOptions {
+ /**
+ * Whether to complete before any word is typed, without the completion
being asked for explicitly. For example right
+ * after a keyword that is always followed by a word that can be completed.
+ */
+ completeEmptyWord?: (context: CompletionContext) => boolean;
+}
+
/**
- * The word before the cursor, or undefined when there is nothing to complete
(no word typed and the completion was not
- * asked for explicitly). What is a word is up to the language: letters,
digits and _, plus the language's `wordChars`
- * (language data).
+ * The word before the cursor, or undefined when there is nothing to complete
(no word typed, the completion was not
+ * asked for explicitly and completeEmptyWord does not allow it). What is a
word is up to the language: letters, digits
+ * and _, plus the language's `wordChars` (language data).
*/
-export function matchCompletionWord(context: CompletionContext):
CompletionWord | undefined {
+export function matchCompletionWord(
+ context: CompletionContext,
+ completeEmptyWord?: (context: CompletionContext) => boolean,
+): CompletionWord | undefined {
const { state, pos } = context;
const word = state.wordAt(pos);
const from = word && word.from < pos ? word.from : pos;
- if (from === pos && !context.explicit) return;
+ if (from === pos && !context.explicit && !completeEmptyWord?.(context))
return;
return { from, text: state.sliceDoc(from, pos) };
}
@@ -87,10 +98,13 @@ function toCompletion({ doc, ...completion }:
EditorCompletion): Completion {
/**
* Makes a CodeMirror completion source out of a completion builder (like
getSqlCompletions)
*/
-export function makeCompletionSource(build: EditorCompletionBuilder):
CompletionSource {
+export function makeCompletionSource(
+ build: EditorCompletionBuilder,
+ { completeEmptyWord }: CompletionSourceOptions = {},
+): CompletionSource {
return (context): CompletionResult | null => {
if (context.state.readOnly) return null;
- const word = matchCompletionWord(context);
+ const word = matchCompletionWord(context, completeEmptyWord);
if (!word) return null;
const options = build(context, word).map(toCompletion);
diff --git a/web-console/src/components/code-editor/doc-markdown.spec.ts
b/web-console/src/components/code-editor/doc-markdown.spec.ts
index 0387f976d5c..ed0c3dc48b5 100644
--- a/web-console/src/components/code-editor/doc-markdown.spec.ts
+++ b/web-console/src/components/code-editor/doc-markdown.spec.ts
@@ -49,6 +49,17 @@ describe('renderDocMarkdown', () => {
expect(renderToHtml('\\- not a list item')).toEqual('- not a list item');
});
+ it('renders links to https URLs and leaves other links as text', () => {
+ expect(
+ renderToHtml('See [the
`docs`](https://druid.apache.org/docs/latest/querying/sql) now'),
+ ).toEqual(
+ 'See <a href="https://druid.apache.org/docs/latest/querying/sql"
target="_blank" rel="noopener noreferrer">the <code>docs</code></a> now',
+ );
+
expect(renderToHtml('[click](javascript:alert(1))')).toEqual('[click](javascript:alert(1))');
+
expect(renderToHtml('[click](http://example.com)')).toEqual('[click](http://example.com)');
+
expect(renderToHtml('[click](javascript:void)')).toEqual('[click](javascript:void)');
+ });
+
it('renders line breaks', () => {
expect(renderToHtml('First.\n\nSecond.')).toEqual('First.<br><br>Second.');
});
diff --git a/web-console/src/components/code-editor/doc-markdown.ts
b/web-console/src/components/code-editor/doc-markdown.ts
index 4ca8d26a3dd..f629298b255 100644
--- a/web-console/src/components/code-editor/doc-markdown.ts
+++ b/web-console/src/components/code-editor/doc-markdown.ts
@@ -16,15 +16,16 @@
* limitations under the License.
*/
-// `code`, *emphasis* (which can contain code) or a backslash escaped character
-const INLINE_REGEXP = /`([^`]*)`|\*((?:\\.|`[^`]*`|[^*\\`])+)\*|\\(.)/g;
+// `code`, *emphasis* (which can contain code), a [link](https://...) or a
backslash escaped character
+const INLINE_REGEXP =
+
/`([^`]*)`|\*((?:\\.|`[^`]*`|[^*\\`])+)\*|\[([^[\]]*)\]\((https:\/\/[^\s()]+)\)|\\(.)/g;
function renderInline(text: string): (Node | string)[] {
const nodes: (Node | string)[] = [];
let lastIndex = 0;
for (const m of text.matchAll(INLINE_REGEXP)) {
if (m.index > lastIndex) nodes.push(text.slice(lastIndex, m.index));
- const [, code, emphasis, escaped] = m;
+ const [, code, emphasis, linkText, linkHref, escaped] = m;
if (typeof code === 'string') {
const codeElement = document.createElement('code');
codeElement.textContent = code;
@@ -33,6 +34,13 @@ function renderInline(text: string): (Node | string)[] {
const emphasisElement = document.createElement('em');
emphasisElement.append(...renderInline(emphasis));
nodes.push(emphasisElement);
+ } else if (typeof linkText === 'string') {
+ const linkElement = document.createElement('a');
+ linkElement.href = linkHref;
+ linkElement.target = '_blank';
+ linkElement.rel = 'noopener noreferrer';
+ linkElement.append(...renderInline(linkText));
+ nodes.push(linkElement);
} else {
nodes.push(escaped);
}
@@ -47,6 +55,7 @@ function renderInline(text: string): (Node | string)[] {
* sanitizeMarkdown in script/create-sql-docs.mjs):
* - `code` spans
* - *emphasis*
+ * - [links](https://...), only to https URLs (anything else is shown as text)
* - line breaks (\n)
* - list items (lines that start with "- ")
* - a backslash escapes the next character
diff --git a/web-console/src/console-application.tsx
b/web-console/src/console-application.tsx
index 999ca8b8f72..c33fd907ec5 100644
--- a/web-console/src/console-application.tsx
+++ b/web-console/src/console-application.tsx
@@ -23,8 +23,14 @@ import type { JSX } from 'react';
import React from 'react';
import { HeaderBar, Loader } from './components';
+import { QueryContextParametersProvider } from
'./contexts/query-context-parameters-context';
import { SqlFunctionsProvider } from './contexts/sql-functions-context';
-import type { ConsoleViewId, QueryContext, QueryWithContext } from
'./druid-models';
+import type {
+ ConsoleViewId,
+ QueryContext,
+ QueryContextParameterMetadata,
+ QueryWithContext,
+} from './druid-models';
import type { AvailableFunctions } from './helpers';
import { Capabilities, maybeGetClusterCapacity } from './helpers';
import { AppToaster } from './singletons';
@@ -88,6 +94,7 @@ export interface ConsoleApplicationProps {
export interface ConsoleApplicationState {
capabilities: Capabilities;
availableSqlFunctions?: AvailableFunctions;
+ queryContextParameters?: readonly QueryContextParameterMetadata[];
capabilitiesLoading: boolean;
hashPath: string;
}
@@ -142,6 +149,8 @@ export class ConsoleApplication extends React.PureComponent<
return [Capabilities.FULL, undefined];
}
+ // Only needed for SET completions, so the console does not wait for
them
+ void this.loadQueryContextParameters(capabilities);
return Promise.all([
Capabilities.detectCapacity(capabilities),
Capabilities.detectAvailableSqlFunctions(capabilities),
@@ -162,6 +171,11 @@ export class ConsoleApplication extends
React.PureComponent<
});
}
+ private async loadQueryContextParameters(capabilities: Capabilities):
Promise<void> {
+ const queryContextParameters = await
Capabilities.detectQueryContextParameters(capabilities);
+ this.setState({ queryContextParameters });
+ }
+
componentDidMount(): void {
window.addEventListener('hashchange', this.handleHashChange);
this.handleHashChange();
@@ -481,7 +495,7 @@ export class ConsoleApplication extends React.PureComponent<
}
render() {
- const { availableSqlFunctions, capabilitiesLoading } = this.state;
+ const { availableSqlFunctions, queryContextParameters, capabilitiesLoading
} = this.state;
if (capabilitiesLoading) {
return (
@@ -494,7 +508,9 @@ export class ConsoleApplication extends React.PureComponent<
return (
<HotkeysProvider>
<SqlFunctionsProvider availableSqlFunctions={availableSqlFunctions}>
- <div className="console-application">{this.renderView()}</div>
+ <QueryContextParametersProvider
queryContextParameters={queryContextParameters}>
+ <div className="console-application">{this.renderView()}</div>
+ </QueryContextParametersProvider>
</SqlFunctionsProvider>
</HotkeysProvider>
);
diff --git a/web-console/src/contexts/query-context-parameters-context.tsx
b/web-console/src/contexts/query-context-parameters-context.tsx
new file mode 100644
index 00000000000..ce8fef3596d
--- /dev/null
+++ b/web-console/src/contexts/query-context-parameters-context.tsx
@@ -0,0 +1,50 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type React from 'react';
+import { createContext, useContext } from 'react';
+
+import type { QueryContextParameterMetadata } from '../druid-models';
+
+const QueryContextParametersContext = createContext<
+ readonly QueryContextParameterMetadata[] | undefined
+>(undefined);
+
+export interface QueryContextParametersProviderProps {
+ queryContextParameters?: readonly QueryContextParameterMetadata[];
+ children: React.ReactNode;
+}
+
+export const QueryContextParametersProvider:
React.FC<QueryContextParametersProviderProps> = ({
+ queryContextParameters,
+ children,
+}) => {
+ return (
+ <QueryContextParametersContext value={queryContextParameters}>
+ {children}
+ </QueryContextParametersContext>
+ );
+};
+
+/**
+ * The query context parameters from sys.query_context_parameters, or
undefined when they could not be loaded (an
+ * older Druid or no access to sys tables)
+ */
+export const useQueryContextParameters = () => {
+ return useContext(QueryContextParametersContext);
+};
diff --git a/web-console/src/druid-models/query-context/query-context.tsx
b/web-console/src/druid-models/query-context/query-context.tsx
index ae6ffb27e1c..d25f58e7763 100644
--- a/web-console/src/druid-models/query-context/query-context.tsx
+++ b/web-console/src/druid-models/query-context/query-context.tsx
@@ -70,6 +70,32 @@ export const DEFAULT_SERVER_QUERY_CONTEXT: QueryContext = {
includeAllCounters: false,
};
+/**
+ * A query context parameter, as a row of `sys.query_context_parameters`
+ */
+export interface QueryContextParameterMetadata {
+ name: string;
+ type: string;
+ description: string;
+ default_value: string | null;
+ default_description: string | null;
+ effective_value: string | null;
+ effective_source:
+ | 'BUILT_IN'
+ | 'RUNTIME_PROPERTY'
+ | 'DEFAULT_CONTEXT_PROPERTY'
+ | 'BROKER_DYNAMIC_CONFIG'
+ | 'UNAUTHORIZED'
+ | null;
+ deprecation: string | null;
+ /** The query languages that can provide the parameter, such as SQL or JSON
(native queries) */
+ query_languages: string[] | null;
+}
+
+export const QUERY_CONTEXT_PARAMETERS_QUERY = `SELECT "name", "type",
"description", "default_value", "default_description", "effective_value",
"effective_source", "deprecation", "query_languages"
+FROM sys.query_context_parameters
+WHERE "visibility" = 'PUBLIC'`;
+
export interface QueryWithContext {
queryString: string;
queryContext?: QueryContext;
diff --git
a/web-console/src/editor-completions/query-context-parameter-completions.spec.ts
b/web-console/src/editor-completions/query-context-parameter-completions.spec.ts
new file mode 100644
index 00000000000..c3d4209d48e
--- /dev/null
+++
b/web-console/src/editor-completions/query-context-parameter-completions.spec.ts
@@ -0,0 +1,203 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { QueryContextParameterMetadata } from '../druid-models';
+
+import {
+ getQueryContextCompletions,
+ queryContextDescriptionToDocMarkdown,
+ resolveDocsLink,
+} from './query-context-parameter-completions';
+
+describe('query-context-parameter-completions', () => {
+ describe('getQueryContextCompletions', () => {
+ const parameter: QueryContextParameterMetadata = {
+ name: 'p',
+ type: 'Long',
+ description: 'D.',
+ default_value: null,
+ default_description: null,
+ effective_value: null,
+ effective_source: null,
+ deprecation: null,
+ query_languages: ['JSON', 'SQL'],
+ };
+ const property = '${druid.x}';
+
+ // Only the combinations that sys.query_context_parameters returns
+ it.each<[string, Partial<QueryContextParameterMetadata>, string]>([
+ [
+ 'built-in',
+ { default_value: '1', effective_value: '1', effective_source:
'BUILT_IN' },
+ 'Default: `1`',
+ ],
+ [
+ 'built-in without CONFIG read access',
+ { default_value: '1', effective_source: 'UNAUTHORIZED' },
+ 'Default: `1` (built-in; the Broker may override it)',
+ ],
+ [
+ 'default query context',
+ { default_value: '1', effective_value: '2', effective_source:
'DEFAULT_CONTEXT_PROPERTY' },
+ 'Default: `2` (default query context; built-in: `1`)',
+ ],
+ [
+ 'Broker dynamic config',
+ { default_value: '1', effective_value: '2', effective_source:
'BROKER_DYNAMIC_CONFIG' },
+ 'Default: `2` (Broker dynamic config; built-in: `1`)',
+ ],
+ [
+ 'runtime property',
+ { default_value: property, effective_value: '5', effective_source:
'RUNTIME_PROPERTY' },
+ 'Default: `5` (runtime property `druid.x`)',
+ ],
+ [
+ 'runtime property overridden by the default query context',
+ {
+ default_value: property,
+ effective_value: '6',
+ effective_source: 'DEFAULT_CONTEXT_PROPERTY',
+ },
+ 'Default: `6` (default query context; runtime property `druid.x`)',
+ ],
+ [
+ 'runtime property that is not set',
+ { default_value: property },
+ 'Default: value of runtime property `druid.x`',
+ ],
+ [
+ 'runtime property without CONFIG read access',
+ { default_value: property, effective_source: 'UNAUTHORIZED' },
+ 'Default: value of runtime property `druid.x` (the Broker may override
it)',
+ ],
+ ['described', { default_description: 'auto' }, 'Default: auto'],
+ [
+ 'described without CONFIG read access',
+ { default_description: 'auto', effective_source: 'UNAUTHORIZED' },
+ 'Default: auto (the Broker may override it)',
+ ],
+ ])('shows a default that is %s', (_, overrides, expected) => {
+ expect(getQueryContextCompletions(false, [{ ...parameter, ...overrides
}])[0].doc).toEqual({
+ name: 'p',
+ descriptionMarkdown: `Type: \`Long\`\n\n${expected}\n\nD.`,
+ });
+ });
+
+ it('sorts a deprecated parameter after the others and notes the
deprecation', () => {
+ const [completion] = getQueryContextCompletions(false, [
+ { ...parameter, deprecation: 'Use `q` instead.' },
+ ]);
+ expect(completion.boost).toEqual(40);
+ expect(completion.doc?.descriptionMarkdown).toEqual(
+ 'Type: `Long`\n\nD.\n\n*Deprecated*: Use `q` instead.',
+ );
+ });
+
+ it('does not suggest parameters that SQL can not provide', () => {
+ expect(
+ getQueryContextCompletions(false, [
+ { ...parameter, name: 'useCache' },
+ { ...parameter, name: 'bySegment', query_languages: ['JSON'] },
+ ]).map(c => c.label),
+ ).toEqual(['useCache']);
+ });
+
+ it('quotes a parameter name that needs it unless a quote is being typed',
() => {
+ const queryContextParameters = [{ ...parameter, name: 'my-key' }];
+ expect(getQueryContextCompletions(false, queryContextParameters).map(c
=> c.label)).toEqual([
+ '"my-key"',
+ ]);
+ expect(getQueryContextCompletions(true, queryContextParameters).map(c =>
c.label)).toEqual([
+ 'my-key',
+ ]);
+ });
+ });
+
+ describe('resolveDocsLink', () => {
+ it('resolves links relative to the querying docs to the docs site', () => {
+ const docs = 'https://druid.apache.org/docs/latest';
+ expect(resolveDocsLink('dart.md')).toEqual(`${docs}/querying/dart`);
+ expect(resolveDocsLink('./filters.md#null-filter')).toEqual(
+ `${docs}/querying/filters#null-filter`,
+ );
+
expect(resolveDocsLink('../multi-stage-query/reference.md#joins')).toEqual(
+ `${docs}/multi-stage-query/reference#joins`,
+ );
+ expect(resolveDocsLink('../configuration/index.md#broker')).toEqual(
+ `${docs}/configuration/#broker`,
+ );
+ // Anchors as the docs site makes them
+ expect(
+ resolveDocsLink('../configuration/index.md#Guardrails for
materialization of subqueries'),
+
).toEqual(`${docs}/configuration/#guardrails-for-materialization-of-subqueries`);
+
expect(resolveDocsLink('https://example.com/x')).toEqual('https://example.com/x');
+ });
+
+ it('does not resolve links to an anchor of the same page or to other
schemes', () => {
+ expect(resolveDocsLink('#limits')).toBeUndefined();
+ expect(resolveDocsLink('javascript:alert(1)')).toBeUndefined();
+ expect(resolveDocsLink('http://example.com/x')).toBeUndefined();
+ });
+ });
+
+ describe('queryContextDescriptionToDocMarkdown', () => {
+ it('turns links into docs links and HTML into line breaks and list items',
() => {
+ expect(
+ queryContextDescriptionToDocMarkdown(
+ 'Uses [lookups](lookups.md#reverse) for `IN`, see
[limits](#limits).<br /><br />Options:<ul><li>`a`: first</li><li>`b`:
second</li></ul>Done.',
+ ),
+ ).toEqual(
+ 'Uses
[lookups](https://druid.apache.org/docs/latest/querying/lookups#reverse) for
`IN`, see limits.\n\nOptions:\n- `a`: first\n- `b`: second\nDone.',
+ );
+ });
+
+ it('converts links whose text is a code span and leaves brackets in code
spans alone', () => {
+ expect(
+ queryContextDescriptionToDocMarkdown(
+ 'An [`indexSpec`](../ingestion/ingestion-spec.md#indexspec) for
`ARRAY[1](2)`, see [`this`](#section).',
+ ),
+ ).toEqual(
+ 'An
[`indexSpec`](https://druid.apache.org/docs/latest/ingestion/ingestion-spec#indexspec)
for `ARRAY[1](2)`, see `this`.',
+ );
+ });
+
+ it('keeps code spans as they are and converts code tags, strong text and
entities', () => {
+ expect(
+ queryContextDescriptionToDocMarkdown(
+ '**Deprecated.** Threads are named `processing_<queryId>`; use
<code>||</code> & more.',
+ ),
+ ).toEqual('*Deprecated.* Threads are named `processing_<queryId>`; use
`||` & more.');
+ });
+
+ it('drops tags that removing another tag leaves behind', () => {
+ expect(queryContextDescriptionToDocMarkdown('a <<b>i>tag</i>
b')).toEqual('a tag b');
+ });
+
+ it('decodes hex entities and leaves entities that are not code points as
they are', () => {
+ expect(queryContextDescriptionToDocMarkdown('a | b � c
�')).toEqual(
+ 'a | b � c �',
+ );
+ });
+
+ it('decodes entities once', () => {
+ expect(
+ queryContextDescriptionToDocMarkdown('Use <code>&lt;</code> for
&lt;.'),
+ ).toEqual('Use `<` for <.');
+ });
+ });
+});
diff --git
a/web-console/src/editor-completions/query-context-parameter-completions.ts
b/web-console/src/editor-completions/query-context-parameter-completions.ts
new file mode 100644
index 00000000000..b872dac459d
--- /dev/null
+++ b/web-console/src/editor-completions/query-context-parameter-completions.ts
@@ -0,0 +1,301 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { C, RefName } from 'druid-query-toolkit';
+
+import type { QueryContextParameterMetadata } from '../druid-models';
+import { DEFAULT_SERVER_QUERY_CONTEXT } from '../druid-models';
+import { getLink } from '../links';
+
+import type { EditorCompletion } from './editor-completion';
+
+/**
+ * The query context parameter names to suggest after SET: the parameters
loaded from sys.query_context_parameters, or
+ * the well-known context keys when they are not loaded
+ */
+export function getQueryContextCompletions(
+ quote: boolean,
+ queryContextParameters: readonly QueryContextParameterMetadata[] | undefined,
+): EditorCompletion[] {
+ if (!queryContextParameters?.length) {
+ return Object.keys(DEFAULT_SERVER_QUERY_CONTEXT).map(key => ({
+ label: quote ? key : contextKeyLabel(key),
+ boost: 50,
+ detail: 'context',
+ }));
+ }
+
+ let completions = PARAMETER_COMPLETIONS.get(queryContextParameters);
+ if (!completions) {
+ completions = buildParameterCompletions(queryContextParameters);
+ PARAMETER_COMPLETIONS.set(queryContextParameters, completions);
+ }
+ return quote ? completions.afterQuote : completions.bare;
+}
+
+interface ParameterCompletions {
+ /** For typing after a double quote: the names as they are */
+ afterQuote: EditorCompletion[];
+ /** Otherwise: the names, quoted where needed */
+ bare: EditorCompletion[];
+}
+
+/**
+ * The completions of each loaded list of parameters, built on first use: the
docs only change when the parameters are
+ * loaded again (a new list)
+ */
+const PARAMETER_COMPLETIONS = new WeakMap<
+ readonly QueryContextParameterMetadata[],
+ ParameterCompletions
+>();
+
+function buildParameterCompletions(
+ queryContextParameters: readonly QueryContextParameterMetadata[],
+): ParameterCompletions {
+ // Parameters that SQL can't provide, such as bySegment, which SQL queries
ignore, are not suggested after SET
+ const afterQuote = queryContextParameters
+ .filter(parameter => !parameter.query_languages ||
parameter.query_languages.includes('SQL'))
+ .map(parameter => ({
+ label: parameter.name,
+ // Deprecated parameters sort after the others
+ boost: parameter.deprecation ? 40 : 50,
+ detail: 'context',
+ doc: {
+ name: parameter.name,
+ descriptionMarkdown: queryContextParameterDocMarkdown(parameter),
+ },
+ }));
+ return {
+ afterQuote,
+ bare: afterQuote.map(completion => ({
+ ...completion,
+ label: contextKeyLabel(completion.label),
+ })),
+ };
+}
+
+const HTML_ENTITIES: Record<string, string> = {
+ amp: '&',
+ lt: '<',
+ gt: '>',
+ quot: '"',
+ apos: "'",
+ nbsp: ' ',
+};
+
+function decodeHtmlEntities(text: string): string {
+ return text.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (entity, code: string)
=> {
+ if (code.startsWith('#')) {
+ const codePoint = /^#x/i.test(code) ? parseInt(code.slice(2), 16) :
Number(code.slice(1));
+ // An entity that is not a valid code point is left as it is
+ return codePoint > 0 && codePoint <= 0x10ffff ?
String.fromCodePoint(codePoint) : entity;
+ }
+ return HTML_ENTITIES[code.toLowerCase()] ?? entity;
+ });
+}
+
+/**
+ * The docs URL of a link in a parameter description. The descriptions come
from the Druid docs, where links are
+ * relative to the page they appear on, but they are written so that relative
links resolve from querying/
+ * (ParameterDocumentationGeneratorTest checks that they do). A link to an
anchor of the same page can not be resolved
+ * and gives undefined.
+ */
+export function resolveDocsLink(target: string): string | undefined {
+ if (target.startsWith('https://')) return target;
+ const [path, anchor] = target.split('#', 2);
+ if (!path || /^[a-z]+:/i.test(path)) return;
+
+ const segments = ['querying'];
+ for (const segment of path.split('/')) {
+ if (segment === '..') {
+ segments.pop();
+ } else if (segment && segment !== '.') {
+ segments.push(segment);
+ }
+ }
+ // Pages are served without .md, and index.md as its directory
+ const page = segments.pop()!.replace(/\.md$/, '');
+ if (page !== 'index') segments.push(page);
+ const url = `${getLink('DOCS')}/${segments.join('/')}${page === 'index' ?
'/' : ''}`;
+ // The docs site makes anchors lowercase and hyphenated
+ return anchor ? `${url}#${anchor.trim().toLowerCase().replace(/\s+/g, '-')}`
: url;
+}
+
+/**
+ * Converts text outside of code spans and links: <br /> becomes a line break,
list items become "- " lines, **strong**
+ * becomes *emphasis*, other HTML tags are dropped and HTML entities are
decoded
+ */
+function convertHtmlText(text: string): string {
+ return decodeHtmlEntities(
+ stripHtmlTags(
+ text
+ .replace(/<br\s*\/?>/gi, '\n')
+ .replace(/<li>/gi, '\n- ')
+ .replace(/<\/(ul|ol)>/gi, '\n'),
+ ).replace(/\*\*([^*]+)\*\*/g, '*$1*'),
+ );
+}
+
+/**
+ * Drops HTML tags, repeatedly so that removing one tag can not leave another
one behind (like "<<b>i>")
+ */
+function stripHtmlTags(text: string): string {
+ let previous: string;
+ let stripped = text;
+ do {
+ previous = stripped;
+ stripped = stripped.replace(/<\/?[a-z][^>]*>/gi, '');
+ } while (stripped !== previous);
+ return stripped;
+}
+
+/**
+ * Converts text outside of code spans, where <code> becomes a code span. Each
part is decoded once: the content of a
+ * <code> tag on its own, the text around it by convertHtmlText.
+ */
+function convertDescriptionText(text: string): string {
+ return text
+ .split(/<code>([^<]*)<\/code>/i)
+ .map((part, i) => (i % 2 ? `\`${decodeHtmlEntities(part)}\`` :
convertHtmlText(part)))
+ .join('');
+}
+
+/**
+ * Converts Markdown text without links: code spans are kept as they are, the
text around them by
+ * convertDescriptionText
+ */
+function convertMarkdownText(text: string): string {
+ return text
+ .split(/(`[^`]*`)/)
+ .map((part, i) => (i % 2 ? part : convertDescriptionText(part)))
+ .join('');
+}
+
+/**
+ * Converts the Markdown of a query context parameter description, which comes
from the Druid docs, into the "doc
+ * markdown" that completion docs render. Code spans are kept as they are
(angle brackets in them are text, not HTML).
+ * A paragraph break (<br /><br />) stays as an empty line.
+ */
+export function queryContextDescriptionToDocMarkdown(description: string):
string {
+ let converted = '';
+ let textStart = 0;
+ // Links are converted before code spans are split out, since the text of a
link can be a code span, like
+ // [`indexSpec`](../ingestion/ingestion-spec.md). A code span that is not in
a link is matched on its own so that
+ // brackets in it are not taken for a link.
+ for (const match of
description.matchAll(/(`[^`]*`)|\[((?:`[^`]*`|[^[\]`])*)\]\(([^()]*)\)/g)) {
+ if (match[1] !== undefined) continue;
+ converted += convertMarkdownText(description.slice(textStart,
match.index));
+ const text = convertMarkdownText(match[2]);
+ const href = resolveDocsLink(match[3]);
+ converted += href ? `[${text}](${href})` : text;
+ textStart = match.index + match[0].length;
+ }
+ converted += convertMarkdownText(description.slice(textStart));
+ return converted
+ .split('\n')
+ .map(line => line.trim())
+ .join('\n')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+}
+
+/**
+ * The name of a query context parameter after SET: as is when it can be
written unquoted (SET keys are case-sensitive
+ * as typed, like `SET sqlTimeZone = 'UTC'`), quoted otherwise, such as for a
reserved keyword
+ */
+function contextKeyLabel(key: string): string {
+ return RefName.needsQuotes(key) ? String(C(key)) : key;
+}
+
+function docCode(value: string): string {
+ return `\`${value.replace(/`/g, '')}\``;
+}
+
+const EFFECTIVE_SOURCE_LABELS: Record<string, string> = {
+ DEFAULT_CONTEXT_PROPERTY: 'default query context',
+ BROKER_DYNAMIC_CONFIG: 'Broker dynamic config',
+};
+
+/**
+ * The runtime property of a default_value like
`${druid.server.http.defaultQueryTimeout}`, if it is one
+ */
+function defaultValueProperty(defaultValue: string | null): string | undefined
{
+ return defaultValue?.startsWith('${') && defaultValue.endsWith('}')
+ ? defaultValue.slice(2, -1)
+ : undefined;
+}
+
+/**
+ * The declared default, like "built-in: `false`" or "runtime property
`druid.server.http.defaultQueryTimeout`"
+ */
+function declaredDefault(defaultValue: string): string {
+ const property = defaultValueProperty(defaultValue);
+ return property ? `runtime property ${docCode(property)}` : `built-in:
${docCode(defaultValue)}`;
+}
+
+/**
+ * Where the default comes from when it is not the built-in one, like "
(Broker dynamic config; built-in: `false`)"
+ */
+function effectiveSourceNote(parameter: QueryContextParameterMetadata): string
{
+ if (parameter.effective_source === 'RUNTIME_PROPERTY' &&
parameter.default_value != null) {
+ return ` (${declaredDefault(parameter.default_value)})`;
+ }
+ const label = parameter.effective_source
+ ? EFFECTIVE_SOURCE_LABELS[parameter.effective_source]
+ : undefined;
+ if (!label) return '';
+ return parameter.default_value != null
+ ? ` (${label}; ${declaredDefault(parameter.default_value)})`
+ : ` (${label})`;
+}
+
+/**
+ * The doc of a query context parameter: its type, its default and then its
description, separated by empty lines
+ */
+function queryContextParameterDocMarkdown(parameter:
QueryContextParameterMetadata): string {
+ const sections = [`Type: \`${parameter.type}\``];
+ if (parameter.effective_value != null) {
+ sections.push(
+ `Default:
${docCode(parameter.effective_value)}${effectiveSourceNote(parameter)}`,
+ );
+ } else if (parameter.default_value != null) {
+ // Without CONFIG read access the Broker's effective default is hidden, so
the declared one may be overridden
+ const property = defaultValueProperty(parameter.default_value);
+ const mayOverride = parameter.effective_source === 'UNAUTHORIZED';
+ sections.push(
+ property
+ ? `Default: value of runtime property ${docCode(property)}${
+ mayOverride ? ' (the Broker may override it)' : ''
+ }`
+ : `Default: ${docCode(parameter.default_value)}${
+ mayOverride ? ' (built-in; the Broker may override it)' : ''
+ }`,
+ );
+ } else if (parameter.default_description) {
+ sections.push(
+ `Default:
${queryContextDescriptionToDocMarkdown(parameter.default_description)}${
+ parameter.effective_source === 'UNAUTHORIZED' ? ' (the Broker may
override it)' : ''
+ }`,
+ );
+ }
+ sections.push(queryContextDescriptionToDocMarkdown(parameter.description));
+ if (parameter.deprecation) {
+ sections.push(`*Deprecated*:
${queryContextDescriptionToDocMarkdown(parameter.deprecation)}`);
+ }
+ return sections.join('\n\n');
+}
diff --git a/web-console/src/editor-completions/sql-completions.spec.ts
b/web-console/src/editor-completions/sql-completions.spec.ts
index 3f2b6b6c419..d4366e1bdf6 100644
--- a/web-console/src/editor-completions/sql-completions.spec.ts
+++ b/web-console/src/editor-completions/sql-completions.spec.ts
@@ -18,6 +18,7 @@
import { EditorState } from '@codemirror/state';
+import type { QueryContextParameterMetadata } from '../druid-models';
import { dsql } from '../editor-languages/dsql';
import { completionContextAt } from '../test-utils/completion-context';
import type { ColumnMetadata } from '../utils';
@@ -241,11 +242,53 @@ describe('sql-completions', () => {
expect(columnCompletions.map(c => c.label)).toContain('"user"');
});
- it('includes context keys after SET keyword', () => {
- const completions = completionsAt('SET |');
+ it('suggests the query context parameters from
sys.query_context_parameters after SET', () => {
+ const queryContextParameters: QueryContextParameterMetadata[] = [
+ {
+ name: 'sqlTimeZone',
+ type: 'String',
+ description:
+ 'Time zone for a connection. See [time
zones](../querying/sql.md#time-zones).',
+ default_value: null,
+ default_description: 'UTC',
+ effective_value: null,
+ effective_source: null,
+ deprecation: null,
+ query_languages: ['JSON', 'SQL'],
+ },
+ {
+ name: 'useCache',
+ type: 'Boolean',
+ description: 'Whether to use the cache.<br />Applies to native
queries.',
+ default_value: 'true',
+ default_description: null,
+ effective_value: 'false',
+ effective_source: 'BROKER_DYNAMIC_CONFIG',
+ deprecation: 'Use `useResultLevelCache` instead.',
+ query_languages: ['JSON', 'SQL'],
+ },
+ ];
- const contextCompletions = completions.filter(c => c.detail ===
'context');
- expect(contextCompletions.length).toBeGreaterThan(0);
+ // Only the context parameters, no keywords, tables or columns. Their
docs are tested with getQueryContextCompletions.
+ expect(
+ completionsAt('SET |', { queryContextParameters, columnMetadata
}).map(c => [
+ c.label,
+ c.boost,
+ c.detail,
+ ]),
+ ).toEqual([
+ ['sqlTimeZone', 50, 'context'],
+ ['useCache', 40, 'context'],
+ ]);
+ });
+
+ it('falls back to the well-known context keys when the parameters are not
loaded', () => {
+ for (const queryContextParameters of [undefined, []]) {
+ const completions = completionsAt('SET |', { queryContextParameters });
+ // Only context keys, without docs
+ expect(completions.map(c => c.label)).toContain('sqlTimeZone');
+ expect(completions.every(c => c.detail === 'context' && c.doc ===
undefined)).toBe(true);
+ }
});
it('handles column names correctly with quotes', () => {
diff --git a/web-console/src/editor-completions/sql-completions.ts
b/web-console/src/editor-completions/sql-completions.ts
index 5402352dce0..5b45062a6e2 100644
--- a/web-console/src/editor-completions/sql-completions.ts
+++ b/web-console/src/editor-completions/sql-completions.ts
@@ -23,7 +23,7 @@ import { C, filterMap, N, T } from 'druid-query-toolkit';
import type { CompletionWord } from
'../components/code-editor/completion-source';
import { completeSyntaxTree, tokenBefore } from
'../components/code-editor/completion-source';
-import { DEFAULT_SERVER_QUERY_CONTEXT } from '../druid-models';
+import type { QueryContextParameterMetadata } from '../druid-models';
import { SQL_DATA_TYPES, SQL_FUNCTIONS } from '../editor-languages/dsql-docs';
import { SQL_CONSTANTS, SQL_DYNAMICS, SQL_KEYWORDS } from
'../editor-languages/dsql-keywords';
import type { AvailableFunctions } from '../helpers';
@@ -31,6 +31,7 @@ import type { ColumnMetadata } from '../utils';
import { lookupBy, uniq } from '../utils';
import type { EditorCompletion } from './editor-completion';
+import { getQueryContextCompletions } from
'./query-context-parameter-completions';
const SQL_KEYWORDS_THAT_CAN_NOT_BE_FOLLOWED_BY_FUNCTION = [
'AS',
@@ -222,6 +223,8 @@ function tokenEndingBefore(state: EditorState, pos:
number): SyntaxNode | undefi
export interface SqlCompletionOptions {
columnMetadata?: readonly ColumnMetadata[];
+ /** The query context parameters to suggest after SET, from
sys.query_context_parameters */
+ queryContextParameters?: readonly QueryContextParameterMetadata[];
columns?: readonly string[];
availableSqlFunctions?: AvailableFunctions;
skipAggregates?: boolean;
@@ -233,7 +236,13 @@ export interface SqlCompletionOptions {
export function getSqlCompletions(
{ state, pos }: CompletionContext,
{ from, text: prefix }: CompletionWord,
- { columnMetadata, columns, availableSqlFunctions, skipAggregates }:
SqlCompletionOptions = {},
+ {
+ columnMetadata,
+ columns,
+ availableSqlFunctions,
+ skipAggregates,
+ queryContextParameters,
+ }: SqlCompletionOptions = {},
): EditorCompletion[] {
const token = tokenBefore(state, pos);
@@ -277,6 +286,11 @@ export function getSqlCompletions(
return []; // Don't start completing if the user is typing a number
}
+ // After SET only a query context parameter name can follow
+ if (keywordBeforePrefix === 'SET') {
+ return getQueryContextCompletions(quote, queryContextParameters);
+ }
+
const possibleReferences = getPossibleSqlReferences(state, 100, prefix);
let completions: EditorCompletion[] = possibleReferences.map(label => ({
@@ -401,16 +415,6 @@ export function getSqlCompletions(
}
}
- if (keywordBeforePrefix === 'SET') {
- completions = completions.concat(
- Object.keys(DEFAULT_SERVER_QUERY_CONTEXT).map(key => ({
- label: quote ? key : String(C(key)),
- boost: 50,
- detail: 'context',
- })),
- );
- }
-
return completions;
}
diff --git a/web-console/src/editor-languages/dsql.ts
b/web-console/src/editor-languages/dsql.ts
index 65c86d3def0..fc997e4c9cd 100644
--- a/web-console/src/editor-languages/dsql.ts
+++ b/web-console/src/editor-languages/dsql.ts
@@ -16,12 +16,13 @@
* limitations under the License.
*/
+import type { CompletionContext } from '@codemirror/autocomplete';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { indentNodeProp, LanguageSupport, LRLanguage } from
'@codemirror/language';
import { keymap } from '@codemirror/view';
import { styleTags, tags } from '@lezer/highlight';
-import { makeCompletionSource } from
'../components/code-editor/completion-source';
+import { makeCompletionSource, tokenBefore } from
'../components/code-editor/completion-source';
import type { SqlCompletionOptions } from
'../editor-completions/sql-completions';
import { getSqlCompletions } from '../editor-completions/sql-completions';
import type { AvailableFunctions } from '../helpers';
@@ -85,6 +86,18 @@ export function getDsqlLanguage(availableSqlFunctions?:
AvailableFunctions): LRL
return language;
}
+/**
+ * Whether the cursor is right after a SET keyword and any amount of
whitespace, where the query context parameter names
+ * are suggested without typing a letter first. A SET in a string literal or a
comment does not count.
+ */
+function isAfterSetKeyword({ state, pos }: CompletionContext): boolean {
+ let setEnd = pos;
+ while (setEnd > 0 && /\s/.test(state.sliceDoc(setEnd - 1, setEnd))) setEnd--;
+ if (setEnd === pos) return false;
+ const { name, from, to } = tokenBefore(state, setEnd);
+ return name === 'Keyword' && to === setEnd && state.sliceDoc(from,
to).toUpperCase() === 'SET';
+}
+
export type DsqlOptions = SqlCompletionOptions;
/**
@@ -94,8 +107,9 @@ export function dsql(options: DsqlOptions = {}):
LanguageSupport {
const language = getDsqlLanguage(options.availableSqlFunctions);
return new LanguageSupport(language, [
language.data.of({
- autocomplete: makeCompletionSource((context, word) =>
- getSqlCompletions(context, word, options),
+ autocomplete: makeCompletionSource(
+ (context, word) => getSqlCompletions(context, word, options),
+ { completeEmptyWord: isAfterSetKeyword },
),
}),
closeBrackets(),
diff --git a/web-console/src/editor-languages/editor-languages.spec.ts
b/web-console/src/editor-languages/editor-languages.spec.ts
index 726b324324b..0f4d6074cc3 100644
--- a/web-console/src/editor-languages/editor-languages.spec.ts
+++ b/web-console/src/editor-languages/editor-languages.spec.ts
@@ -234,6 +234,21 @@ dataSource: wikipedia`;
expect(complete(hjson(), '{\n que|')).toBeUndefined();
});
+ it('completes query context parameters right after the SET keyword in
dsql', () => {
+ expect(complete(dsql(), 'SET |')).toContain('sqlTimeZone');
+ expect(complete(dsql(), 'SELECT 1;\nset |')).toContain('sqlTimeZone');
+ // However long the whitespace after SET is
+ expect(complete(dsql(), `SET\n${'
'.repeat(40)}|`)).toContain('sqlTimeZone');
+ expect(complete(dsql(), 'SELECT |')).toBeUndefined();
+ // Not the SET keyword
+ expect(complete(dsql(), "SELECT * FROM t WHERE x = 'foo set
|")).toBeUndefined();
+ expect(complete(dsql(), '-- set |')).toBeUndefined();
+ expect(complete(dsql(), 'SELECT * FROM t LIMIT 1 OFFSET
|')).toBeUndefined();
+ // Not after SET itself: in an unclosed quoted identifier or string
+ expect(complete(dsql(), 'SET "a |')).toBeUndefined();
+ expect(complete(dsql(), "SET 'a |")).toBeUndefined();
+ });
+
it('is off in read only editors', () => {
expect(complete(dsql(), 'SELECT COU|', true)).toBeUndefined();
});
diff --git a/web-console/src/helpers/capabilities.ts
b/web-console/src/helpers/capabilities.ts
index e5032c5e5eb..cf036c0fcd9 100644
--- a/web-console/src/helpers/capabilities.ts
+++ b/web-console/src/helpers/capabilities.ts
@@ -16,7 +16,9 @@
* limitations under the License.
*/
-import type { DruidEngine } from '../druid-models';
+import type { DruidEngine, QueryContextParameterMetadata } from
'../druid-models';
+// Imported from its module rather than the druid-models barrel, which imports
helpers (a circular import)
+import { QUERY_CONTEXT_PARAMETERS_QUERY } from
'../druid-models/query-context/query-context';
import { Api } from '../singletons';
import { filterMap } from '../utils';
@@ -245,6 +247,36 @@ export class Capabilities {
}
}
+ /**
+ * The query context parameters, for SET completions. Undefined when they
can not be loaded (an
+ * older Druid or no access to sys tables), in which case the completions
fall back to the well-known context keys.
+ */
+ static async detectQueryContextParameters(
+ capabilities: Capabilities,
+ ): Promise<QueryContextParameterMetadata[] | undefined> {
+ if (!capabilities.hasSql()) return;
+
+ try {
+ return (
+ await Api.instance.post<QueryContextParameterMetadata[]>(
+ '/druid/v2/sql?capabilities-query-context-parameters',
+ {
+ query: QUERY_CONTEXT_PARAMETERS_QUERY,
+ // Arrays such as query_languages come back as arrays rather than
JSON strings
+ context: {
+ engine: 'native',
+ timeout: Capabilities.STATUS_TIMEOUT,
+ sqlStringifyArrays: false,
+ },
+ },
+ { timeout: Capabilities.STATUS_TIMEOUT },
+ )
+ ).data;
+ } catch {
+ return;
+ }
+ }
+
private readonly queryType: QueryType;
private readonly multiStageQueryTask: boolean;
private readonly multiStageQueryDart: boolean;
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 ac328683ea5..7b3938473d5 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
@@ -21,6 +21,7 @@ import type { Column } from 'druid-query-toolkit';
import React from 'react';
import { CodeEditor } from '../../../../components';
+import { useQueryContextParameters } from
'../../../../contexts/query-context-parameters-context';
import { useAvailableSqlFunctions } from
'../../../../contexts/sql-functions-context';
import { dsql } from '../../../../editor-languages/dsql';
@@ -54,14 +55,16 @@ export function SqlInput(props: SqlInputProps) {
} = props;
const availableSqlFunctions = useAvailableSqlFunctions();
+ const queryContextParameters = useQueryContextParameters();
const language = React.useMemo(
() =>
dsql({
columns: columns?.map(column => column.name),
availableSqlFunctions,
+ queryContextParameters,
skipAggregates: !includeAggregates,
}),
- [columns, availableSqlFunctions, includeAggregates],
+ [columns, availableSqlFunctions, queryContextParameters,
includeAggregates],
);
return (
diff --git
a/web-console/src/views/workbench-view/flexible-query-input/flexible-query-input.tsx
b/web-console/src/views/workbench-view/flexible-query-input/flexible-query-input.tsx
index 05672f1fa10..f484e323785 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
@@ -25,6 +25,7 @@ import { keymap } from '@codemirror/view';
import React from 'react';
import { CodeEditor } from '../../../components';
+import { useQueryContextParameters } from
'../../../contexts/query-context-parameters-context';
import { useAvailableSqlFunctions } from
'../../../contexts/sql-functions-context';
import { NATIVE_JSON_QUERY_COMPLETIONS } from '../../../druid-models';
import { dsql } from '../../../editor-languages/dsql';
@@ -87,6 +88,7 @@ export function FlexibleQueryInput(props:
FlexibleQueryInputProps) {
} = props;
const availableSqlFunctions = useAvailableSqlFunctions();
+ const queryContextParameters = useQueryContextParameters();
const editorViewRef = React.useRef<EditorView | undefined>(undefined);
React.useImperativeHandle(ref, () => editorViewRef.current);
@@ -127,8 +129,8 @@ export function FlexibleQueryInput(props:
FlexibleQueryInputProps) {
() =>
isJson
? hjson({ jsonCompletions: NATIVE_JSON_QUERY_COMPLETIONS })
- : dsql({ columnMetadata, availableSqlFunctions }),
- [isJson, columnMetadata, availableSqlFunctions],
+ : dsql({ columnMetadata, availableSqlFunctions, queryContextParameters
}),
+ [isJson, columnMetadata, availableSqlFunctions, queryContextParameters],
);
return (
diff --git
a/web-console/src/views/workbench-view/workbench-history-dialog/workbench-history-dialog.tsx
b/web-console/src/views/workbench-view/workbench-history-dialog/workbench-history-dialog.tsx
index bc34224960b..33d3b479780 100644
---
a/web-console/src/views/workbench-view/workbench-history-dialog/workbench-history-dialog.tsx
+++
b/web-console/src/views/workbench-view/workbench-history-dialog/workbench-history-dialog.tsx
@@ -31,6 +31,7 @@ import type { JSX } from 'react';
import React, { useMemo, useState } from 'react';
import { CenterMessage, CodeEditor } from '../../../components';
+import { useQueryContextParameters } from
'../../../contexts/query-context-parameters-context';
import { useAvailableSqlFunctions } from
'../../../contexts/sql-functions-context';
import type { WorkbenchQuery } from '../../../druid-models';
import { dsql } from '../../../editor-languages/dsql';
@@ -53,7 +54,11 @@ export const WorkbenchHistoryDialog = React.memo(function
WorkbenchHistoryDialog
const [activeTab, setActiveTab] = useState(0);
const [queryRecords] = useState(() => WorkbenchHistory.getHistory());
const availableSqlFunctions = useAvailableSqlFunctions();
- const sqlLanguage = useMemo(() => dsql({ availableSqlFunctions }),
[availableSqlFunctions]);
+ const queryContextParameters = useQueryContextParameters();
+ const sqlLanguage = useMemo(
+ () => dsql({ availableSqlFunctions, queryContextParameters }),
+ [availableSqlFunctions, queryContextParameters],
+ );
function handleSelect() {
const queryRecord = queryRecords[activeTab];
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]