From 483a03f26c7221f10493d86715985a58e61af967 Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Fri, 9 Aug 2024 14:46:59 -0700 Subject: [PATCH] Web console: Server context defaults (#16868) * add server defaults * null is NULL * r to d * add test * typo --- licenses.yaml | 2 +- web-console/console-config.js | 3 +- web-console/package-lock.json | 14 +- web-console/package.json | 2 +- .../src/components/header-bar/header-bar.tsx | 3 +- web-console/src/console-application.tsx | 63 +++-- .../query-context/query-context.tsx | 251 +++--------------- web-console/src/entry.tsx | 14 +- .../helpers/execution/sql-task-execution.ts | 16 +- web-console/src/utils/general.tsx | 6 + web-console/src/utils/values-query.spec.tsx | 4 +- web-console/src/utils/values-query.tsx | 21 +- .../sql-data-loader-view.tsx | 12 +- .../max-tasks-button.spec.tsx | 8 +- .../max-tasks-button/max-tasks-button.tsx | 47 ++-- .../workbench-view/query-tab/query-tab.tsx | 32 ++- .../workbench-view/run-panel/run-panel.tsx | 210 +++++++++------ .../views/workbench-view/workbench-view.tsx | 46 +++- 18 files changed, 343 insertions(+), 411 deletions(-) diff --git a/licenses.yaml b/licenses.yaml index dcdac7bd1879..0646c7131fd5 100644 --- a/licenses.yaml +++ b/licenses.yaml @@ -5085,7 +5085,7 @@ license_category: binary module: web-console license_name: Apache License version 2.0 copyright: Imply Data -version: 0.22.20 +version: 0.22.21 --- diff --git a/web-console/console-config.js b/web-console/console-config.js index 10bdddb611af..25d99e7c6504 100644 --- a/web-console/console-config.js +++ b/web-console/console-config.js @@ -17,6 +17,5 @@ */ window.consoleConfig = { - exampleManifestsUrl: 'https://druid.apache.org/data/example-manifests-v2.tsv', - /* future configs may go here */ + /* configs go here */ }; diff --git a/web-console/package-lock.json b/web-console/package-lock.json index 412f728d56d6..e9319969b692 100644 --- a/web-console/package-lock.json +++ b/web-console/package-lock.json @@ -14,7 +14,7 @@ "@blueprintjs/datetime2": "^2.3.7", "@blueprintjs/icons": "^5.10.0", "@blueprintjs/select": "^5.2.1", - "@druid-toolkit/query": "^0.22.20", + "@druid-toolkit/query": "^0.22.21", "@druid-toolkit/visuals-core": "^0.3.3", "@druid-toolkit/visuals-react": "^0.3.3", "@fontsource/open-sans": "^5.0.28", @@ -989,9 +989,9 @@ } }, "node_modules/@druid-toolkit/query": { - "version": "0.22.20", - "resolved": "https://registry.npmjs.org/@druid-toolkit/query/-/query-0.22.20.tgz", - "integrity": "sha512-GmmSd27y7zLVTjgTBQy+XoGeSSGhSDNmwyiwWtSua7I5LX8XqHV7Chi8HIH25YQoVgTK1pLK4RS8eRXxthRAzg==", + "version": "0.22.21", + "resolved": "https://registry.npmjs.org/@druid-toolkit/query/-/query-0.22.21.tgz", + "integrity": "sha512-4k0NGO2Ay90naSO8nyivPPvvhz73D/OkCo6So3frmPDLFfw5CYKSvAhy4RadtnLMZPwsnlVREjAmqbvBsHqgjQ==", "dependencies": { "tslib": "^2.5.2" } @@ -19093,9 +19093,9 @@ "dev": true }, "@druid-toolkit/query": { - "version": "0.22.20", - "resolved": "https://registry.npmjs.org/@druid-toolkit/query/-/query-0.22.20.tgz", - "integrity": "sha512-GmmSd27y7zLVTjgTBQy+XoGeSSGhSDNmwyiwWtSua7I5LX8XqHV7Chi8HIH25YQoVgTK1pLK4RS8eRXxthRAzg==", + "version": "0.22.21", + "resolved": "https://registry.npmjs.org/@druid-toolkit/query/-/query-0.22.21.tgz", + "integrity": "sha512-4k0NGO2Ay90naSO8nyivPPvvhz73D/OkCo6So3frmPDLFfw5CYKSvAhy4RadtnLMZPwsnlVREjAmqbvBsHqgjQ==", "requires": { "tslib": "^2.5.2" } diff --git a/web-console/package.json b/web-console/package.json index 0c9370f88083..d55bb79d6090 100644 --- a/web-console/package.json +++ b/web-console/package.json @@ -68,7 +68,7 @@ "@blueprintjs/datetime2": "^2.3.7", "@blueprintjs/icons": "^5.10.0", "@blueprintjs/select": "^5.2.1", - "@druid-toolkit/query": "^0.22.20", + "@druid-toolkit/query": "^0.22.21", "@druid-toolkit/visuals-core": "^0.3.3", "@druid-toolkit/visuals-react": "^0.3.3", "@fontsource/open-sans": "^5.0.28", diff --git a/web-console/src/components/header-bar/header-bar.tsx b/web-console/src/components/header-bar/header-bar.tsx index e1b97cf4e131..aed667982999 100644 --- a/web-console/src/components/header-bar/header-bar.tsx +++ b/web-console/src/components/header-bar/header-bar.tsx @@ -59,7 +59,6 @@ import './header-bar.scss'; const capabilitiesOverride = localStorageGetJson(LocalStorageKeys.CAPABILITIES_OVERRIDE); export type HeaderActiveTab = - | null | 'data-loader' | 'streaming-data-loader' | 'classic-batch-data-loader' @@ -93,7 +92,7 @@ const DruidLogo = React.memo(function DruidLogo() { }); export interface HeaderBarProps { - active: HeaderActiveTab; + active: HeaderActiveTab | null; capabilities: Capabilities; onUnrestrict(capabilities: Capabilities): void; } diff --git a/web-console/src/console-application.tsx b/web-console/src/console-application.tsx index 0d097729cbf4..36a0b8aa3921 100644 --- a/web-console/src/console-application.tsx +++ b/web-console/src/console-application.tsx @@ -28,7 +28,7 @@ import type { Filter } from 'react-table'; import type { HeaderActiveTab } from './components'; import { HeaderBar, Loader } from './components'; -import type { DruidEngine, QueryWithContext } from './druid-models'; +import type { DruidEngine, QueryContext, QueryWithContext } from './druid-models'; import { Capabilities, maybeGetClusterCapacity } from './helpers'; import { stringToTableFilters, tableFiltersToString } from './react-table'; import { AppToaster } from './singletons'; @@ -51,22 +51,32 @@ import './console-application.scss'; type FiltersRouteMatch = RouteComponentProps<{ filters?: string }>; -function changeHashWithFilter(slug: string, filters: Filter[]) { +function changeTabWithFilter(tab: HeaderActiveTab, filters: Filter[]) { const filterString = tableFiltersToString(filters); - location.hash = slug + (filterString ? `/${filterString}` : ''); + location.hash = tab + (filterString ? `/${filterString}` : ''); } -function viewFilterChange(slug: string) { - return (filters: Filter[]) => changeHashWithFilter(slug, filters); +function viewFilterChange(tab: HeaderActiveTab) { + return (filters: Filter[]) => changeTabWithFilter(tab, filters); } -function pathWithFilter(slug: string) { - return [`/${slug}/:filters`, `/${slug}`]; +function pathWithFilter(tab: HeaderActiveTab) { + return [`/${tab}/:filters`, `/${tab}`]; +} + +function switchTab(tab: HeaderActiveTab) { + location.hash = tab; +} + +function switchToWorkbenchTab(tabId: string) { + location.hash = `workbench/${tabId}`; } export interface ConsoleApplicationProps { - defaultQueryContext?: Record; - mandatoryQueryContext?: Record; + baseQueryContext?: QueryContext; + defaultQueryContext?: QueryContext; + mandatoryQueryContext?: QueryContext; + serverQueryContext?: QueryContext; } export interface ConsoleApplicationState { @@ -158,22 +168,22 @@ export class ConsoleApplication extends React.PureComponent< private readonly goToStreamingDataLoader = (supervisorId?: string) => { if (supervisorId) this.supervisorId = supervisorId; - location.hash = 'streaming-data-loader'; + switchTab('streaming-data-loader'); this.resetInitialsWithDelay(); }; private readonly goToClassicBatchDataLoader = (taskId?: string) => { if (taskId) this.taskId = taskId; - location.hash = 'classic-batch-data-loader'; + switchTab('classic-batch-data-loader'); this.resetInitialsWithDelay(); }; private readonly goToDatasources = (datasource: string) => { - changeHashWithFilter('datasources', [{ id: 'datasource', value: `=${datasource}` }]); + changeTabWithFilter('datasources', [{ id: 'datasource', value: `=${datasource}` }]); }; private readonly goToSegments = (datasource: string, onlyUnavailable = false) => { - changeHashWithFilter( + changeTabWithFilter( 'segments', compact([ { id: 'datasource', value: `=${datasource}` }, @@ -183,19 +193,19 @@ export class ConsoleApplication extends React.PureComponent< }; private readonly goToSupervisor = (supervisorId: string) => { - changeHashWithFilter('supervisors', [{ id: 'supervisor_id', value: `=${supervisorId}` }]); + changeTabWithFilter('supervisors', [{ id: 'supervisor_id', value: `=${supervisorId}` }]); }; private readonly goToTasksWithTaskId = (taskId: string) => { - changeHashWithFilter('tasks', [{ id: 'task_id', value: `=${taskId}` }]); + changeTabWithFilter('tasks', [{ id: 'task_id', value: `=${taskId}` }]); }; private readonly goToTasksWithTaskGroupId = (taskGroupId: string) => { - changeHashWithFilter('tasks', [{ id: 'group_id', value: `=${taskGroupId}` }]); + changeTabWithFilter('tasks', [{ id: 'group_id', value: `=${taskGroupId}` }]); }; private readonly goToTasksWithDatasource = (datasource: string, type?: string) => { - changeHashWithFilter( + changeTabWithFilter( 'tasks', compact([ { id: 'datasource', value: `=${datasource}` }, @@ -206,24 +216,24 @@ export class ConsoleApplication extends React.PureComponent< private readonly openSupervisorSubmit = () => { this.openSupervisorDialog = true; - location.hash = 'supervisors'; + switchTab('supervisors'); this.resetInitialsWithDelay(); }; private readonly openTaskSubmit = () => { this.openTaskDialog = true; - location.hash = 'tasks'; + switchTab('tasks'); this.resetInitialsWithDelay(); }; private readonly goToQuery = (queryWithContext: QueryWithContext) => { this.queryWithContext = queryWithContext; - location.hash = 'workbench'; + switchTab('workbench'); this.resetInitialsWithDelay(); }; private readonly wrapInViewContainer = ( - active: HeaderActiveTab, + active: HeaderActiveTab | null, el: JSX.Element, classType: 'normal' | 'narrow-pad' | 'thin' | 'thinner' = 'normal', ) => { @@ -293,7 +303,8 @@ export class ConsoleApplication extends React.PureComponent< }; private readonly wrappedWorkbenchView = (p: RouteComponentProps<{ tabId?: string }>) => { - const { defaultQueryContext, mandatoryQueryContext } = this.props; + const { defaultQueryContext, mandatoryQueryContext, baseQueryContext, serverQueryContext } = + this.props; const { capabilities } = this.state; const queryEngines: DruidEngine[] = ['native']; @@ -309,12 +320,12 @@ export class ConsoleApplication extends React.PureComponent< { - location.hash = `workbench/${newTabId}`; - }} + onTabChange={switchToWorkbenchTab} initQueryWithContext={this.queryWithContext} defaultQueryContext={defaultQueryContext} mandatoryQueryContext={mandatoryQueryContext} + baseQueryContext={baseQueryContext} + serverQueryContext={serverQueryContext} queryEngines={queryEngines} allowExplain goToTask={this.goToTasksWithTaskId} @@ -325,6 +336,7 @@ export class ConsoleApplication extends React.PureComponent< }; private readonly wrappedSqlDataLoaderView = () => { + const { serverQueryContext } = this.props; const { capabilities } = this.state; return this.wrapInViewContainer( 'sql-data-loader', @@ -334,6 +346,7 @@ export class ConsoleApplication extends React.PureComponent< goToTask={this.goToTasksWithTaskId} goToTaskGroup={this.goToTasksWithTaskGroupId} getClusterCapacity={maybeGetClusterCapacity} + serverQueryContext={serverQueryContext} />, ); }; 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 17e8204e9496..a25d268d8457 100644 --- a/web-console/src/druid-models/query-context/query-context.tsx +++ b/web-console/src/druid-models/query-context/query-context.tsx @@ -16,9 +16,10 @@ * limitations under the License. */ -import { deepDelete, deepSet } from '../../utils'; - +export type SelectDestination = 'taskReport' | 'durableStorage'; export type ArrayIngestMode = 'array' | 'mvd'; +export type TaskAssignment = 'auto' | 'max'; +export type SqlJoinAlgorithm = 'broadcast' | 'sortMerge'; export interface QueryContext { useCache?: boolean; @@ -30,15 +31,38 @@ export interface QueryContext { // Multi-stage query maxNumTasks?: number; finalizeAggregations?: boolean; - selectDestination?: string; + selectDestination?: SelectDestination; durableShuffleStorage?: boolean; maxParseExceptions?: number; groupByEnableMultiValueUnnesting?: boolean; arrayIngestMode?: ArrayIngestMode; + taskAssignment?: TaskAssignment; + sqlJoinAlgorithm?: SqlJoinAlgorithm; + failOnEmptyInsert?: boolean; + waitUntilSegmentsLoad?: boolean; [key: string]: any; } +export const DEFAULT_SERVER_QUERY_CONTEXT: QueryContext = { + useCache: true, + populateCache: true, + useApproximateCountDistinct: true, + useApproximateTopN: true, + sqlTimeZone: 'Etc/UTC', + + // Multi-stage query + finalizeAggregations: true, + selectDestination: 'taskReport', + durableShuffleStorage: false, + maxParseExceptions: 0, + groupByEnableMultiValueUnnesting: true, + taskAssignment: 'max', + sqlJoinAlgorithm: 'broadcast', + failOnEmptyInsert: false, + waitUntilSegmentsLoad: false, +}; + export interface QueryWithContext { queryString: string; queryContext?: QueryContext; @@ -49,221 +73,10 @@ export function isEmptyContext(context: QueryContext | undefined): boolean { return !context || Object.keys(context).length === 0; } -// ----------------------------- - -export function getUseCache(context: QueryContext): boolean { - const { useCache } = context; - return typeof useCache === 'boolean' ? useCache : true; -} - -export function changeUseCache(context: QueryContext, useCache: boolean): QueryContext { - let newContext = context; - if (useCache) { - newContext = deepDelete(newContext, 'useCache'); - newContext = deepDelete(newContext, 'populateCache'); - } else { - newContext = deepSet(newContext, 'useCache', false); - newContext = deepSet(newContext, 'populateCache', false); - } - return newContext; -} - -// ----------------------------- - -export function getUseApproximateCountDistinct(context: QueryContext): boolean { - const { useApproximateCountDistinct } = context; - return typeof useApproximateCountDistinct === 'boolean' ? useApproximateCountDistinct : true; -} - -export function changeUseApproximateCountDistinct( - context: QueryContext, - useApproximateCountDistinct: boolean, -): QueryContext { - if (useApproximateCountDistinct) { - return deepDelete(context, 'useApproximateCountDistinct'); - } else { - return deepSet(context, 'useApproximateCountDistinct', false); - } -} - -// ----------------------------- - -export function getUseApproximateTopN(context: QueryContext): boolean { - const { useApproximateTopN } = context; - return typeof useApproximateTopN === 'boolean' ? useApproximateTopN : true; -} - -export function changeUseApproximateTopN( - context: QueryContext, - useApproximateTopN: boolean, -): QueryContext { - if (useApproximateTopN) { - return deepDelete(context, 'useApproximateTopN'); - } else { - return deepSet(context, 'useApproximateTopN', false); - } -} - -// sqlTimeZone - -export function getTimezone(context: QueryContext): string | undefined { - return context.sqlTimeZone; -} - -export function changeTimezone(context: QueryContext, timezone: string | undefined): QueryContext { - if (timezone) { - return deepSet(context, 'sqlTimeZone', timezone); - } else { - return deepDelete(context, 'sqlTimeZone'); - } -} - -// maxNumTasks - -export function getMaxNumTasks(context: QueryContext): number | undefined { - return context.maxNumTasks; -} - -export function changeMaxNumTasks( - context: QueryContext, - maxNumTasks: number | undefined, -): QueryContext { - return typeof maxNumTasks === 'number' - ? deepSet(context, 'maxNumTasks', maxNumTasks) - : deepDelete(context, 'maxNumTasks'); -} - -// taskAssignment - -export function getTaskAssigment(context: QueryContext): string { - const { taskAssignment } = context; - return taskAssignment ?? 'max'; -} - -export function changeTaskAssigment( - context: QueryContext, - taskAssignment: string | undefined, -): QueryContext { - return typeof taskAssignment === 'string' - ? deepSet(context, 'taskAssignment', taskAssignment) - : deepDelete(context, 'taskAssignment'); -} - -// failOnEmptyInsert - -export function getFailOnEmptyInsert(context: QueryContext): boolean | undefined { - const { failOnEmptyInsert } = context; - return typeof failOnEmptyInsert === 'boolean' ? failOnEmptyInsert : undefined; -} - -export function changeFailOnEmptyInsert( - context: QueryContext, - failOnEmptyInsert: boolean | undefined, -): QueryContext { - return typeof failOnEmptyInsert === 'boolean' - ? deepSet(context, 'failOnEmptyInsert', failOnEmptyInsert) - : deepDelete(context, 'failOnEmptyInsert'); -} - -// finalizeAggregations - -export function getFinalizeAggregations(context: QueryContext): boolean | undefined { - const { finalizeAggregations } = context; - return typeof finalizeAggregations === 'boolean' ? finalizeAggregations : undefined; -} - -export function changeFinalizeAggregations( - context: QueryContext, - finalizeAggregations: boolean | undefined, -): QueryContext { - return typeof finalizeAggregations === 'boolean' - ? deepSet(context, 'finalizeAggregations', finalizeAggregations) - : deepDelete(context, 'finalizeAggregations'); -} - -// waitUntilSegmentsLoad - -export function getWaitUntilSegmentsLoad(context: QueryContext): boolean | undefined { - const { waitUntilSegmentsLoad } = context; - return typeof waitUntilSegmentsLoad === 'boolean' ? waitUntilSegmentsLoad : undefined; -} - -export function changeWaitUntilSegmentsLoad( - context: QueryContext, - waitUntilSegmentsLoad: boolean | undefined, -): QueryContext { - return typeof waitUntilSegmentsLoad === 'boolean' - ? deepSet(context, 'waitUntilSegmentsLoad', waitUntilSegmentsLoad) - : deepDelete(context, 'waitUntilSegmentsLoad'); -} - -// groupByEnableMultiValueUnnesting - -export function getGroupByEnableMultiValueUnnesting(context: QueryContext): boolean | undefined { - const { groupByEnableMultiValueUnnesting } = context; - return typeof groupByEnableMultiValueUnnesting === 'boolean' - ? groupByEnableMultiValueUnnesting - : undefined; -} - -export function changeGroupByEnableMultiValueUnnesting( - context: QueryContext, - groupByEnableMultiValueUnnesting: boolean | undefined, -): QueryContext { - return typeof groupByEnableMultiValueUnnesting === 'boolean' - ? deepSet(context, 'groupByEnableMultiValueUnnesting', groupByEnableMultiValueUnnesting) - : deepDelete(context, 'groupByEnableMultiValueUnnesting'); -} - -// durableShuffleStorage - -export function getDurableShuffleStorage(context: QueryContext): boolean { - const { durableShuffleStorage } = context; - return Boolean(durableShuffleStorage); -} - -export function changeDurableShuffleStorage( - context: QueryContext, - durableShuffleStorage: boolean, -): QueryContext { - if (durableShuffleStorage) { - return deepSet(context, 'durableShuffleStorage', true); - } else { - return deepDelete(context, 'durableShuffleStorage'); - } -} - -// maxParseExceptions - -export function getMaxParseExceptions(context: QueryContext): number { - const { maxParseExceptions } = context; - return Number(maxParseExceptions) || 0; -} - -export function changeMaxParseExceptions( - context: QueryContext, - maxParseExceptions: number, -): QueryContext { - if (maxParseExceptions !== 0) { - return deepSet(context, 'maxParseExceptions', maxParseExceptions); - } else { - return deepDelete(context, 'maxParseExceptions'); - } -} - -// arrayIngestMode - -export function getArrayIngestMode(context: QueryContext): ArrayIngestMode | undefined { - return context.arrayIngestMode; -} - -export function changeArrayIngestMode( +export function getQueryContextKey( + key: keyof QueryContext, context: QueryContext, - arrayIngestMode: ArrayIngestMode | undefined, -): QueryContext { - if (arrayIngestMode) { - return deepSet(context, 'arrayIngestMode', arrayIngestMode); - } else { - return deepDelete(context, 'arrayIngestMode'); - } + defaultContext: QueryContext, +): any { + return typeof context[key] !== 'undefined' ? context[key] : defaultContext[key]; } diff --git a/web-console/src/entry.tsx b/web-console/src/entry.tsx index 25518ecdeb95..0e698a3f84bc 100644 --- a/web-console/src/entry.tsx +++ b/web-console/src/entry.tsx @@ -28,6 +28,7 @@ import { createRoot } from 'react-dom/client'; import { bootstrapJsonParse } from './bootstrap/json-parser'; import { bootstrapReactTable } from './bootstrap/react-table-defaults'; import { ConsoleApplication } from './console-application'; +import type { QueryContext } from './druid-models'; import type { Links } from './links'; import { setLinkOverrides } from './links'; import { Api, UrlBaser } from './singletons'; @@ -55,11 +56,16 @@ interface ConsoleConfig { // A set of custom headers name/value to set on every AJAX request customHeaders?: Record; - // The query context to set if the user does not have one saved in local storage, defaults to {} - defaultQueryContext?: Record; + baseQueryContext?: QueryContext; + + // The query context to set one new query tabs + defaultQueryContext?: QueryContext; // Extra context properties that will be added to all query requests - mandatoryQueryContext?: Record; + mandatoryQueryContext?: QueryContext; + + // The default context that is set by the server + serverQueryContext?: QueryContext; // Allow for link overriding to different docs linkOverrides?: Links; @@ -104,8 +110,10 @@ QueryRunner.defaultQueryExecutor = (payload, isSql, cancelToken) => { createRoot(container).render( , ); diff --git a/web-console/src/helpers/execution/sql-task-execution.ts b/web-console/src/helpers/execution/sql-task-execution.ts index 0fa9b0909597..f4dd45a2cb91 100644 --- a/web-console/src/helpers/execution/sql-task-execution.ts +++ b/web-console/src/helpers/execution/sql-task-execution.ts @@ -36,6 +36,7 @@ function ensureExecutionModeIsSet(context: QueryContext | undefined): QueryConte export interface SubmitTaskQueryOptions { query: string | Record; context?: QueryContext; + baseQueryContext?: QueryContext; prefixLines?: number; cancelToken?: CancelToken; preserveOnTermination?: boolean; @@ -45,7 +46,15 @@ export interface SubmitTaskQueryOptions { export async function submitTaskQuery( options: SubmitTaskQueryOptions, ): Promise> { - const { query, context, prefixLines, cancelToken, preserveOnTermination, onSubmitted } = options; + const { + query, + context, + baseQueryContext, + prefixLines, + cancelToken, + preserveOnTermination, + onSubmitted, + } = options; let sqlQuery: string; let jsonQuery: Record; @@ -53,7 +62,7 @@ export async function submitTaskQuery( sqlQuery = query; jsonQuery = { query: sqlQuery, - context: ensureExecutionModeIsSet(context), + context: ensureExecutionModeIsSet({ ...baseQueryContext, ...context }), resultFormat: 'array', header: true, typesHeader: true, @@ -65,6 +74,7 @@ export async function submitTaskQuery( jsonQuery = { ...query, context: ensureExecutionModeIsSet({ + ...baseQueryContext, ...query.context, ...context, }), @@ -96,7 +106,7 @@ export async function submitTaskQuery( ); } - const execution = Execution.fromAsyncStatus(sqlAsyncStatus, sqlQuery, context); + const execution = Execution.fromAsyncStatus(sqlAsyncStatus, sqlQuery, jsonQuery.context); if (onSubmitted) { onSubmitted(execution.id); diff --git a/web-console/src/utils/general.tsx b/web-console/src/utils/general.tsx index a3256c3ab11d..7698d3c3af89 100644 --- a/web-console/src/utils/general.tsx +++ b/web-console/src/utils/general.tsx @@ -389,6 +389,12 @@ export function assemble(...xs: (T | undefined | false | null | '')[]): T[] { return compact(xs); } +export function removeUndefinedValues>(obj: T): Partial { + return Object.fromEntries( + Object.entries(obj).filter(([_, value]) => value !== undefined), + ) as Partial; +} + export function moveToEnd( xs: T[], predicate: (value: T, index: number, array: T[]) => unknown, diff --git a/web-console/src/utils/values-query.spec.tsx b/web-console/src/utils/values-query.spec.tsx index 99884f382c1c..7bc093bc3e82 100644 --- a/web-console/src/utils/values-query.spec.tsx +++ b/web-console/src/utils/values-query.spec.tsx @@ -45,6 +45,7 @@ describe('queryResultToValuesQuery', () => { [2, 3], null, ], + [null, null, null, null, null, null, null], ], false, true, @@ -64,7 +65,8 @@ describe('queryResultToValuesQuery', () => { FROM ( VALUES ('2022-02-01T00:00:00.000Z', 'brokerA.internal', 'broker', '{"type":"sys","swap/free":1223334,"swap/max":3223334}', 'es<#>es-419', '1', NULL), - ('2022-02-01T00:00:00.000Z', 'brokerA.internal', 'broker', '{"type":"query","time":1223,"bytes":2434234}', 'en<#>es<#>es-419', '2<#>3', NULL) + ('2022-02-01T00:00:00.000Z', 'brokerA.internal', 'broker', '{"type":"query","time":1223,"bytes":2434234}', 'en<#>es<#>es-419', '2<#>3', NULL), + (NULL, NULL, NULL, NULL, NULL, NULL, NULL) ) AS "t" ("c1", "c2", "c3", "c4", "c5", "c6", "c7") `); }); diff --git a/web-console/src/utils/values-query.tsx b/web-console/src/utils/values-query.tsx index 2f1a5f699cae..1b5e62b44c23 100644 --- a/web-console/src/utils/values-query.tsx +++ b/web-console/src/utils/values-query.tsx @@ -65,28 +65,29 @@ export function queryResultToValuesQuery(sample: QueryResult): SqlQuery { expression: SqlValues.create( rows.map(row => SqlRecord.create( - row.map((r, i) => { + row.map((d, i) => { + if (d == null) return L.NULL; const column = header[i]; const { nativeType } = column; const sqlType = getEffectiveSqlType(column); if (nativeType === 'COMPLEX') { - return L(isJsonString(r) ? r : JSONBig.stringify(r)); + return L(isJsonString(d) ? d : JSONBig.stringify(d)); } else if (String(sqlType).endsWith(' ARRAY')) { - return L(r.join(SAMPLE_ARRAY_SEPARATOR)); + return L(d.join(SAMPLE_ARRAY_SEPARATOR)); } else if ( sqlType === 'OTHER' && String(nativeType).startsWith('COMPLEX<') && - typeof r === 'string' && - r.startsWith('"') && - r.endsWith('"') + typeof d === 'string' && + d.startsWith('"') && + d.endsWith('"') ) { - // r is a JSON encoded base64 string - return L(r.slice(1, -1)); - } else if (typeof r === 'object') { + // d is a JSON encoded base64 string + return L(d.slice(1, -1)); + } else if (typeof d === 'object') { // Cleanup array if it happens to get here, it shouldn't. return L.NULL; } else { - return L(r); + return L(d); } }), ), diff --git a/web-console/src/views/sql-data-loader-view/sql-data-loader-view.tsx b/web-console/src/views/sql-data-loader-view/sql-data-loader-view.tsx index 6cc00957b8d4..2e10734a170e 100644 --- a/web-console/src/views/sql-data-loader-view/sql-data-loader-view.tsx +++ b/web-console/src/views/sql-data-loader-view/sql-data-loader-view.tsx @@ -30,6 +30,7 @@ import type { QueryWithContext, } from '../../druid-models'; import { + DEFAULT_SERVER_QUERY_CONTEXT, Execution, externalConfigToIngestQueryPattern, ingestQueryPatternToQuery, @@ -65,12 +66,20 @@ export interface SqlDataLoaderViewProps { goToTask(taskId: string): void; goToTaskGroup(taskGroupId: string): void; getClusterCapacity: (() => Promise) | undefined; + serverQueryContext?: QueryContext; } export const SqlDataLoaderView = React.memo(function SqlDataLoaderView( props: SqlDataLoaderViewProps, ) { - const { capabilities, goToQuery, goToTask, goToTaskGroup, getClusterCapacity } = props; + const { + capabilities, + goToQuery, + goToTask, + goToTaskGroup, + getClusterCapacity, + serverQueryContext = DEFAULT_SERVER_QUERY_CONTEXT, + } = props; const [alertElement, setAlertElement] = useState(); const [externalConfigStep, setExternalConfigStep] = useState>({}); const [content, setContent] = useLocalStorageState( @@ -187,6 +196,7 @@ export const SqlDataLoaderView = React.memo(function SqlDataLoaderView( clusterCapacity={capabilities.getMaxTaskSlots()} queryContext={content.queryContext || {}} changeQueryContext={queryContext => setContent({ ...content, queryContext })} + defaultQueryContext={serverQueryContext} minimal /> } diff --git a/web-console/src/views/workbench-view/max-tasks-button/max-tasks-button.spec.tsx b/web-console/src/views/workbench-view/max-tasks-button/max-tasks-button.spec.tsx index 5954c1f3f9b2..1ae864dee065 100644 --- a/web-console/src/views/workbench-view/max-tasks-button/max-tasks-button.spec.tsx +++ b/web-console/src/views/workbench-view/max-tasks-button/max-tasks-button.spec.tsx @@ -18,6 +18,7 @@ import React from 'react'; +import { DEFAULT_SERVER_QUERY_CONTEXT } from '../../../druid-models'; import { shallow } from '../../../utils/shallow-renderer'; import { MaxTasksButton } from './max-tasks-button'; @@ -25,7 +26,12 @@ import { MaxTasksButton } from './max-tasks-button'; describe('MaxTasksButton', () => { it('matches snapshot', () => { const comp = shallow( - {}} />, + {}} + defaultQueryContext={DEFAULT_SERVER_QUERY_CONTEXT} + />, ); expect(comp).toMatchSnapshot(); diff --git a/web-console/src/views/workbench-view/max-tasks-button/max-tasks-button.tsx b/web-console/src/views/workbench-view/max-tasks-button/max-tasks-button.tsx index ea239bc3a268..c84f9f00e37e 100644 --- a/web-console/src/views/workbench-view/max-tasks-button/max-tasks-button.tsx +++ b/web-console/src/views/workbench-view/max-tasks-button/max-tasks-button.tsx @@ -19,37 +19,38 @@ import type { ButtonProps } from '@blueprintjs/core'; import { Button, Menu, MenuDivider, MenuItem, Popover, Position } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; +import type { JSX } from 'react'; import React, { useState } from 'react'; import { NumericInputDialog } from '../../../dialogs'; -import type { QueryContext } from '../../../druid-models'; -import { - changeMaxNumTasks, - changeTaskAssigment, - getMaxNumTasks, - getTaskAssigment, -} from '../../../druid-models'; -import { formatInteger, tickIcon } from '../../../utils'; +import type { QueryContext, TaskAssignment } from '../../../druid-models'; +import { getQueryContextKey } from '../../../druid-models'; +import { deleteKeys, formatInteger, tickIcon } from '../../../utils'; const MAX_NUM_TASK_OPTIONS = [2, 3, 4, 5, 7, 9, 11, 17, 33, 65, 129]; -const TASK_ASSIGNMENT_OPTIONS = ['max', 'auto']; +const TASK_ASSIGNMENT_OPTIONS: TaskAssignment[] = ['max', 'auto']; const TASK_ASSIGNMENT_DESCRIPTION: Record = { max: 'Use as many tasks as possible, up to the maximum.', auto: `Use as few tasks as possible without exceeding 512 MiB or 10,000 files per task, unless exceeding these limits is necessary to stay within 'maxNumTasks'. When calculating the size of files, the weighted size is used, which considers the file format and compression format used if any. When file sizes cannot be determined through directory listing (for example: http), behaves the same as 'max'.`, }; -const DEFAULT_MAX_NUM_LABEL_FN = (maxNum: number) => { +const DEFAULT_MAX_NUM_TASKS_LABEL_FN = (maxNum: number) => { if (maxNum === 2) return { text: formatInteger(maxNum), label: '(1 controller + 1 worker)' }; return { text: formatInteger(maxNum), label: `(1 controller + max ${maxNum - 1} workers)` }; }; +const DEFAULT_FULL_CLUSTER_CAPACITY_LABEL_FN = (clusterCapacity: number) => + `${formatInteger(clusterCapacity)} (full cluster capacity)`; + export interface MaxTasksButtonProps extends Omit { clusterCapacity: number | undefined; queryContext: QueryContext; changeQueryContext(queryContext: QueryContext): void; + defaultQueryContext: QueryContext; menuHeader?: JSX.Element; - maxNumLabelFn?: (maxNum: number) => { text: string; label?: string }; + maxTasksLabelFn?: (maxNum: number) => { text: string; label?: string }; + fullClusterCapacityLabelFn?: (clusterCapacity: number) => string; } export const MaxTasksButton = function MaxTasksButton(props: MaxTasksButtonProps) { @@ -57,19 +58,19 @@ export const MaxTasksButton = function MaxTasksButton(props: MaxTasksButtonProps clusterCapacity, queryContext, changeQueryContext, + defaultQueryContext, menuHeader, - maxNumLabelFn = DEFAULT_MAX_NUM_LABEL_FN, + maxTasksLabelFn = DEFAULT_MAX_NUM_TASKS_LABEL_FN, + fullClusterCapacityLabelFn = DEFAULT_FULL_CLUSTER_CAPACITY_LABEL_FN, ...rest } = props; const [customMaxNumTasksDialogOpen, setCustomMaxNumTasksDialogOpen] = useState(false); - const maxNumTasks = getMaxNumTasks(queryContext); - const taskAssigment = getTaskAssigment(queryContext); + const maxNumTasks = queryContext.maxNumTasks; + const taskAssigment = getQueryContextKey('taskAssignment', queryContext, defaultQueryContext); const fullClusterCapacity = - typeof clusterCapacity === 'number' - ? `${formatInteger(clusterCapacity)} (full cluster capacity)` - : undefined; + typeof clusterCapacity === 'number' ? fullClusterCapacityLabelFn(clusterCapacity) : undefined; const shownMaxNumTaskOptions = clusterCapacity ? MAX_NUM_TASK_OPTIONS.filter(_ => _ <= clusterCapacity) @@ -88,11 +89,11 @@ export const MaxTasksButton = function MaxTasksButton(props: MaxTasksButtonProps changeQueryContext(changeMaxNumTasks(queryContext, undefined))} + onClick={() => changeQueryContext(deleteKeys(queryContext, ['maxNumTasks']))} /> )} {shownMaxNumTaskOptions.map(m => { - const { text, label } = maxNumLabelFn(m); + const { text, label } = maxTasksLabelFn(m); return ( changeQueryContext(changeMaxNumTasks(queryContext, m))} + onClick={() => changeQueryContext({ ...queryContext, maxNumTasks: m })} /> ); })} @@ -124,7 +125,7 @@ export const MaxTasksButton = function MaxTasksButton(props: MaxTasksButtonProps } shouldDismissPopover={false} multiline - onClick={() => changeQueryContext(changeTaskAssigment(queryContext, t))} + onClick={() => changeQueryContext({ ...queryContext, taskAssignment: t })} /> ))} @@ -158,8 +159,8 @@ export const MaxTasksButton = function MaxTasksButton(props: MaxTasksButtonProps minValue={2} integer initValue={maxNumTasks || 2} - onSubmit={p => { - changeQueryContext(changeMaxNumTasks(queryContext, p)); + onSubmit={maxNumTasks => { + changeQueryContext({ ...queryContext, maxNumTasks }); }} onClose={() => setCustomMaxNumTasksDialogOpen(false)} /> 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 cf863a14387b..acdfa67fad20 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 @@ -21,14 +21,14 @@ import { IconNames } from '@blueprintjs/icons'; import type { QueryResult } from '@druid-toolkit/query'; import { QueryRunner, SqlQuery } from '@druid-toolkit/query'; import axios from 'axios'; -import type { ComponentProps, JSX } from 'react'; +import type { JSX } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import SplitterLayout from 'react-splitter-layout'; import { useStore } from 'zustand'; import { Loader, QueryErrorPane } from '../../../components'; import type { CapacityInfo, DruidEngine, LastExecution, QueryContext } from '../../../druid-models'; -import { Execution, WorkbenchQuery } from '../../../druid-models'; +import { DEFAULT_SERVER_QUERY_CONTEXT, Execution, WorkbenchQuery } from '../../../druid-models'; import { executionBackgroundStatusCheck, reattachTaskExecution, @@ -60,6 +60,7 @@ import { FlexibleQueryInput } from '../flexible-query-input/flexible-query-input import { IngestSuccessPane } from '../ingest-success-pane/ingest-success-pane'; import { metadataStateStore } from '../metadata-state-store'; import { ResultTablePane } from '../result-table-pane/result-table-pane'; +import type { RunPanelProps } from '../run-panel/run-panel'; import { RunPanel } from '../run-panel/run-panel'; import { workStateStore } from '../work-state-store'; @@ -69,10 +70,16 @@ const queryRunner = new QueryRunner({ inflateDateStrategy: 'none', }); -export interface QueryTabProps { +export interface QueryTabProps + extends Pick< + RunPanelProps, + 'maxTasksMenuHeader' | 'enginesLabelFn' | 'maxTasksLabelFn' | 'fullClusterCapacityLabelFn' + > { query: WorkbenchQuery; id: string; mandatoryQueryContext: QueryContext | undefined; + baseQueryContext: QueryContext | undefined; + serverQueryContext: QueryContext; columnMetadata: readonly ColumnMetadata[] | undefined; onQueryChange(newQuery: WorkbenchQuery): void; onQueryTab(newQuery: WorkbenchQuery, tabName?: string): void; @@ -82,9 +89,6 @@ export interface QueryTabProps { clusterCapacity: number | undefined; goToTask(taskId: string): void; getClusterCapacity: (() => Promise) | undefined; - maxTaskMenuHeader?: JSX.Element; - enginesLabelFn?: ComponentProps['enginesLabelFn']; - maxTaskLabelFn?: ComponentProps['maxTaskLabelFn']; } export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) { @@ -93,6 +97,8 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) { id, columnMetadata, mandatoryQueryContext, + baseQueryContext, + serverQueryContext = DEFAULT_SERVER_QUERY_CONTEXT, onQueryChange, onQueryTab, onDetails, @@ -101,9 +107,10 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) { clusterCapacity, goToTask, getClusterCapacity, - maxTaskMenuHeader, + maxTasksMenuHeader, enginesLabelFn, - maxTaskLabelFn, + maxTasksLabelFn, + fullClusterCapacityLabelFn, } = props; const [alertElement, setAlertElement] = useState(); @@ -196,6 +203,8 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) { case 'sql-msq-task': return await submitTaskQuery({ query, + context: mandatoryQueryContext, + baseQueryContext, prefixLines, cancelToken, preserveOnTermination: true, @@ -227,6 +236,7 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) { const resultPromise = queryRunner.runQuery({ query, extraQueryContext: mandatoryQueryContext, + defaultQueryContext: baseQueryContext, cancelToken: new axios.CancelToken(cancelFn => { nativeQueryCancelFnRef.current = cancelFn; }), @@ -404,10 +414,12 @@ export const QueryTab = React.memo(function QueryTab(props: QueryTabProps) { running={executionState.loading} queryEngines={queryEngines} clusterCapacity={clusterCapacity} + defaultQueryContext={{ ...serverQueryContext, ...baseQueryContext }} moreMenu={runMoreMenu} - maxTaskMenuHeader={maxTaskMenuHeader} + maxTasksMenuHeader={maxTasksMenuHeader} enginesLabelFn={enginesLabelFn} - maxTaskLabelFn={maxTaskLabelFn} + maxTasksLabelFn={maxTasksLabelFn} + fullClusterCapacityLabelFn={fullClusterCapacityLabelFn} /> {executionState.isLoading() && ( { }; }; -export interface RunPanelProps { +export interface RunPanelProps + extends Pick { query: WorkbenchQuery; onQueryChange(query: WorkbenchQuery): void; running: boolean; @@ -127,10 +107,10 @@ export interface RunPanelProps { onRun(preview: boolean): void | Promise; queryEngines: DruidEngine[]; clusterCapacity: number | undefined; + defaultQueryContext: QueryContext; moreMenu?: JSX.Element; - maxTaskMenuHeader?: JSX.Element; + maxTasksMenuHeader?: JSX.Element; enginesLabelFn?: (engine: DruidEngine | undefined) => { text: string; label?: string }; - maxTaskLabelFn?: ComponentProps['maxNumLabelFn']; } export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { @@ -143,9 +123,11 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { small, queryEngines, clusterCapacity, - maxTaskMenuHeader, - maxTaskLabelFn, + defaultQueryContext, + maxTasksMenuHeader, enginesLabelFn = DEFAULT_ENGINES_LABEL_FN, + maxTasksLabelFn, + fullClusterCapacityLabelFn, } = props; const [editContextDialogOpen, setEditContextDialogOpen] = useState(false); const [editParametersDialogOpen, setEditParametersDialogOpen] = useState(false); @@ -158,20 +140,52 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { const numContextKeys = Object.keys(queryContext).length; const queryParameters = query.queryParameters; - const arrayIngestMode = getArrayIngestMode(queryContext); - const maxParseExceptions = getMaxParseExceptions(queryContext); - const failOnEmptyInsert = getFailOnEmptyInsert(queryContext); - const finalizeAggregations = getFinalizeAggregations(queryContext); - const waitUntilSegmentsLoad = getWaitUntilSegmentsLoad(queryContext); - const groupByEnableMultiValueUnnesting = getGroupByEnableMultiValueUnnesting(queryContext); - const sqlJoinAlgorithm = queryContext.sqlJoinAlgorithm ?? 'broadcast'; - const selectDestination = queryContext.selectDestination ?? 'taskReport'; - const durableShuffleStorage = getDurableShuffleStorage(queryContext); + // Extract the context parts that have UI + const sqlTimeZone = queryContext.sqlTimeZone; + + const useCache = getQueryContextKey('useCache', queryContext, defaultQueryContext); + const useApproximateTopN = getQueryContextKey( + 'useApproximateTopN', + queryContext, + defaultQueryContext, + ); + const useApproximateCountDistinct = getQueryContextKey( + 'useApproximateCountDistinct', + queryContext, + defaultQueryContext, + ); + + const arrayIngestMode = queryContext.arrayIngestMode; + const maxParseExceptions = getQueryContextKey( + 'maxParseExceptions', + queryContext, + defaultQueryContext, + ); + const failOnEmptyInsert = getQueryContextKey( + 'failOnEmptyInsert', + queryContext, + defaultQueryContext, + ); + const finalizeAggregations = queryContext.finalizeAggregations; + const waitUntilSegmentsLoad = queryContext.waitUntilSegmentsLoad; + const groupByEnableMultiValueUnnesting = queryContext.groupByEnableMultiValueUnnesting; + const sqlJoinAlgorithm = getQueryContextKey( + 'sqlJoinAlgorithm', + queryContext, + defaultQueryContext, + ); + const selectDestination = getQueryContextKey( + 'selectDestination', + queryContext, + defaultQueryContext, + ); + const durableShuffleStorage = getQueryContextKey( + 'durableShuffleStorage', + queryContext, + defaultQueryContext, + ); + const indexSpec: IndexSpec | undefined = deepGet(queryContext, 'indexSpec'); - const useApproximateCountDistinct = getUseApproximateCountDistinct(queryContext); - const useApproximateTopN = getUseApproximateTopN(queryContext); - const useCache = getUseCache(queryContext); - const timezone = getTimezone(queryContext); const handleRun = useCallback(() => { if (!onRun) return; @@ -210,7 +224,7 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { const queryEngine = query.engine; function changeQueryContext(queryContext: QueryContext) { - onQueryChange(query.changeQueryContext(queryContext)); + onQueryChange(query.changeQueryContext(removeUndefinedValues(queryContext))); } function offsetOptions(): JSX.Element[] { @@ -221,10 +235,10 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { items.push( changeQueryContext(changeTimezone(queryContext, offset))} + onClick={() => changeQueryContext({ ...queryContext, sqlTimeZone: offset })} />, ); } @@ -315,29 +329,32 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { changeQueryContext(changeTimezone(queryContext, undefined))} + onClick={() => + changeQueryContext({ ...queryContext, sqlTimeZone: undefined }) + } /> - + {NAMED_TIMEZONES.map(namedTimezone => ( - changeQueryContext(changeTimezone(queryContext, namedTimezone)) + changeQueryContext({ ...queryContext, sqlTimeZone: namedTimezone }) } /> ))} - + {offsetOptions()} - changeQueryContext(changeMaxParseExceptions(queryContext, v)) + changeQueryContext({ ...queryContext, maxParseExceptions: v }) } shouldDismissPopover={false} /> @@ -371,8 +388,8 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { text="Fail on empty insert" value={failOnEmptyInsert} undefinedEffectiveValue={false} - onValueChange={v => - changeQueryContext(changeFailOnEmptyInsert(queryContext, v)) + onValueChange={failOnEmptyInsert => + changeQueryContext({ ...queryContext, failOnEmptyInsert }) } /> - changeQueryContext(changeFinalizeAggregations(queryContext, v)) + onValueChange={finalizeAggregations => + changeQueryContext({ ...queryContext, finalizeAggregations }) } /> - changeQueryContext(changeWaitUntilSegmentsLoad(queryContext, v)) + onValueChange={waitUntilSegmentsLoad => + changeQueryContext({ ...queryContext, waitUntilSegmentsLoad }) } /> - changeQueryContext(changeGroupByEnableMultiValueUnnesting(queryContext, v)) + onValueChange={groupByEnableMultiValueUnnesting => + changeQueryContext({ ...queryContext, groupByEnableMultiValueUnnesting }) } /> - {['broadcast', 'sortMerge'].map(o => ( + {(['broadcast', 'sortMerge'] as SqlJoinAlgorithm[]).map(o => ( - changeQueryContext(deepSet(queryContext, 'sqlJoinAlgorithm', o)) + changeQueryContext({ ...queryContext, sqlJoinAlgorithm: o }) } /> ))} @@ -425,14 +442,14 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { label={selectDestination} intent={intent} > - {['taskReport', 'durableStorage'].map(o => ( + {(['taskReport', 'durableStorage'] as SelectDestination[]).map(o => ( - changeQueryContext(deepSet(queryContext, 'selectDestination', o)) + changeQueryContext({ ...queryContext, selectDestination: o }) } /> ))} @@ -454,9 +471,10 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { checked={durableShuffleStorage} text="Durable shuffle storage" onChange={() => - changeQueryContext( - changeDurableShuffleStorage(queryContext, !durableShuffleStorage), - ) + changeQueryContext({ + ...queryContext, + durableShuffleStorage: !durableShuffleStorage, + }) } /> changeQueryContext(changeUseCache(queryContext, !useCache))} + onChange={() => + changeQueryContext({ + ...queryContext, + useCache: !useCache, + populateCache: !useCache, + }) + } /> - changeQueryContext( - changeUseApproximateTopN(queryContext, !useApproximateTopN), - ) + changeQueryContext({ + ...queryContext, + useApproximateTopN: !useApproximateTopN, + }) } /> @@ -492,12 +517,10 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { checked={useApproximateCountDistinct} text="Use approximate COUNT(DISTINCT)" onChange={() => - changeQueryContext( - changeUseApproximateCountDistinct( - queryContext, - !useApproximateCountDistinct, - ), - ) + changeQueryContext({ + ...queryContext, + useApproximateCountDistinct: !useApproximateCountDistinct, + }) } /> )} @@ -519,8 +542,9 @@ export const RunPanel = React.memo(function RunPanel(props: RunPanelProps) { >