/** * 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 { AdhocColumn, BuildQuery, PostProcessingRule, QueryFormOrderBy, QueryMode, QueryObject, buildQueryContext, ensureIsArray, getMetricLabel, isPhysicalColumn, removeDuplicates, } from '@superset-ui/core'; import { isTimeComparison, timeCompareOperator, } from '@superset-ui/chart-controls'; import { isEmpty } from 'lodash'; import { TableChartFormData } from './types'; import { updateTableOwnState } from './DataTable/utils/externalAPIs'; /** * Infer query mode from form data. If `all_columns` is set, then raw records mode, * otherwise defaults to aggregation mode. * * The same logic is used in `controlPanel` with control values as well. */ export function getQueryMode(formData: TableChartFormData) { const { query_mode: mode } = formData; if (mode === QueryMode.Aggregate || mode === QueryMode.Raw) { return mode; } const rawColumns = formData?.all_columns; const hasRawColumns = rawColumns && rawColumns.length > 0; return hasRawColumns ? QueryMode.Raw : QueryMode.Aggregate; } const buildQuery: BuildQuery = ( formData: TableChartFormData, options, ) => { const { percent_metrics: percentMetrics, order_desc: orderDesc = false, extra_form_data, } = formData; const queryMode = getQueryMode(formData); const sortByMetric = ensureIsArray(formData.timeseries_limit_metric)[0]; const time_grain_sqla = extra_form_data?.time_grain_sqla || formData.time_grain_sqla; let formDataCopy = formData; // never include time in raw records mode if (queryMode === QueryMode.Raw) { formDataCopy = { ...formData, include_time: false, }; } const addComparisonPercentMetrics = (metrics: string[], suffixes: string[]) => metrics.reduce((acc, metric) => { const newMetrics = suffixes.map(suffix => `${metric}__${suffix}`); return acc.concat([metric, ...newMetrics]); }, []); return buildQueryContext(formDataCopy, baseQueryObject => { let { metrics, orderby = [], columns = [] } = baseQueryObject; const { extras = {} } = baseQueryObject; const postProcessing: PostProcessingRule[] = []; const nonCustomNorInheritShifts = ensureIsArray( formData.time_compare, ).filter((shift: string) => shift !== 'custom' && shift !== 'inherit'); const customOrInheritShifts = ensureIsArray(formData.time_compare).filter( (shift: string) => shift === 'custom' || shift === 'inherit', ); let timeOffsets: string[] = []; // Shifts for non-custom or non inherit time comparison if ( isTimeComparison(formData, baseQueryObject) && !isEmpty(nonCustomNorInheritShifts) ) { timeOffsets = nonCustomNorInheritShifts; } // Shifts for custom or inherit time comparison if ( isTimeComparison(formData, baseQueryObject) && !isEmpty(customOrInheritShifts) ) { if (customOrInheritShifts.includes('custom')) { timeOffsets = timeOffsets.concat([formData.start_date_offset]); } if (customOrInheritShifts.includes('inherit')) { timeOffsets = timeOffsets.concat(['inherit']); } } if ( extra_form_data?.time_compare && !timeOffsets.includes(extra_form_data.time_compare) ) { timeOffsets = [extra_form_data.time_compare]; } let temporalColumnAdded = false; let temporalColumn = null; if (queryMode === QueryMode.Aggregate) { metrics = metrics || []; // override orderby with timeseries metric when in aggregation mode if (sortByMetric) { orderby = [[sortByMetric, !orderDesc]]; } else if (metrics?.length > 0) { // default to ordering by first metric in descending order // when no "sort by" metric is set (regardless if "SORT DESC" is set to true) orderby = [[metrics[0], false]]; } // add postprocessing for percent metrics only when in aggregation mode type PercentMetricCalculationMode = 'row_limit' | 'all_records'; const calculationMode: PercentMetricCalculationMode = (formData.percent_metric_calculation as PercentMetricCalculationMode) || 'row_limit'; if (percentMetrics && percentMetrics.length > 0) { const percentMetricsLabelsWithTimeComparison = isTimeComparison( formData, baseQueryObject, ) ? addComparisonPercentMetrics( percentMetrics.map(getMetricLabel), timeOffsets, ) : percentMetrics.map(getMetricLabel); const percentMetricLabels = removeDuplicates( percentMetricsLabelsWithTimeComparison, ); metrics = removeDuplicates( metrics.concat(percentMetrics), getMetricLabel, ); if (calculationMode === 'all_records') { postProcessing.push({ operation: 'contribution', options: { columns: percentMetricLabels, rename_columns: percentMetricLabels.map(m => `%${m}`), }, }); } else { postProcessing.push({ operation: 'contribution', options: { columns: percentMetricLabels, rename_columns: percentMetricLabels.map(m => `%${m}`), }, }); } } // Add the operator for the time comparison if some is selected if (!isEmpty(timeOffsets)) { postProcessing.push(timeCompareOperator(formData, baseQueryObject)); } const temporalColumnsLookup = formData?.temporal_columns_lookup; // Filter out the column if needed and prepare the temporal column object columns = columns.filter(col => { const shouldBeAdded = isPhysicalColumn(col) && time_grain_sqla && temporalColumnsLookup?.[col]; if (shouldBeAdded && !temporalColumnAdded) { temporalColumn = { timeGrain: time_grain_sqla, columnType: 'BASE_AXIS', sqlExpression: col, label: col, expressionType: 'SQL', } as AdhocColumn; temporalColumnAdded = true; return false; // Do not include this in the output; it's added separately } return true; }); // So we ensure the temporal column is added first if (temporalColumn) { columns = [temporalColumn, ...columns]; } } const moreProps: Partial = {}; const ownState = options?.ownState ?? {}; // Build Query flag to check if its for either download as csv, excel or json const isDownloadQuery = ['csv', 'xlsx'].includes(formData?.result_format || '') || (formData?.result_format === 'json' && formData?.result_type === 'results'); if (isDownloadQuery) { moreProps.row_limit = Number(formDataCopy.row_limit) || 0; moreProps.row_offset = 0; } if (!isDownloadQuery && formDataCopy.server_pagination) { const pageSize = ownState.pageSize ?? formDataCopy.server_page_length; const currentPage = ownState.currentPage ?? 0; moreProps.row_limit = pageSize; moreProps.row_offset = currentPage * pageSize; } // getting sort by in case of server pagination from own state let sortByFromOwnState: QueryFormOrderBy[] | undefined; if (Array.isArray(ownState?.sortBy) && ownState?.sortBy.length > 0) { const sortByItem = ownState?.sortBy[0]; sortByFromOwnState = [[sortByItem?.key, !sortByItem?.desc]]; } let queryObject = { ...baseQueryObject, columns, extras, orderby: formData.server_pagination && sortByFromOwnState ? sortByFromOwnState : orderby, metrics, post_processing: postProcessing, time_offsets: timeOffsets, ...moreProps, }; if ( formData.server_pagination && options?.extras?.cachedChanges?.[formData.slice_id] && JSON.stringify(options?.extras?.cachedChanges?.[formData.slice_id]) !== JSON.stringify(queryObject.filters) ) { queryObject = { ...queryObject, row_offset: 0 }; const modifiedOwnState = { ...options?.ownState, currentPage: 0, pageSize: queryObject.row_limit ?? 0, }; updateTableOwnState(options?.hooks?.setDataMask, modifiedOwnState); } if (formData.server_pagination) { // Add search filter if search text exists if (ownState.searchText && ownState?.searchColumn) { queryObject = { ...queryObject, filters: [ ...(queryObject.filters || []), { col: ownState?.searchColumn, op: 'ILIKE', val: `${ownState.searchText}%`, }, ], }; } } // Because we use same buildQuery for all table on the page we need split them by id options?.hooks?.setCachedChanges({ [formData.slice_id]: queryObject.filters, }); const extraQueries: QueryObject[] = []; const calculationMode = formData.percent_metric_calculation || 'row_limit'; if ( calculationMode === 'all_records' && percentMetrics && percentMetrics.length > 0 ) { extraQueries.push({ ...queryObject, columns: [], metrics: percentMetrics, post_processing: [], row_limit: 0, row_offset: 0, orderby: [], is_timeseries: false, }); } if ( metrics?.length && formData.show_totals && queryMode === QueryMode.Aggregate ) { extraQueries.push({ ...queryObject, columns: [], row_limit: 0, row_offset: 0, post_processing: [], order_desc: undefined, orderby: undefined, }); } const interactiveGroupBy = formData.extra_form_data?.interactive_groupby; if (interactiveGroupBy && queryObject.columns) { queryObject.columns = [ ...new Set([...queryObject.columns, ...interactiveGroupBy]), ]; } // Now since row limit control is always visible even // in case of server pagination // we must use row limit from form data if (formData.server_pagination && !isDownloadQuery) { return [ { ...queryObject }, { ...queryObject, time_offsets: [], row_limit: Number(formData?.row_limit ?? 0), row_offset: 0, post_processing: [], is_rowcount: true, }, ...extraQueries, ]; } return [queryObject, ...extraQueries]; }); }; // Use this closure to cache changing of external filters, if we have server pagination we need reset page to 0, after // external filter changed export const cachedBuildQuery = (): BuildQuery => { let cachedChanges: any = {}; const setCachedChanges = (newChanges: any) => { cachedChanges = { ...cachedChanges, ...newChanges }; }; return (formData, options) => buildQuery( { ...formData }, { extras: { cachedChanges }, ownState: options?.ownState ?? {}, hooks: { ...options?.hooks, setDataMask: () => {}, setCachedChanges, }, }, ); }; export default cachedBuildQuery();