fix(table): omit dormant grains from semantic aggregate requests (#44455)
This commit is contained in:
@@ -44,6 +44,15 @@ assists people when migrating to a new version.
|
||||
loader rejects semantic bundles;
|
||||
use the chart, dashboard or assets importer instead.
|
||||
|
||||
### Semantic-view Table charts without a temporal axis
|
||||
|
||||
Semantic-view Table charts omit recognized dormant time grains from
|
||||
frontend-generated aggregate queries when no temporal axis is present. The saved
|
||||
grain and Time Grain control visibility are unchanged. Direct API payloads and
|
||||
saved chart-data GET requests that bypass frontend rebuilding retain strict
|
||||
validation; some old stored query contexts can therefore still fail. Deploy
|
||||
updated frontend assets with this change.
|
||||
|
||||
### SQLite time filters on `DATE` columns
|
||||
|
||||
On SQLite, Shillelagh and the Superset meta database, a time filter on a `DATE`
|
||||
|
||||
@@ -24,6 +24,25 @@ The following information about each flight is given:
|
||||
- Information about the origin and destination.
|
||||
- The distance between the origin and destination, in kilometers (km).
|
||||
|
||||
### Semantic Table time grains
|
||||
|
||||
For a Table chart backed by a semantic view, Time Grain applies when grouping by
|
||||
a temporal dimension. Removing the last temporal dimension leaves the saved
|
||||
choice intact, but the frontend omits a recognized dormant grain from aggregate
|
||||
requests whose selected columns are known to be non-temporal (including no
|
||||
grouping columns). Adding the temporal dimension again reuses the saved choice.
|
||||
Unknown columns or grain values are not silently normalized. Ordinary dataset
|
||||
charts and raw-record queries are unchanged.
|
||||
|
||||
This applies to requests rebuilt by the updated frontend in Explore and
|
||||
dashboards; it does not rewrite saved charts. Direct API requests and saved
|
||||
chart-data GET paths that bypass rebuilding remain subject to strict validation,
|
||||
so old stored query contexts can still fail. Updated frontend assets are required.
|
||||
Dashboards reuse each chart's saved `temporal_columns_lookup` rather than
|
||||
recomputing it. A grouped chart saved before this change may lack the
|
||||
non-temporal column metadata needed to omit a dormant grain, so its dashboard
|
||||
requests can keep sending that grain until the chart is re-saved in Explore.
|
||||
|
||||
### Enabling Data Upload Functionality
|
||||
|
||||
You may need to enable the functionality to upload a CSV or Excel file to your database. The following section
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
QueryFormOrderBy,
|
||||
QueryMode,
|
||||
QueryObject,
|
||||
TimeGranularity,
|
||||
buildQueryContext,
|
||||
ensureIsArray,
|
||||
getMetricLabel,
|
||||
@@ -41,6 +42,36 @@ import { isEmpty } from 'lodash-es';
|
||||
import { TableChartFormData } from './types';
|
||||
import { updateTableOwnState } from './DataTable/utils/externalAPIs';
|
||||
|
||||
// Only recognized duration choices are safe to treat as dormant. Legacy date
|
||||
// formats and anchored week intervals are not semantic-layer durations.
|
||||
const DURATION_GRAINS = new Set<string>(
|
||||
Object.values(TimeGranularity).filter(
|
||||
grain => grain.startsWith('P') && !grain.includes('/'),
|
||||
),
|
||||
);
|
||||
|
||||
function omitDormantGrain(
|
||||
query: QueryObject,
|
||||
temporalColumns: Record<string, boolean> | undefined,
|
||||
): QueryObject {
|
||||
const grain = query.extras?.time_grain_sqla;
|
||||
if (
|
||||
typeof grain !== 'string' ||
|
||||
!DURATION_GRAINS.has(grain) ||
|
||||
query.granularity ||
|
||||
query.is_timeseries ||
|
||||
!Array.isArray(query.columns) ||
|
||||
!query.columns.every(
|
||||
column => isPhysicalColumn(column) && temporalColumns?.[column] === false,
|
||||
)
|
||||
) {
|
||||
return query;
|
||||
}
|
||||
const extras = { ...query.extras };
|
||||
delete extras.time_grain_sqla;
|
||||
return { ...query, extras };
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer query mode from form data. If `all_columns` is set, then raw records mode,
|
||||
* otherwise defaults to aggregation mode.
|
||||
@@ -85,7 +116,7 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
return acc.concat([metric, ...newMetrics]);
|
||||
}, []);
|
||||
|
||||
return buildQueryContext(formDataCopy, baseQueryObject => {
|
||||
const context = buildQueryContext(formDataCopy, baseQueryObject => {
|
||||
let { metrics, orderby = [], columns = [] } = baseQueryObject;
|
||||
const { extras = {} } = baseQueryObject;
|
||||
const postProcessing: PostProcessingRule[] = [];
|
||||
@@ -401,6 +432,19 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
|
||||
return [queryObject, ...extraQueries];
|
||||
});
|
||||
|
||||
// Normalize only rebuilt frontend requests, never saved form data. GET chart
|
||||
// data can bypass this builder, so old stored query contexts may still fail
|
||||
// strict host validation. Classify each final query, including derived ones.
|
||||
if (
|
||||
context.datasource.type === DatasourceType.SemanticView &&
|
||||
queryMode === QueryMode.Aggregate
|
||||
) {
|
||||
context.queries = context.queries.map(query =>
|
||||
omitDormantGrain(query, formData.temporal_columns_lookup),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Use this closure to cache changing of external filters, if we have server pagination we need reset page to 0, after
|
||||
|
||||
@@ -295,7 +295,26 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'temporal_columns_lookup',
|
||||
{
|
||||
name: 'temporal_columns_lookup',
|
||||
config: {
|
||||
...sharedControls.temporal_columns_lookup,
|
||||
// A missing entry is unknown, not evidence of a non-temporal
|
||||
// column when classifying a dormant semantic grain.
|
||||
initialValue: (
|
||||
control: ControlState,
|
||||
state: ControlPanelState | null,
|
||||
) =>
|
||||
Object.fromEntries(
|
||||
(state?.datasource?.columns ?? [])
|
||||
.filter(column => typeof column.is_dttm === 'boolean')
|
||||
.map(column => [
|
||||
column.column_name ?? column.name,
|
||||
column.is_dttm,
|
||||
]),
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
|
||||
@@ -32,6 +32,172 @@ const basicFormData: TableChartFormData = {
|
||||
datasource: '11__table',
|
||||
};
|
||||
|
||||
test('omits dormant grain only for known non-temporal selected columns', () => {
|
||||
const query = buildQueryUncached({
|
||||
...basicFormData,
|
||||
datasource: '2__semantic_view',
|
||||
metrics: ['orders'],
|
||||
groupby: ['country'],
|
||||
temporal_columns_lookup: { country: false },
|
||||
time_grain_sqla: TimeGranularity.DAY,
|
||||
}).queries[0];
|
||||
expect(query.columns).toEqual(['country']);
|
||||
expect(query.extras).not.toHaveProperty('time_grain_sqla');
|
||||
});
|
||||
|
||||
test.each<Partial<TableChartFormData>>([
|
||||
{ groupby: ['unknown'], temporal_columns_lookup: {} },
|
||||
{ groupby: ['unknown'] },
|
||||
{
|
||||
groupby: ['country', 'unknown'],
|
||||
temporal_columns_lookup: { country: false },
|
||||
},
|
||||
{
|
||||
groupby: [
|
||||
{ expressionType: 'SQL', sqlExpression: 'year(ds)', label: 'year' },
|
||||
],
|
||||
},
|
||||
{ granularity_sqla: 'metric_time' },
|
||||
{
|
||||
groupby: ['metric_time', 'other_time'],
|
||||
temporal_columns_lookup: { metric_time: true, other_time: true },
|
||||
},
|
||||
{
|
||||
extra_form_data: { interactive_groupby: ['metric_time'] },
|
||||
temporal_columns_lookup: { metric_time: true },
|
||||
},
|
||||
{ datasource: '11__table' },
|
||||
{ query_mode: QueryMode.Raw },
|
||||
])(
|
||||
'preserves grain for active, ambiguous or out-of-scope input %j',
|
||||
overrides => {
|
||||
const query = buildQueryUncached({
|
||||
...basicFormData,
|
||||
datasource: '2__semantic_view',
|
||||
metrics: ['orders'],
|
||||
groupby: [],
|
||||
time_grain_sqla: TimeGranularity.DAY,
|
||||
...overrides,
|
||||
}).queries[0];
|
||||
expect(query.extras?.time_grain_sqla).toBe(TimeGranularity.DAY);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
undefined,
|
||||
null,
|
||||
'',
|
||||
'invalid',
|
||||
'P2D',
|
||||
'date',
|
||||
'P1W/1970-01-04T00:00:00Z',
|
||||
])('does not normalize unrecognized or absent grain %s', grain => {
|
||||
const form = {
|
||||
...basicFormData,
|
||||
datasource: '2__semantic_view',
|
||||
metrics: ['orders'],
|
||||
groupby: [],
|
||||
time_grain_sqla: grain,
|
||||
} as unknown as TableChartFormData;
|
||||
expect(buildQueryUncached(form).queries[0].extras?.time_grain_sqla).toBe(
|
||||
grain,
|
||||
);
|
||||
});
|
||||
|
||||
test.each([TimeGranularity.DAY, TimeGranularity.MONTH, TimeGranularity.HOUR])(
|
||||
'omits dormant semantic grain %s without changing the saved form',
|
||||
grain => {
|
||||
const form: TableChartFormData = {
|
||||
...basicFormData,
|
||||
datasource: '2__semantic_view',
|
||||
metrics: ['count_lifetime_orders'],
|
||||
groupby: [],
|
||||
time_grain_sqla: grain,
|
||||
time_range: '2026-01-01 : 2026-02-01',
|
||||
row_limit: 123,
|
||||
};
|
||||
const original = structuredClone(form);
|
||||
Object.freeze(form.groupby);
|
||||
Object.freeze(form.metrics);
|
||||
Object.freeze(form);
|
||||
const result = buildQueryUncached(form);
|
||||
|
||||
expect(result.queries[0].extras).not.toHaveProperty('time_grain_sqla');
|
||||
expect(result.queries[0]).toMatchObject({
|
||||
columns: [],
|
||||
metrics: ['count_lifetime_orders'],
|
||||
time_range: form.time_range,
|
||||
row_limit: 123,
|
||||
});
|
||||
expect(form).toEqual(original);
|
||||
expect(result.form_data).toEqual(original);
|
||||
},
|
||||
);
|
||||
|
||||
test('normalizes final dormant override in all semantic derived queries', () => {
|
||||
const form: TableChartFormData = {
|
||||
...basicFormData,
|
||||
datasource: '2__semantic_view',
|
||||
groupby: [],
|
||||
metrics: ['orders'],
|
||||
percent_metrics: ['orders'],
|
||||
percent_metric_calculation: 'all_records',
|
||||
show_totals: true,
|
||||
server_pagination: true,
|
||||
server_page_length: 10,
|
||||
row_limit: 50,
|
||||
time_grain_sqla: TimeGranularity.DAY,
|
||||
extra_form_data: Object.freeze({ time_grain_sqla: TimeGranularity.MONTH }),
|
||||
};
|
||||
const original = structuredClone(form);
|
||||
Object.freeze(form);
|
||||
const { queries } = buildQueryUncached(form);
|
||||
|
||||
expect(queries).toHaveLength(4);
|
||||
queries.forEach(query => {
|
||||
expect(query.extras).not.toHaveProperty('time_grain_sqla');
|
||||
});
|
||||
expect(queries[1].is_rowcount).toBe(true);
|
||||
expect(form).toEqual(original);
|
||||
});
|
||||
|
||||
test('normalizes totals independently of the active semantic main axis', () => {
|
||||
const { queries } = buildQueryUncached({
|
||||
...basicFormData,
|
||||
datasource: '2__semantic_view',
|
||||
metrics: ['orders'],
|
||||
groupby: ['metric_time'],
|
||||
temporal_columns_lookup: { metric_time: true },
|
||||
time_grain_sqla: TimeGranularity.MONTH,
|
||||
show_totals: true,
|
||||
});
|
||||
|
||||
expect(queries[0].extras?.time_grain_sqla).toBe(TimeGranularity.MONTH);
|
||||
expect(queries[1].columns).toEqual([]);
|
||||
expect(queries[1].extras).not.toHaveProperty('time_grain_sqla');
|
||||
});
|
||||
|
||||
test('keeps the selected semantic grain through axis removal and re-addition', () => {
|
||||
const form: TableChartFormData = {
|
||||
...basicFormData,
|
||||
datasource: '2__semantic_view',
|
||||
metrics: ['orders'],
|
||||
groupby: ['metric_time'],
|
||||
temporal_columns_lookup: { metric_time: true },
|
||||
time_grain_sqla: TimeGranularity.MONTH,
|
||||
};
|
||||
expect(buildQueryUncached(form).queries[0].extras?.time_grain_sqla).toBe(
|
||||
TimeGranularity.MONTH,
|
||||
);
|
||||
expect(
|
||||
buildQueryUncached({ ...form, groupby: [] }).queries[0].extras,
|
||||
).not.toHaveProperty('time_grain_sqla');
|
||||
expect(buildQueryUncached(form).queries[0].extras?.time_grain_sqla).toBe(
|
||||
TimeGranularity.MONTH,
|
||||
);
|
||||
expect(form.time_grain_sqla).toBe(TimeGranularity.MONTH);
|
||||
});
|
||||
|
||||
const extraQueryFormData: TableChartFormData = {
|
||||
...basicFormData,
|
||||
time_grain_sqla: TimeGranularity.MONTH,
|
||||
|
||||
@@ -17,10 +17,46 @@ import {
|
||||
ControlPanelsContainerProps,
|
||||
ControlState,
|
||||
CustomControlItem,
|
||||
sharedControls,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { ComparisonType, QueryMode } from '@superset-ui/core';
|
||||
import config from '../src/controlPanel';
|
||||
|
||||
test('Table temporal metadata distinguishes non-temporal from unknown columns', () => {
|
||||
const item = config.controlPanelSections
|
||||
.flatMap(section => section?.controlSetRows ?? [])
|
||||
.flat()
|
||||
.find(control =>
|
||||
typeof control === 'string'
|
||||
? control === 'temporal_columns_lookup'
|
||||
: control &&
|
||||
'name' in control &&
|
||||
control.name === 'temporal_columns_lookup',
|
||||
);
|
||||
const control =
|
||||
typeof item === 'object' && item && 'config' in item
|
||||
? item.config
|
||||
: sharedControls.temporal_columns_lookup;
|
||||
const { initialValue } = control;
|
||||
expect(typeof initialValue).toBe('function');
|
||||
if (typeof initialValue !== 'function') {
|
||||
throw new Error('Expected temporal lookup initializer');
|
||||
}
|
||||
const state = {
|
||||
datasource: {
|
||||
columns: [
|
||||
{ column_name: 'metric_time', is_dttm: true },
|
||||
{ column_name: 'country', is_dttm: false },
|
||||
{ column_name: 'unknown' },
|
||||
],
|
||||
},
|
||||
} as unknown as Parameters<typeof initialValue>[1];
|
||||
expect(initialValue({} as ControlState, state)).toEqual({
|
||||
metric_time: true,
|
||||
country: false,
|
||||
});
|
||||
});
|
||||
|
||||
type VisibilityFn = (
|
||||
props: ControlPanelsContainerProps,
|
||||
control?: ControlState,
|
||||
@@ -229,6 +265,14 @@ test('time_grain_sqla visibility should be case-insensitive', () => {
|
||||
expect(vis(mkProps(['some_other_col']), controlState)).toBe(false);
|
||||
});
|
||||
|
||||
test('time grain visibility follows axis removal and re-addition', () => {
|
||||
const visible = getVisibility(config, 'time_grain_sqla');
|
||||
expect(visible(mkProps(['ORDERDATE']))).toBe(true);
|
||||
expect(visible(mkProps([]))).toBe(false);
|
||||
expect(visible(mkProps(['some_other_col']))).toBe(false);
|
||||
expect(visible(mkProps(['ORDERDATE']))).toBe(true);
|
||||
});
|
||||
|
||||
test('time_grain_sqla is hidden in raw records mode', () => {
|
||||
const vis = getVisibility(config, 'time_grain_sqla');
|
||||
const controlState = {} as ControlState;
|
||||
|
||||
@@ -18,7 +18,12 @@
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { act, fireEvent, render } from 'spec/helpers/testing-library';
|
||||
import { FeatureFlag, VizType } from '@superset-ui/core';
|
||||
import {
|
||||
DatasourceType,
|
||||
FeatureFlag,
|
||||
TimeGranularity,
|
||||
VizType,
|
||||
} from '@superset-ui/core';
|
||||
import * as redux from 'redux';
|
||||
|
||||
import * as exploreUtils from 'src/explore/exploreUtils';
|
||||
@@ -33,6 +38,12 @@ import {
|
||||
useAutoRefreshContext,
|
||||
} from 'src/dashboard/contexts/AutoRefreshContext';
|
||||
import Chart from './Chart';
|
||||
import {
|
||||
DashboardDatasetsContext,
|
||||
type DashboardDatasetsContextValue,
|
||||
} from 'src/dashboard/contexts/DashboardDatasetsContext';
|
||||
import { buildQuery as buildTableQuery } from '../../../../../plugins/plugin-chart-table/src/buildQuery';
|
||||
import type { TableChartFormData } from '../../../../../plugins/plugin-chart-table/src/types';
|
||||
|
||||
let capturedChartContainerProps: Record<string, unknown> = {};
|
||||
jest.mock('src/components/Chart/ChartContainer', () => {
|
||||
@@ -106,12 +117,23 @@ const defaultState = {
|
||||
function setup(
|
||||
overrideProps: Record<string, unknown> = {},
|
||||
overrideState: Record<string, unknown> = {},
|
||||
currentDatasets: DashboardDatasetsContextValue | null = null,
|
||||
) {
|
||||
return render(<Chart {...props} {...overrideProps} />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: { ...defaultState, ...overrideState },
|
||||
});
|
||||
const chart = <Chart {...props} {...overrideProps} />;
|
||||
return render(
|
||||
currentDatasets ? (
|
||||
<DashboardDatasetsContext.Provider value={currentDatasets}>
|
||||
{chart}
|
||||
</DashboardDatasetsContext.Provider>
|
||||
) : (
|
||||
chart
|
||||
),
|
||||
{
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: { ...defaultState, ...overrideState },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function StartAutoRefreshFor({ chartIds }: { chartIds: number[] }) {
|
||||
@@ -204,6 +226,260 @@ test('should render a ChartContainer', () => {
|
||||
expect(getByTestId('chart-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('uses current semantic dimensions instead of a saved stale temporal lookup', () => {
|
||||
const savedFormData = {
|
||||
...chartQueries[queryId].form_data,
|
||||
datasource: '2__semantic_view',
|
||||
viz_type: VizType.Table,
|
||||
metrics: ['orders'],
|
||||
groupby: ['event_time'],
|
||||
granularity: undefined,
|
||||
time_grain_sqla: TimeGranularity.DAY,
|
||||
temporal_columns_lookup: { event_time: false },
|
||||
};
|
||||
setup(
|
||||
{},
|
||||
{
|
||||
charts: {
|
||||
...defaultState.charts,
|
||||
[queryId]: {
|
||||
...defaultState.charts[queryId],
|
||||
form_data: savedFormData,
|
||||
},
|
||||
},
|
||||
datasources: {
|
||||
'2__semantic_view': {
|
||||
...mockDatasource['7__table'],
|
||||
type: DatasourceType.SemanticView,
|
||||
columns: [{ column_name: 'event_time', is_dttm: false }],
|
||||
},
|
||||
},
|
||||
dashboardState: {
|
||||
...defaultState.dashboardState,
|
||||
datasetsStatus: 'complete',
|
||||
},
|
||||
},
|
||||
{
|
||||
dashboardId: props.dashboardId,
|
||||
datasets: [
|
||||
{
|
||||
uid: '2__semantic_view',
|
||||
type: DatasourceType.SemanticView,
|
||||
columns: [{ column_name: 'event_time', is_dttm: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const formData = capturedChartContainerProps.formData as TableChartFormData;
|
||||
expect(formData.temporal_columns_lookup).toEqual({ event_time: true });
|
||||
expect(buildTableQuery(formData).queries[0].columns).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
label: 'event_time',
|
||||
timeGrain: TimeGranularity.DAY,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(buildTableQuery(formData).queries[0].extras?.time_grain_sqla).toBe(
|
||||
TimeGranularity.DAY,
|
||||
);
|
||||
expect(savedFormData.temporal_columns_lookup).toEqual({ event_time: false });
|
||||
});
|
||||
|
||||
test('keeps a saved semantic grain when current datasource metadata is unavailable', () => {
|
||||
const savedFormData = {
|
||||
...chartQueries[queryId].form_data,
|
||||
datasource: '2__semantic_view',
|
||||
viz_type: VizType.Table,
|
||||
metrics: ['orders'],
|
||||
groupby: ['event_time'],
|
||||
granularity: undefined,
|
||||
time_grain_sqla: TimeGranularity.DAY,
|
||||
temporal_columns_lookup: { event_time: false },
|
||||
};
|
||||
setup(
|
||||
{},
|
||||
{
|
||||
charts: {
|
||||
...defaultState.charts,
|
||||
[queryId]: {
|
||||
...defaultState.charts[queryId],
|
||||
form_data: savedFormData,
|
||||
},
|
||||
},
|
||||
datasources: {},
|
||||
},
|
||||
);
|
||||
|
||||
const formData = capturedChartContainerProps.formData as TableChartFormData;
|
||||
expect(formData.temporal_columns_lookup).toBeUndefined();
|
||||
expect(buildTableQuery(formData).queries[0].extras?.time_grain_sqla).toBe(
|
||||
TimeGranularity.DAY,
|
||||
);
|
||||
expect(savedFormData.temporal_columns_lookup).toEqual({ event_time: false });
|
||||
});
|
||||
|
||||
test('ignores prior dashboard metadata while current datasets are loading', () => {
|
||||
const savedFormData = {
|
||||
...chartQueries[queryId].form_data,
|
||||
datasource: '2__semantic_view',
|
||||
viz_type: VizType.Table,
|
||||
metrics: ['orders'],
|
||||
groupby: ['event_time'],
|
||||
granularity: undefined,
|
||||
time_grain_sqla: TimeGranularity.DAY,
|
||||
temporal_columns_lookup: { event_time: false },
|
||||
};
|
||||
setup(
|
||||
{},
|
||||
{
|
||||
charts: {
|
||||
...defaultState.charts,
|
||||
[queryId]: {
|
||||
...defaultState.charts[queryId],
|
||||
form_data: savedFormData,
|
||||
},
|
||||
},
|
||||
datasources: {
|
||||
'2__semantic_view': {
|
||||
...mockDatasource['7__table'],
|
||||
type: DatasourceType.SemanticView,
|
||||
columns: [{ column_name: 'event_time', is_dttm: false }],
|
||||
},
|
||||
},
|
||||
dashboardState: {
|
||||
...defaultState.dashboardState,
|
||||
datasetsStatus: 'loading',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const formData = capturedChartContainerProps.formData as TableChartFormData;
|
||||
expect(formData.temporal_columns_lookup).toBeUndefined();
|
||||
expect(buildTableQuery(formData).queries[0].extras?.time_grain_sqla).toBe(
|
||||
TimeGranularity.DAY,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores prior metadata when a completed response omits the semantic source', () => {
|
||||
const savedFormData = {
|
||||
...chartQueries[queryId].form_data,
|
||||
datasource: '2__semantic_view',
|
||||
viz_type: VizType.Table,
|
||||
metrics: ['orders'],
|
||||
groupby: ['event_time'],
|
||||
granularity: undefined,
|
||||
time_grain_sqla: TimeGranularity.DAY,
|
||||
temporal_columns_lookup: { event_time: false },
|
||||
};
|
||||
setup(
|
||||
{},
|
||||
{
|
||||
charts: {
|
||||
...defaultState.charts,
|
||||
[queryId]: {
|
||||
...defaultState.charts[queryId],
|
||||
form_data: savedFormData,
|
||||
},
|
||||
},
|
||||
datasources: {
|
||||
'2__semantic_view': {
|
||||
...mockDatasource['7__table'],
|
||||
type: DatasourceType.SemanticView,
|
||||
columns: [{ column_name: 'event_time', is_dttm: false }],
|
||||
},
|
||||
},
|
||||
dashboardState: {
|
||||
...defaultState.dashboardState,
|
||||
datasetsStatus: 'complete',
|
||||
},
|
||||
},
|
||||
{ dashboardId: props.dashboardId, datasets: [] },
|
||||
);
|
||||
|
||||
const formData = capturedChartContainerProps.formData as TableChartFormData;
|
||||
expect(formData.temporal_columns_lookup).toBeUndefined();
|
||||
expect(buildTableQuery(formData).queries[0].extras?.time_grain_sqla).toBe(
|
||||
TimeGranularity.DAY,
|
||||
);
|
||||
});
|
||||
|
||||
test('omits a dormant semantic grain when current metadata proves the axis non-temporal', () => {
|
||||
const savedFormData = {
|
||||
...chartQueries[queryId].form_data,
|
||||
datasource: '2__semantic_view',
|
||||
viz_type: VizType.Table,
|
||||
metrics: ['orders'],
|
||||
groupby: ['country'],
|
||||
granularity: undefined,
|
||||
time_grain_sqla: TimeGranularity.DAY,
|
||||
temporal_columns_lookup: { country: true },
|
||||
};
|
||||
setup(
|
||||
{},
|
||||
{
|
||||
charts: {
|
||||
...defaultState.charts,
|
||||
[queryId]: {
|
||||
...defaultState.charts[queryId],
|
||||
form_data: savedFormData,
|
||||
},
|
||||
},
|
||||
datasources: {
|
||||
'2__semantic_view': {
|
||||
...mockDatasource['7__table'],
|
||||
type: DatasourceType.SemanticView,
|
||||
columns: [{ column_name: 'country', is_dttm: false }],
|
||||
},
|
||||
},
|
||||
dashboardState: {
|
||||
...defaultState.dashboardState,
|
||||
datasetsStatus: 'complete',
|
||||
},
|
||||
},
|
||||
{
|
||||
dashboardId: props.dashboardId,
|
||||
datasets: [
|
||||
{
|
||||
uid: '2__semantic_view',
|
||||
type: DatasourceType.SemanticView,
|
||||
columns: [{ column_name: 'country', is_dttm: false }],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const formData = capturedChartContainerProps.formData as TableChartFormData;
|
||||
expect(formData.temporal_columns_lookup).toEqual({ country: false });
|
||||
expect(buildTableQuery(formData).queries[0].extras).not.toHaveProperty(
|
||||
'time_grain_sqla',
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves saved SQL dataset lookup unchanged on dashboards', () => {
|
||||
const savedFormData = {
|
||||
...chartQueries[queryId].form_data,
|
||||
datasource: '11__table',
|
||||
temporal_columns_lookup: { event_time: false },
|
||||
};
|
||||
setup(
|
||||
{},
|
||||
{
|
||||
charts: {
|
||||
...defaultState.charts,
|
||||
[queryId]: {
|
||||
...defaultState.charts[queryId],
|
||||
form_data: savedFormData,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const formData = capturedChartContainerProps.formData as TableChartFormData;
|
||||
expect(formData.temporal_columns_lookup).toEqual({ event_time: false });
|
||||
});
|
||||
|
||||
const noDescriptionRenderInputs = ([undefined, false, true] as const).flatMap(
|
||||
sliceExpanded =>
|
||||
([undefined, false, true] as const).map(allExpanded => ({
|
||||
|
||||
@@ -24,11 +24,12 @@ import {
|
||||
useRef,
|
||||
useMemo,
|
||||
useState,
|
||||
useContext,
|
||||
memo,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
import type { ChartCustomization, JsonObject } from '@superset-ui/core';
|
||||
import { VizType } from '@superset-ui/core';
|
||||
import { DatasourceType, VizType } from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { debounce } from 'lodash-es';
|
||||
@@ -83,6 +84,7 @@ import {
|
||||
import getFormDataWithExtraFilters from '../../../util/charts/getFormDataWithExtraFilters';
|
||||
import { useChartCustomizationFromRedux } from '../../nativeFilters/state';
|
||||
import { PLACEHOLDER_DATASOURCE } from '../../../constants';
|
||||
import { DashboardDatasetsContext } from '../../../contexts/DashboardDatasetsContext';
|
||||
|
||||
interface ChartProps {
|
||||
id: number;
|
||||
@@ -170,6 +172,7 @@ const createOwnStateWithChartState = (
|
||||
};
|
||||
|
||||
const Chart = (props: ChartProps) => {
|
||||
const currentDashboardDatasets = useContext(DashboardDatasetsContext);
|
||||
const dispatch = useDispatch();
|
||||
const descriptionRef = useRef<HTMLElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -469,47 +472,74 @@ const Chart = (props: ChartProps) => {
|
||||
),
|
||||
);
|
||||
|
||||
const formData = useMemo(
|
||||
() =>
|
||||
getFormDataWithExtraFilters({
|
||||
chart: { id: chart?.id ?? props.id, form_data: chart?.form_data }, // avoid passing the whole chart object
|
||||
chartConfiguration,
|
||||
chartCustomizationItems:
|
||||
chartCustomizationItems as ChartCustomization[],
|
||||
filters: getAppliedFilterValues(props.id),
|
||||
colorScheme,
|
||||
colorNamespace,
|
||||
sliceId: props.id,
|
||||
nativeFilters,
|
||||
allSliceIds,
|
||||
dataMask,
|
||||
extraControls: (props.extraControls || {}) as Record<
|
||||
string,
|
||||
string | boolean | null
|
||||
>,
|
||||
labelsColor,
|
||||
labelsColorMap,
|
||||
sharedLabelsColors,
|
||||
ownColorScheme,
|
||||
}),
|
||||
[
|
||||
chart?.id,
|
||||
chart?.form_data,
|
||||
const formData = useMemo(() => {
|
||||
const filteredFormData = getFormDataWithExtraFilters({
|
||||
chart: { id: chart?.id ?? props.id, form_data: chart?.form_data }, // avoid passing the whole chart object
|
||||
chartConfiguration,
|
||||
chartCustomizationItems,
|
||||
props.id,
|
||||
props.extraControls,
|
||||
chartCustomizationItems: chartCustomizationItems as ChartCustomization[],
|
||||
filters: getAppliedFilterValues(props.id),
|
||||
colorScheme,
|
||||
colorNamespace,
|
||||
sliceId: props.id,
|
||||
nativeFilters,
|
||||
allSliceIds,
|
||||
dataMask,
|
||||
extraControls: (props.extraControls || {}) as Record<
|
||||
string,
|
||||
string | boolean | null
|
||||
>,
|
||||
labelsColor,
|
||||
labelsColorMap,
|
||||
sharedLabelsColors,
|
||||
ownColorScheme,
|
||||
],
|
||||
);
|
||||
});
|
||||
if (
|
||||
chart?.form_data?.viz_type !== VizType.Table ||
|
||||
!chart.form_data.datasource?.endsWith(`__${DatasourceType.SemanticView}`)
|
||||
) {
|
||||
return filteredFormData;
|
||||
}
|
||||
|
||||
// Saved dashboard form data may predate the current semantic view schema.
|
||||
// Missing metadata is unknown, never proof that an axis is non-temporal.
|
||||
const currentDatasource =
|
||||
currentDashboardDatasets?.dashboardId === props.dashboardId
|
||||
? currentDashboardDatasets.datasets.find(
|
||||
candidate =>
|
||||
candidate.uid === chart.form_data.datasource &&
|
||||
candidate.type === DatasourceType.SemanticView,
|
||||
)
|
||||
: undefined;
|
||||
const temporalColumnsLookup = currentDatasource
|
||||
? Object.fromEntries(
|
||||
(currentDatasource.columns ?? [])
|
||||
.filter(column => typeof column.is_dttm === 'boolean')
|
||||
.map(column => [column.column_name, column.is_dttm]),
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
...filteredFormData,
|
||||
temporal_columns_lookup: temporalColumnsLookup,
|
||||
};
|
||||
}, [
|
||||
chart?.id,
|
||||
chart?.form_data,
|
||||
chartConfiguration,
|
||||
chartCustomizationItems,
|
||||
props.id,
|
||||
props.extraControls,
|
||||
colorScheme,
|
||||
colorNamespace,
|
||||
nativeFilters,
|
||||
allSliceIds,
|
||||
dataMask,
|
||||
labelsColor,
|
||||
labelsColorMap,
|
||||
sharedLabelsColors,
|
||||
ownColorScheme,
|
||||
currentDashboardDatasets,
|
||||
props.dashboardId,
|
||||
]);
|
||||
|
||||
(formData as JsonObject).dashboardId = dashboardInfo.id;
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
useDashboardCharts,
|
||||
useDashboardDatasets,
|
||||
} from 'src/hooks/apiResources';
|
||||
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
|
||||
import { hydrateDashboard } from 'src/dashboard/actions/hydrate';
|
||||
import { clearDashboardHistory } from 'src/dashboard/actions/dashboardLayout';
|
||||
import { setDatasources } from 'src/dashboard/actions/datasources';
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
import DashboardContainer from 'src/dashboard/containers/Dashboard';
|
||||
import CrudThemeProvider from 'src/components/CrudThemeProvider';
|
||||
import type { DashboardChartStates } from 'src/dashboard/types/chartState';
|
||||
import { DashboardDatasetsContext } from 'src/dashboard/contexts/DashboardDatasetsContext';
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { ActiveFilters } from '../types';
|
||||
@@ -152,6 +154,14 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
} = useDashboardDatasets(idOrSlug);
|
||||
const isDashboardHydrated = useRef(false);
|
||||
|
||||
const currentDashboardDatasets = useMemo(
|
||||
() =>
|
||||
status === ResourceStatus.Complete && datasets && dashboard?.id
|
||||
? { dashboardId: dashboard.id, datasets }
|
||||
: null,
|
||||
[status, datasets, dashboard?.id],
|
||||
);
|
||||
|
||||
const error = dashboardApiError || chartsApiError;
|
||||
// Only 404 gets a graceful not-found state; a 403 (access denied) still
|
||||
// surfaces through the error boundary.
|
||||
@@ -424,18 +434,20 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
<>
|
||||
<SyncDashboardState dashboardPageId={dashboardPageId} />
|
||||
<DashboardPageIdContext.Provider value={dashboardPageId}>
|
||||
<CrudThemeProvider
|
||||
theme={reduxTheme !== undefined ? reduxTheme : dashboard?.theme}
|
||||
>
|
||||
<AutoRefreshProvider>
|
||||
<DashboardContainer
|
||||
activeFilters={activeFilters as ActiveFilters}
|
||||
ownDataCharts={relevantDataMask}
|
||||
>
|
||||
{DashboardBuilderComponent}
|
||||
</DashboardContainer>
|
||||
</AutoRefreshProvider>
|
||||
</CrudThemeProvider>
|
||||
<DashboardDatasetsContext.Provider value={currentDashboardDatasets}>
|
||||
<CrudThemeProvider
|
||||
theme={reduxTheme !== undefined ? reduxTheme : dashboard?.theme}
|
||||
>
|
||||
<AutoRefreshProvider>
|
||||
<DashboardContainer
|
||||
activeFilters={activeFilters as ActiveFilters}
|
||||
ownDataCharts={relevantDataMask}
|
||||
>
|
||||
{DashboardBuilderComponent}
|
||||
</DashboardContainer>
|
||||
</AutoRefreshProvider>
|
||||
</CrudThemeProvider>
|
||||
</DashboardDatasetsContext.Provider>
|
||||
</DashboardPageIdContext.Provider>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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 { createContext } from 'react';
|
||||
import type { Datasource } from 'src/dashboard/types';
|
||||
|
||||
export interface DashboardDatasetsContextValue {
|
||||
dashboardId: number;
|
||||
datasets: Pick<Datasource, 'uid' | 'type' | 'columns'>[];
|
||||
}
|
||||
|
||||
export const DashboardDatasetsContext =
|
||||
createContext<DashboardDatasetsContextValue | null>(null);
|
||||
@@ -30,9 +30,41 @@ import * as hostNamesConfig from 'src/utils/hostNamesConfig';
|
||||
import {
|
||||
ChartMetadata,
|
||||
getChartMetadataRegistry,
|
||||
getChartBuildQueryRegistry,
|
||||
QueryFormData,
|
||||
SupersetClient,
|
||||
} from '@superset-ui/core';
|
||||
import { buildQuery as buildTableQuery } from '../../../plugins/plugin-chart-table/src/buildQuery';
|
||||
|
||||
test('shared chart request producer rebuilds saved semantic Table without saving', async () => {
|
||||
const viz = 'semantic_table_dormant_grain_test';
|
||||
const registry = getChartBuildQueryRegistry();
|
||||
registry.registerValue(viz, buildTableQuery);
|
||||
const post = jest.spyOn(SupersetClient, 'post');
|
||||
const put = jest.spyOn(SupersetClient, 'put');
|
||||
const formData = Object.freeze({
|
||||
viz_type: viz,
|
||||
datasource: '2__semantic_view',
|
||||
slice_id: 17,
|
||||
metrics: ['orders'],
|
||||
groupby: [],
|
||||
time_grain_sqla: 'P1D',
|
||||
extra_form_data: Object.freeze({ time_grain_sqla: 'P1M' }),
|
||||
});
|
||||
try {
|
||||
const payload = await buildV1ChartDataPayload({ formData });
|
||||
expect(payload.queries[0].extras).not.toHaveProperty('time_grain_sqla');
|
||||
expect(payload.form_data).toMatchObject(formData);
|
||||
expect(formData.time_grain_sqla).toBe('P1D');
|
||||
expect(formData.extra_form_data.time_grain_sqla).toBe('P1M');
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
post.mockRestore();
|
||||
put.mockRestore();
|
||||
registry.remove(viz);
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('exploreUtils', () => {
|
||||
|
||||
@@ -111,4 +111,18 @@ describe('useDashboardDatasets', () => {
|
||||
expect(result.current.result).toEqual(expectedContent);
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps transformed datasets stable across unrelated rerenders', async () => {
|
||||
fetchMock.get('glob:*/api/v1/dashboard/*/datasets', {
|
||||
result: mockDatasets,
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(() => useDashboardDatasets(1));
|
||||
await waitFor(() => expect(result.current.result).not.toBeNull());
|
||||
const datasets = result.current.result;
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.result).toBe(datasets);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,24 +70,28 @@ export const useDashboard = (idOrSlug: string | number) => {
|
||||
export const useDashboardCharts = (idOrSlug: string | number) =>
|
||||
useApiV1Resource<Chart[]>(`/api/v1/dashboard/${idOrSlug}/charts`);
|
||||
|
||||
// A stable transform keeps dashboard dataset metadata stable across unrelated
|
||||
// renders, so consumers do not rebuild chart form data while a query is active.
|
||||
const addCurrencyFormats = (datasets: Datasource[]) =>
|
||||
datasets.map(dataset => ({
|
||||
...dataset,
|
||||
currencyFormats: Object.fromEntries(
|
||||
(dataset.metrics ?? [])
|
||||
.filter(metric => !!metric.currency)
|
||||
.map((metric): [string, Currency] => [
|
||||
metric.metric_name,
|
||||
metric.currency!,
|
||||
]),
|
||||
),
|
||||
}));
|
||||
|
||||
// gets the datasets for a dashboard
|
||||
// important: this endpoint only returns the fields in the dataset
|
||||
// that are necessary for rendering the given dashboard
|
||||
export const useDashboardDatasets = (idOrSlug: string | number) =>
|
||||
useTransformedResource(
|
||||
useApiV1Resource<Datasource[]>(`/api/v1/dashboard/${idOrSlug}/datasets`),
|
||||
datasets =>
|
||||
datasets.map(dataset => ({
|
||||
...dataset,
|
||||
currencyFormats: Object.fromEntries(
|
||||
(dataset.metrics ?? [])
|
||||
.filter(metric => !!metric.currency)
|
||||
.map((metric): [string, Currency] => [
|
||||
metric.metric_name,
|
||||
metric.currency!,
|
||||
]),
|
||||
),
|
||||
})),
|
||||
addCurrencyFormats,
|
||||
);
|
||||
|
||||
export const useEmbeddedDashboard = (idOrSlug: string | number) =>
|
||||
|
||||
Reference in New Issue
Block a user