mirror of
https://github.com/apache/superset.git
synced 2026-04-12 12:47:53 +00:00
646 lines
21 KiB
TypeScript
646 lines
21 KiB
TypeScript
/**
|
|
* 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,
|
|
buildQueryContext,
|
|
ensureIsArray,
|
|
getColumnLabel,
|
|
getMetricLabel,
|
|
isDefined,
|
|
isPhysicalColumn,
|
|
QueryFormColumn,
|
|
QueryFormMetric,
|
|
QueryFormOrderBy,
|
|
QueryMode,
|
|
QueryObject,
|
|
QueryObjectExtras,
|
|
removeDuplicates,
|
|
PostProcessingRule,
|
|
BuildQuery,
|
|
} from '@superset-ui/core';
|
|
import {
|
|
isTimeComparison,
|
|
timeCompareOperator,
|
|
} from '@superset-ui/chart-controls';
|
|
import { isEmpty } from 'lodash';
|
|
import { TableChartFormData } from './types';
|
|
import { updateTableOwnState } from './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<TableChartFormData> = (
|
|
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<string[]>((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;
|
|
let 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']);
|
|
}
|
|
}
|
|
|
|
// Dashboard filter override - allows dashboard-level time shifts to OVERRIDE
|
|
// chart-level time shift settings (from PRs #33947 and #34014)
|
|
if (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
|
|
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,
|
|
);
|
|
postProcessing = [
|
|
{
|
|
operation: 'contribution',
|
|
options: {
|
|
columns: percentMetricLabels,
|
|
rename_columns: percentMetricLabels.map(x => `%${x}`),
|
|
},
|
|
},
|
|
];
|
|
}
|
|
// 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<QueryObject> = {};
|
|
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;
|
|
}
|
|
|
|
let sortByFromOwnState: QueryFormOrderBy[] | undefined;
|
|
|
|
const sortSource =
|
|
isDownloadQuery && ownState?.sortModel
|
|
? ownState.sortModel
|
|
: ownState?.sortBy;
|
|
|
|
if (Array.isArray(sortSource) && sortSource.length > 0) {
|
|
const mapColIdToIdentifier = (colId: string): string | undefined => {
|
|
const matchingColumn = columns.find((col: QueryFormColumn) => {
|
|
const colLabel = getColumnLabel(col);
|
|
return colLabel === colId;
|
|
});
|
|
|
|
if (matchingColumn) {
|
|
if (
|
|
typeof matchingColumn === 'object' &&
|
|
'sqlExpression' in matchingColumn
|
|
) {
|
|
return matchingColumn.sqlExpression;
|
|
}
|
|
return getColumnLabel(matchingColumn);
|
|
}
|
|
|
|
const matchingMetric = (metrics || []).find((met: QueryFormMetric) => {
|
|
const metLabel = getMetricLabel(met);
|
|
return metLabel === colId || `%${metLabel}` === colId;
|
|
});
|
|
|
|
if (matchingMetric) {
|
|
return getMetricLabel(matchingMetric);
|
|
}
|
|
|
|
return colId;
|
|
};
|
|
|
|
sortByFromOwnState = sortSource
|
|
.map(
|
|
(sortItem: {
|
|
colId?: string | number;
|
|
key?: string | number;
|
|
sort?: string;
|
|
desc?: boolean;
|
|
}) => {
|
|
const colId = isDefined(sortItem?.colId)
|
|
? sortItem.colId
|
|
: sortItem?.key;
|
|
if (!isDefined(colId)) return null;
|
|
const sortKey = mapColIdToIdentifier(String(colId));
|
|
if (!sortKey) return null;
|
|
const isDesc = sortItem?.sort === 'desc' || sortItem?.desc;
|
|
return [sortKey, !isDesc] as QueryFormOrderBy;
|
|
},
|
|
)
|
|
.filter((item): item is QueryFormOrderBy => item !== null);
|
|
|
|
// Add secondary sort for stable ordering (matches AG Grid's stable sort behavior)
|
|
if (sortByFromOwnState.length === 1 && isDownloadQuery && orderby) {
|
|
const primarySort = sortByFromOwnState[0][0];
|
|
orderby.forEach(orderItem => {
|
|
if (orderItem[0] !== primarySort) {
|
|
sortByFromOwnState!.push(orderItem);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Note: In Superset, "columns" are dimensions and "metrics" are measures,
|
|
// but AG Grid treats them all as "columns" in the UI
|
|
let orderedColumns = columns;
|
|
let orderedMetrics = metrics;
|
|
|
|
if (
|
|
isDownloadQuery &&
|
|
ownState.columnOrder &&
|
|
Array.isArray(ownState.columnOrder)
|
|
) {
|
|
type ColumnOrMetric = QueryFormColumn | QueryFormMetric;
|
|
|
|
const matchesColId = (item: ColumnOrMetric, colId: string): boolean => {
|
|
if (typeof item === 'string') {
|
|
return item === colId;
|
|
}
|
|
|
|
// Check AdhocColumn properties
|
|
if ('sqlExpression' in item || 'columnName' in item) {
|
|
return (
|
|
(item as AdhocColumn).sqlExpression === colId ||
|
|
item.label === colId
|
|
);
|
|
}
|
|
|
|
// Check metric properties
|
|
return getMetricLabel(item) === colId || item.label === colId;
|
|
};
|
|
|
|
const reorderByColumnOrder = (
|
|
items: ColumnOrMetric[],
|
|
): ColumnOrMetric[] => {
|
|
const ordered: ColumnOrMetric[] = [];
|
|
const remaining = new Set(items);
|
|
|
|
ownState.columnOrder.forEach((colId: string) => {
|
|
const match = items.find(
|
|
item => remaining.has(item) && matchesColId(item, colId),
|
|
);
|
|
if (match) {
|
|
ordered.push(match);
|
|
remaining.delete(match);
|
|
}
|
|
});
|
|
|
|
remaining.forEach(item => ordered.push(item));
|
|
return ordered;
|
|
};
|
|
|
|
orderedColumns = reorderByColumnOrder(columns) as typeof columns;
|
|
orderedMetrics = metrics
|
|
? (reorderByColumnOrder(metrics) as typeof metrics)
|
|
: metrics;
|
|
}
|
|
|
|
let queryObject = {
|
|
...baseQueryObject,
|
|
columns: orderedColumns,
|
|
extras: {
|
|
...extras,
|
|
// Pass column order to enable mixed column+metric ordering
|
|
...(isDownloadQuery &&
|
|
ownState.columnOrder &&
|
|
Array.isArray(ownState.columnOrder)
|
|
? { column_order: ownState.columnOrder }
|
|
: {}),
|
|
},
|
|
orderby:
|
|
(formData.server_pagination || isDownloadQuery) && sortByFromOwnState
|
|
? sortByFromOwnState
|
|
: orderby,
|
|
metrics: orderedMetrics,
|
|
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,
|
|
lastFilteredColumn: undefined,
|
|
lastFilteredInputPosition: undefined,
|
|
};
|
|
updateTableOwnState(options?.hooks?.setDataMask, modifiedOwnState);
|
|
}
|
|
// 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 interactiveGroupBy = formData.extra_form_data?.interactive_groupby;
|
|
if (interactiveGroupBy && queryObject.columns) {
|
|
queryObject.columns = [
|
|
...new Set([...queryObject.columns, ...interactiveGroupBy]),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Helper to determine if a column is a metric (needs HAVING) or dimension (needs WHERE)
|
|
*/
|
|
const isMetricColumn = (colId: string): boolean => {
|
|
const metricLabels = new Set(
|
|
(metrics || []).map(m =>
|
|
typeof m === 'string' ? m : getMetricLabel(m),
|
|
),
|
|
);
|
|
return metricLabels.has(colId) || colId.startsWith('%');
|
|
};
|
|
|
|
/**
|
|
* Helper to classify SQL clauses into WHERE (for dimensions) and HAVING (for metrics)
|
|
*/
|
|
const classifySQLClauses = (
|
|
sqlClauses: Record<string, string>,
|
|
): { whereClause?: string; havingClause?: string } => {
|
|
const whereClauses: string[] = [];
|
|
const havingClauses: string[] = [];
|
|
|
|
Object.entries(sqlClauses).forEach(([colId, sqlClause]) => {
|
|
if (isMetricColumn(colId)) {
|
|
havingClauses.push(sqlClause);
|
|
} else {
|
|
whereClauses.push(sqlClause);
|
|
}
|
|
});
|
|
|
|
return {
|
|
whereClause:
|
|
whereClauses.length > 0 ? whereClauses.join(' AND ') : undefined,
|
|
havingClause:
|
|
havingClauses.length > 0 ? havingClauses.join(' AND ') : undefined,
|
|
};
|
|
};
|
|
|
|
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}%`,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
// Add AG Grid column filters from ownState (non-metric filters only)
|
|
if (
|
|
ownState.agGridSimpleFilters &&
|
|
ownState.agGridSimpleFilters.length > 0
|
|
) {
|
|
// Get columns that have AG Grid filters
|
|
const agGridFilterColumns = new Set(
|
|
ownState.agGridSimpleFilters.map(
|
|
(filter: { col: string }) => filter.col,
|
|
),
|
|
);
|
|
|
|
// Remove existing TEMPORAL_RANGE filters for columns that have new AG Grid filters
|
|
// This prevents duplicate filters like "No filter" and actual date ranges
|
|
const existingFilters = (queryObject.filters || []).filter(filter => {
|
|
// Keep filter if it doesn't have the expected structure
|
|
if (!filter || typeof filter !== 'object' || !filter.col) {
|
|
return true;
|
|
}
|
|
// Keep filter if it's not a temporal range filter
|
|
if (filter.op !== 'TEMPORAL_RANGE') {
|
|
return true;
|
|
}
|
|
// Remove if this column has an AG Grid filter
|
|
return !agGridFilterColumns.has(filter.col);
|
|
});
|
|
|
|
queryObject = {
|
|
...queryObject,
|
|
filters: [...existingFilters, ...ownState.agGridSimpleFilters],
|
|
};
|
|
}
|
|
|
|
// Add AG Grid complex WHERE clause from ownState (non-metric filters)
|
|
if (ownState.agGridComplexWhere && ownState.agGridComplexWhere.trim()) {
|
|
const existingWhere = queryObject.extras?.where;
|
|
const combinedWhere = existingWhere
|
|
? `${existingWhere} AND ${ownState.agGridComplexWhere}`
|
|
: ownState.agGridComplexWhere;
|
|
|
|
queryObject = {
|
|
...queryObject,
|
|
extras: {
|
|
...queryObject.extras,
|
|
where: combinedWhere,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Add AG Grid HAVING clause from ownState (metric filters only)
|
|
if (ownState.agGridHavingClause && ownState.agGridHavingClause.trim()) {
|
|
const existingHaving = queryObject.extras?.having;
|
|
const combinedHaving = existingHaving
|
|
? `${existingHaving} AND ${ownState.agGridHavingClause}`
|
|
: ownState.agGridHavingClause;
|
|
|
|
queryObject = {
|
|
...queryObject,
|
|
extras: {
|
|
...queryObject.extras,
|
|
having: combinedHaving,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
if (isDownloadQuery) {
|
|
// Apply any QueryFilterClause filters from ownState (e.g., server pagination search)
|
|
if (ownState.filters?.length) {
|
|
queryObject.filters = [
|
|
...(queryObject.filters || []),
|
|
...ownState.filters,
|
|
];
|
|
}
|
|
|
|
// Apply AG Grid filters as SQL WHERE/HAVING clauses
|
|
if (ownState.sqlClauses) {
|
|
const { whereClause, havingClause } = classifySQLClauses(
|
|
ownState.sqlClauses as Record<string, string>,
|
|
);
|
|
|
|
if (whereClause || havingClause) {
|
|
queryObject.extras = {
|
|
...queryObject.extras,
|
|
transpile_to_dialect: true,
|
|
...(whereClause && {
|
|
where: queryObject.extras?.where
|
|
? `${queryObject.extras.where} AND ${whereClause}`
|
|
: whereClause,
|
|
}),
|
|
...(havingClause && {
|
|
having: queryObject.extras?.having
|
|
? `${queryObject.extras.having} AND ${havingClause}`
|
|
: havingClause,
|
|
}),
|
|
} as QueryObjectExtras;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create totals query AFTER all filters (including AG Grid filters) are applied
|
|
// This ensures we can properly exclude AG Grid WHERE filters from the totals
|
|
if (
|
|
metrics?.length &&
|
|
formData.show_totals &&
|
|
queryMode === QueryMode.Aggregate
|
|
) {
|
|
// Create a copy of extras without the AG Grid WHERE clause
|
|
// AG Grid filters in extras.where can reference calculated columns
|
|
// which aren't available in the totals subquery
|
|
const totalsExtras = { ...queryObject.extras };
|
|
if (ownState.agGridComplexWhere) {
|
|
// Remove AG Grid WHERE clause from totals query
|
|
const whereClause = totalsExtras.where;
|
|
if (whereClause) {
|
|
// Remove the AG Grid filter part from the WHERE clause using string methods
|
|
const agGridWhere = ownState.agGridComplexWhere;
|
|
let newWhereClause = whereClause;
|
|
|
|
// Try to remove with " AND " before
|
|
newWhereClause = newWhereClause.replace(` AND ${agGridWhere}`, '');
|
|
// Try to remove with " AND " after
|
|
newWhereClause = newWhereClause.replace(`${agGridWhere} AND `, '');
|
|
// If it's the only clause, remove it entirely
|
|
if (newWhereClause === agGridWhere) {
|
|
newWhereClause = '';
|
|
}
|
|
|
|
if (newWhereClause.trim()) {
|
|
totalsExtras.where = newWhereClause;
|
|
} else {
|
|
delete totalsExtras.where;
|
|
}
|
|
}
|
|
}
|
|
|
|
extraQueries.push({
|
|
...queryObject,
|
|
columns: [],
|
|
extras: totalsExtras, // Use extras with AG Grid WHERE removed
|
|
row_limit: 0,
|
|
row_offset: 0,
|
|
post_processing: [],
|
|
order_desc: undefined, // we don't need orderby stuff here,
|
|
orderby: undefined, // because this query will be used for get total aggregation.
|
|
});
|
|
}
|
|
|
|
// 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<TableChartFormData> => {
|
|
let cachedChanges: Record<string, unknown> = {};
|
|
const setCachedChanges = (newChanges: Record<string, unknown>) => {
|
|
cachedChanges = { ...cachedChanges, ...newChanges };
|
|
};
|
|
|
|
return (formData, options) =>
|
|
buildQuery(
|
|
{ ...formData },
|
|
{
|
|
extras: { cachedChanges },
|
|
ownState: options?.ownState ?? {},
|
|
hooks: {
|
|
...options?.hooks,
|
|
setDataMask: () => {},
|
|
setCachedChanges,
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
export default cachedBuildQuery();
|