mirror of
https://github.com/apache/superset.git
synced 2026-08-27 18:41:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f0ccab62d |
@@ -40,18 +40,10 @@ import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { apiGet } from '../../helpers/api/requests';
|
||||
import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { getAccessToken } from '../../helpers/api/embedded';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
|
||||
async function authorizeApi(page: Page): Promise<void> {
|
||||
const accessToken = await getAccessToken(page);
|
||||
await page.context().setExtraHTTPHeaders({
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Visible row text must never expose synthetic identifiers (layout node
|
||||
// ids like CHART-xyz / ROW-… or bare UUIDs) — the rendering layer maps
|
||||
// these to human names or kind-only phrasing.
|
||||
@@ -87,35 +79,19 @@ async function currentUserSubjectId(page: Page): Promise<number> {
|
||||
* this reads like its sibling specs, but fall back to whatever the instance
|
||||
* has rather than requiring a particular fixture to be loaded.
|
||||
*/
|
||||
async function anyDataset(page: Page): Promise<{
|
||||
id: number;
|
||||
columnName: string;
|
||||
}> {
|
||||
async function anyDatasetId(page: Page): Promise<number> {
|
||||
const named = await getDatasetByName(page, DATASET_NAME);
|
||||
let datasetId = named?.id;
|
||||
if (!datasetId) {
|
||||
const res = await apiGet(
|
||||
page,
|
||||
`api/v1/dataset/?q=${rison.encode({ columns: ['id'], page_size: 1 })}`,
|
||||
);
|
||||
expect(res.ok(), 'dataset list request').toBeTruthy();
|
||||
const [first] = (await res.json()).result;
|
||||
expect(first, 'the instance has at least one dataset').toBeTruthy();
|
||||
datasetId = first.id;
|
||||
if (named) {
|
||||
return named.id;
|
||||
}
|
||||
if (datasetId === undefined) {
|
||||
throw new Error('Unable to resolve a dataset id');
|
||||
}
|
||||
|
||||
const detailRes = await apiGet(page, `api/v1/dataset/${datasetId}`);
|
||||
expect(detailRes.ok(), 'dataset detail request').toBeTruthy();
|
||||
const { columns } = (await detailRes.json()).result;
|
||||
const [firstColumn] = columns;
|
||||
expect(firstColumn, 'the dataset has at least one column').toBeTruthy();
|
||||
return {
|
||||
id: datasetId,
|
||||
columnName: firstColumn.column_name,
|
||||
};
|
||||
const res = await apiGet(
|
||||
page,
|
||||
`api/v1/dataset/?q=${rison.encode({ columns: ['id'], page_size: 1 })}`,
|
||||
);
|
||||
expect(res.ok(), 'dataset list request').toBeTruthy();
|
||||
const [first] = (await res.json()).result;
|
||||
expect(first, 'the instance has at least one dataset').toBeTruthy();
|
||||
return first.id;
|
||||
}
|
||||
|
||||
/** Open the Explore "Additional actions → View version history" panel. */
|
||||
@@ -133,8 +109,7 @@ testWithAssets(
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
await authorizeApi(page);
|
||||
const { id: datasetId, columnName } = await anyDataset(page);
|
||||
const datasetId = await anyDatasetId(page);
|
||||
|
||||
const baseName = `version_history_${Date.now()}`;
|
||||
const chartResp = await apiPostChart(page, {
|
||||
@@ -148,7 +123,7 @@ testWithAssets(
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'table',
|
||||
query_mode: 'raw',
|
||||
all_columns: [columnName],
|
||||
all_columns: [],
|
||||
adhoc_filters: [],
|
||||
row_limit: 10,
|
||||
}),
|
||||
@@ -196,79 +171,3 @@ testWithAssets(
|
||||
).toBeFalsy();
|
||||
},
|
||||
);
|
||||
|
||||
testWithAssets(
|
||||
'minor edit of a non-canonical chart omits hydration noise',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
await authorizeApi(page);
|
||||
const { id: datasetId, columnName } = await anyDataset(page);
|
||||
const baseName = `version_history_normalization_${Date.now()}`;
|
||||
const chartResp = await apiPostChart(page, {
|
||||
slice_name: baseName,
|
||||
viz_type: 'table',
|
||||
datasource_id: datasetId,
|
||||
datasource_type: 'table',
|
||||
// Deliberately omit visualization defaults. Explore hydration supplies
|
||||
// them, reproducing params imported before they were canonical.
|
||||
params: JSON.stringify({
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'table',
|
||||
query_mode: 'raw',
|
||||
all_columns: [columnName],
|
||||
adhoc_filters: [],
|
||||
extra_form_data: {},
|
||||
dashboards: [],
|
||||
row_limit: 10,
|
||||
}),
|
||||
});
|
||||
expect(chartResp.ok(), 'chart creation').toBeTruthy();
|
||||
const chartBody = await chartResp.json();
|
||||
const chartId: number = chartBody.result?.id ?? chartBody.id;
|
||||
expect(chartId, 'chart creation should return an id').toBeTruthy();
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
const adminSubjectId = await currentUserSubjectId(page);
|
||||
const editorResp = await apiPutChart(page, chartId, {
|
||||
editors: [adminSubjectId],
|
||||
});
|
||||
expect(editorResp.ok(), 'claim chart editorship').toBeTruthy();
|
||||
|
||||
await page.goto(`explore/?slice_id=${chartId}`);
|
||||
await page.getByRole('combobox', { name: 'Row limit' }).click();
|
||||
await page.getByRole('option', { name: '100', exact: true }).click();
|
||||
await page.locator('[data-test="query-save-button"]').click();
|
||||
await page.locator('[data-test="save-overwrite-radio"]').click();
|
||||
|
||||
const saveResponsePromise = page.waitForResponse(
|
||||
response =>
|
||||
response.request().method() === 'PUT' &&
|
||||
response.url().includes(`/api/v1/chart/${chartId}`),
|
||||
);
|
||||
await page.locator('[data-test="btn-modal-save"]').click();
|
||||
const saveResponse = await saveResponsePromise;
|
||||
expect(saveResponse.ok(), 'chart overwrite').toBeTruthy();
|
||||
|
||||
const requestPayload = saveResponse.request().postDataJSON();
|
||||
const savedParams = JSON.parse(requestPayload.params);
|
||||
expect(
|
||||
savedParams.matrixify_enable,
|
||||
'overwrite contains a default absent from the stored params',
|
||||
).toBe(false);
|
||||
|
||||
await openVersionHistory(page);
|
||||
const panel = page.locator('[aria-label="Version history"]');
|
||||
const newestGroup = panel
|
||||
.locator('[data-test="version-history-save-group"]')
|
||||
.first();
|
||||
await expect(newestGroup, 'shows the overwrite save group').toBeVisible();
|
||||
await newestGroup.getByRole('button').first().click();
|
||||
|
||||
const rows = newestGroup.locator(
|
||||
'[data-test="version-history-action-row"]',
|
||||
);
|
||||
await expect(rows, 'shows only the intentional edit').toHaveCount(1);
|
||||
await expect(rows.first()).toContainText(/row limit/i);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -102,18 +102,15 @@ export default function buildQuery(formData: QueryFormData) {
|
||||
1. The resample, rolling, cum, timeCompare operators should be after pivot.
|
||||
2. Resample must come before rolling so that imputed values are
|
||||
included in the rolling window calculation.
|
||||
3. Contribution must come before rename because it relies on the
|
||||
`__<time offset>` suffix to compute each time shift separately,
|
||||
and rename strips that suffix.
|
||||
4. the flatOperator makes multiIndex Dataframe into flat Dataframe
|
||||
3. the flatOperator makes multiIndex Dataframe into flat Dataframe
|
||||
*/
|
||||
post_processing: [
|
||||
pivotOperatorInRuntime,
|
||||
resampleOperator(formData, baseQueryObject),
|
||||
rollingWindowOperator(formData, baseQueryObject),
|
||||
timeCompareOperator(formData, baseQueryObject),
|
||||
contributionOperator(formData, baseQueryObject, time_offsets),
|
||||
renameOperator(formData, baseQueryObject),
|
||||
contributionOperator(formData, baseQueryObject, time_offsets),
|
||||
sortOperator(formData, baseQueryObject),
|
||||
flattenOperator(formData, baseQueryObject),
|
||||
// todo: move prophet before flatten
|
||||
|
||||
@@ -64,28 +64,6 @@ describe('Timeseries buildQuery', () => {
|
||||
expect(query.metrics).toEqual(['bar', 'baz']);
|
||||
});
|
||||
|
||||
test('should apply contribution before rename with time comparison', () => {
|
||||
// rename strips the `__<offset>` suffix that contribution relies on to
|
||||
// compute each time shift separately
|
||||
const queryContext = buildQuery({
|
||||
...formData,
|
||||
metrics: ['bar'],
|
||||
x_axis: 'ds',
|
||||
groupby: ['col1'],
|
||||
contributionMode: 'row',
|
||||
comparison_type: 'values',
|
||||
time_compare: ['1 week ago'],
|
||||
});
|
||||
const [query] = queryContext.queries;
|
||||
const operations = (query.post_processing || []).map(
|
||||
operator => operator?.operation,
|
||||
);
|
||||
expect(operations).toContain('contribution');
|
||||
expect(operations.indexOf('contribution')).toBeLessThan(
|
||||
operations.indexOf('rename'),
|
||||
);
|
||||
});
|
||||
|
||||
test('should not order by timeseries limit if orderby provided', () => {
|
||||
const queryContext = buildQuery({
|
||||
...formData,
|
||||
|
||||
@@ -17,20 +17,10 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { isFeatureEnabled, VizType } from '@superset-ui/core';
|
||||
import { HYDRATE_CHART_NORMALIZATION } from 'src/features/versionHistory/reducer';
|
||||
import { VizType } from '@superset-ui/core';
|
||||
import { hydrateExplore, HYDRATE_EXPLORE } from './hydrateExplore';
|
||||
import { exploreInitialData } from '../fixtures';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||
|
||||
beforeEach(() => mockedIsFeatureEnabled.mockReturnValue(false));
|
||||
|
||||
afterEach(() => {
|
||||
window.history.pushState({}, '', '/');
|
||||
});
|
||||
@@ -353,67 +343,3 @@ test('extracts currency formats from metrics in dataset', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('seeds only guarded matching-input hydration transitions', () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn(() => ({
|
||||
user: {},
|
||||
charts: {},
|
||||
datasources: {},
|
||||
common: { conf: { DEFAULT_TIME_FILTER: 'Last year' } },
|
||||
explore: {},
|
||||
}));
|
||||
const persisted = {
|
||||
...exploreInitialData.form_data,
|
||||
};
|
||||
delete persisted.time_range;
|
||||
const initialData = {
|
||||
...exploreInitialData,
|
||||
form_data: { ...persisted },
|
||||
slice: {
|
||||
...exploreInitialData.slice!,
|
||||
form_data: { ...persisted },
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error focused hydration fixture
|
||||
hydrateExplore(initialData)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: HYDRATE_CHART_NORMALIZATION,
|
||||
tracking: expect.objectContaining({
|
||||
chartId: 371,
|
||||
transitions: expect.objectContaining({
|
||||
time_range: {
|
||||
control: 'time_range',
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: 'Last year',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not seed normalization metadata for dashboard overrides', () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
window.history.pushState({}, '', '/explore/?dashboard_id=12');
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn(() => ({
|
||||
user: {},
|
||||
charts: {},
|
||||
datasources: {},
|
||||
common: {},
|
||||
explore: {},
|
||||
}));
|
||||
|
||||
// @ts-expect-error focused hydration fixture
|
||||
hydrateExplore(exploreInitialData)(dispatch, getState);
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: HYDRATE_CHART_NORMALIZATION }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -49,10 +49,6 @@ import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { nanoid } from 'nanoid';
|
||||
import cloneDeep from 'lodash-es/cloneDeep';
|
||||
import { hydrateChartNormalization } from 'src/features/versionHistory/reducer';
|
||||
import { automaticNormalizationTransitions } from 'src/features/versionHistory/normalization';
|
||||
|
||||
enum ColorSchemeType {
|
||||
CATEGORICAL = 'CATEGORICAL',
|
||||
@@ -82,8 +78,6 @@ export const hydrateExplore =
|
||||
const fallbackSlice = sliceId ? sliceEntities?.slices?.[sliceId] : null;
|
||||
const initialSlice = slice ?? fallbackSlice;
|
||||
const initialFormData = form_data ?? initialSlice?.form_data;
|
||||
const persistedFormData = cloneDeep(initialSlice?.form_data ?? {});
|
||||
const preHydrationFormData = cloneDeep(initialFormData ?? {});
|
||||
const isCachedFormData = getUrlParam(URL_PARAMS.formDataKey) !== null;
|
||||
const [primarySliceNameSource, fallbackSliceNameSource] = isCachedFormData
|
||||
? [initialFormData, initialSlice]
|
||||
@@ -219,10 +213,6 @@ export const hydrateExplore =
|
||||
exploreState,
|
||||
);
|
||||
});
|
||||
const hydratedFormData = {
|
||||
...initialFormData,
|
||||
...getFormDataFromControls(exploreState.controls),
|
||||
};
|
||||
const sliceFormData = initialSlice
|
||||
? getFormDataFromControls(initialControls)
|
||||
: null;
|
||||
@@ -243,7 +233,7 @@ export const hydrateExplore =
|
||||
lastRendered: 0,
|
||||
};
|
||||
|
||||
const result = dispatch({
|
||||
return dispatch({
|
||||
type: HYDRATE_EXPLORE,
|
||||
data: {
|
||||
charts: {
|
||||
@@ -263,28 +253,6 @@ export const hydrateExplore =
|
||||
dataMask,
|
||||
},
|
||||
});
|
||||
if (
|
||||
isFeatureEnabled(FeatureFlag.VersionHistory) &&
|
||||
initialSlice?.slice_id &&
|
||||
!isCachedFormData &&
|
||||
!dashboardId &&
|
||||
getUrlParam(URL_PARAMS.vizType) === null
|
||||
) {
|
||||
dispatch(
|
||||
hydrateChartNormalization({
|
||||
chartId: initialSlice.slice_id,
|
||||
hydrationSessionId: nanoid(),
|
||||
transitions: automaticNormalizationTransitions(
|
||||
persistedFormData,
|
||||
preHydrationFormData,
|
||||
hydratedFormData,
|
||||
),
|
||||
invalidatedControls: {},
|
||||
saveAttemptId: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export type HydrateExplore = {
|
||||
|
||||
@@ -21,7 +21,6 @@ import { Dispatch } from 'redux';
|
||||
import { ADD_TOAST } from 'src/components/MessageToasts/actions';
|
||||
import {
|
||||
DatasourceType,
|
||||
isFeatureEnabled,
|
||||
QueryFormData,
|
||||
SimpleAdhocFilter,
|
||||
VizType,
|
||||
@@ -38,13 +37,6 @@ import {
|
||||
} from './saveModalActions';
|
||||
import { Operators } from '../constants';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
|
||||
|
||||
// Define test constants and mock data using imported types
|
||||
const sliceId = 10;
|
||||
const sliceName = 'New chart';
|
||||
@@ -100,159 +92,17 @@ const sliceResponsePayload: Partial<PayloadSlice> = {
|
||||
};
|
||||
|
||||
const sampleError = new Error('sampleError');
|
||||
const updateSliceEndpoint = `glob:*/api/v1/chart/${sliceId}`;
|
||||
|
||||
jest.mock('../exploreUtils', () => ({
|
||||
buildV1ChartDataPayload: jest.fn(() => queryContext),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
mockedIsFeatureEnabled.mockReturnValue(false);
|
||||
});
|
||||
|
||||
test('existing-chart overwrite sends only still-matching normalization metadata', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
});
|
||||
const dispatch = jest.fn();
|
||||
const getState = () => ({
|
||||
explore: {
|
||||
form_data: {
|
||||
datasource: `${datasourceId}__${datasourceType}`,
|
||||
viz_type: vizType,
|
||||
row_limit: 10000,
|
||||
show_legend: true,
|
||||
object_control: { a: 1, b: 2 },
|
||||
},
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: sliceId,
|
||||
hydrationSessionId: 'hydration-a',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: { show_legend: true as const },
|
||||
transitions: {
|
||||
row_limit: {
|
||||
control: 'row_limit',
|
||||
from_present: true as const,
|
||||
from_value: null,
|
||||
to_present: true as const,
|
||||
to_value: 10000,
|
||||
},
|
||||
show_legend: {
|
||||
control: 'show_legend',
|
||||
from_present: false as const,
|
||||
to_present: true as const,
|
||||
to_value: true,
|
||||
},
|
||||
object_control: {
|
||||
control: 'object_control',
|
||||
from_present: false as const,
|
||||
to_present: true as const,
|
||||
to_value: { b: 2, a: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await updateSlice(
|
||||
{ ...sliceResponsePayload, slice_id: sliceId } as never,
|
||||
sliceName,
|
||||
[],
|
||||
)(dispatch, getState);
|
||||
|
||||
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
|
||||
const body = JSON.parse(request?.options.body as string);
|
||||
expect(body.normalization_changes).toEqual([
|
||||
{
|
||||
control: 'row_limit',
|
||||
from_present: true,
|
||||
from_value: null,
|
||||
to_present: true,
|
||||
to_value: 10000,
|
||||
},
|
||||
{
|
||||
control: 'object_control',
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: { b: 2, a: 1 },
|
||||
},
|
||||
]);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'BEGIN_CHART_NORMALIZATION_SAVE' }),
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'COMPLETE_CHART_NORMALIZATION_SAVE' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('matches normalization metadata against finalized payload filters', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
});
|
||||
const extraTemporalFilter = {
|
||||
expressionType: 'SIMPLE',
|
||||
clause: 'WHERE',
|
||||
subject: 'ds',
|
||||
operator: Operators.TemporalRange,
|
||||
comparator: '',
|
||||
isExtra: true,
|
||||
} as SimpleAdhocFilter;
|
||||
const savedTemporalFilter = {
|
||||
...extraTemporalFilter,
|
||||
comparator: 'No filter',
|
||||
isExtra: false,
|
||||
};
|
||||
const dispatch = jest.fn();
|
||||
const getState = () => ({
|
||||
explore: {
|
||||
form_data: {
|
||||
datasource: `${datasourceId}__${datasourceType}`,
|
||||
viz_type: vizType,
|
||||
adhoc_filters: [extraTemporalFilter],
|
||||
},
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: sliceId,
|
||||
hydrationSessionId: 'hydration-a',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: {},
|
||||
transitions: {
|
||||
adhoc_filters: {
|
||||
control: 'adhoc_filters',
|
||||
from_present: false as const,
|
||||
to_present: true as const,
|
||||
to_value: [savedTemporalFilter],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await updateSlice(
|
||||
{ ...sliceResponsePayload, slice_id: sliceId } as never,
|
||||
sliceName,
|
||||
[],
|
||||
)(dispatch, getState);
|
||||
|
||||
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
|
||||
const body = JSON.parse(request?.options.body as string);
|
||||
expect(body.normalization_changes).toEqual([
|
||||
expect.objectContaining({
|
||||
control: 'adhoc_filters',
|
||||
to_value: [savedTemporalFilter],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
beforeEach(() => fetchMock.clearHistory().removeRoutes());
|
||||
|
||||
/**
|
||||
* Tests updateSlice action
|
||||
*/
|
||||
const updateSliceEndpoint = `glob:*/api/v1/chart/${sliceId}`;
|
||||
test('updateSlice handles success', async () => {
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
@@ -884,97 +734,3 @@ describe('getSlicePayload', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('existing-chart overwrite covers stash-removed keys as drop transitions', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
});
|
||||
const dispatch = jest.fn();
|
||||
const getState = () => ({
|
||||
explore: {
|
||||
// The stash removed order_desc from active form data...
|
||||
form_data: {
|
||||
datasource: `${datasourceId}__${datasourceType}`,
|
||||
viz_type: vizType,
|
||||
row_limit: 10000,
|
||||
},
|
||||
// ...and holds it with the value it had when hidden.
|
||||
hiddenFormData: { order_desc: true },
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: sliceId,
|
||||
hydrationSessionId: 'hydration-drop',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: {},
|
||||
transitions: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await updateSlice(
|
||||
{
|
||||
...sliceResponsePayload,
|
||||
slice_id: sliceId,
|
||||
// Persisted params carry the key the stash removed, same value.
|
||||
form_data: { ...formData, order_desc: true },
|
||||
} as never,
|
||||
sliceName,
|
||||
[],
|
||||
)(dispatch, getState);
|
||||
|
||||
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
|
||||
const body = JSON.parse(request?.options.body as string);
|
||||
expect(body.normalization_changes).toEqual([
|
||||
{
|
||||
control: 'order_desc',
|
||||
from_present: true,
|
||||
from_value: true,
|
||||
to_present: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('a stashed value the user changed before hiding is not covered', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(true);
|
||||
fetchMock.put(updateSliceEndpoint, sliceResponsePayload, {
|
||||
name: updateSliceEndpoint,
|
||||
});
|
||||
const dispatch = jest.fn();
|
||||
const getState = () => ({
|
||||
explore: {
|
||||
form_data: {
|
||||
datasource: `${datasourceId}__${datasourceType}`,
|
||||
viz_type: vizType,
|
||||
row_limit: 10000,
|
||||
},
|
||||
// Stash holds a USER-edited value; persisted differs, so the removal
|
||||
// stays recorded.
|
||||
hiddenFormData: { order_desc: false },
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: sliceId,
|
||||
hydrationSessionId: 'hydration-drop-2',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: {},
|
||||
transitions: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await updateSlice(
|
||||
{
|
||||
...sliceResponsePayload,
|
||||
slice_id: sliceId,
|
||||
form_data: { ...formData, order_desc: true },
|
||||
} as never,
|
||||
sliceName,
|
||||
[],
|
||||
)(dispatch, getState);
|
||||
|
||||
const request = fetchMock.callHistory.lastCall(updateSliceEndpoint);
|
||||
const body = JSON.parse(request?.options.body as string);
|
||||
expect(body.normalization_changes).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -21,8 +21,6 @@ import { Dispatch } from 'redux';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
DatasourceType,
|
||||
FeatureFlag,
|
||||
isFeatureEnabled,
|
||||
type QueryFormData,
|
||||
SimpleAdhocFilter,
|
||||
SupersetClient,
|
||||
@@ -32,25 +30,11 @@ import { isEmpty } from 'lodash-es';
|
||||
import { Slice } from 'src/dashboard/types';
|
||||
import { Operators } from '../constants';
|
||||
import { buildV1ChartDataPayload } from '../exploreUtils';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
beginChartNormalizationSave,
|
||||
completeChartNormalizationSave,
|
||||
} from 'src/features/versionHistory/reducer';
|
||||
import type {
|
||||
AutomaticNormalizationTransitions,
|
||||
ChartNormalizationTrackingState,
|
||||
} from 'src/features/versionHistory/types';
|
||||
import {
|
||||
matchingAutomaticNormalizationTransitions,
|
||||
stashDropNormalizationTransitions,
|
||||
} from 'src/features/versionHistory/normalization';
|
||||
|
||||
export interface PayloadSlice extends Slice {
|
||||
params: string;
|
||||
dashboards: number[];
|
||||
query_context: string;
|
||||
normalization_changes?: AutomaticNormalizationTransitions[string][];
|
||||
}
|
||||
const ADHOC_FILTER_REGEX = /^adhoc_filters/;
|
||||
|
||||
@@ -249,84 +233,21 @@ export const updateSlice =
|
||||
new?: boolean;
|
||||
},
|
||||
) =>
|
||||
async (
|
||||
dispatch: Dispatch,
|
||||
getState: () => Partial<QueryFormData> & {
|
||||
versionHistory?: {
|
||||
chartNormalization?: ChartNormalizationTrackingState | null;
|
||||
};
|
||||
explore?: {
|
||||
form_data?: QueryFormData;
|
||||
hiddenFormData?: Record<string, unknown>;
|
||||
};
|
||||
},
|
||||
) => {
|
||||
async (dispatch: Dispatch, getState: () => Partial<QueryFormData>) => {
|
||||
const { slice_id: sliceId, editors, form_data: formDataFromSlice } = slice;
|
||||
const initialState = getState();
|
||||
const formData = JSON.parse(
|
||||
JSON.stringify(initialState.explore?.form_data ?? {}),
|
||||
) as QueryFormData;
|
||||
const tracking = initialState.versionHistory?.chartNormalization;
|
||||
const saveAttemptId = nanoid();
|
||||
const shouldAttachNormalization =
|
||||
isFeatureEnabled(FeatureFlag.VersionHistory) &&
|
||||
tracking?.chartId === sliceId;
|
||||
if (shouldAttachNormalization) {
|
||||
dispatch(
|
||||
beginChartNormalizationSave(
|
||||
sliceId,
|
||||
tracking.hydrationSessionId,
|
||||
saveAttemptId,
|
||||
),
|
||||
);
|
||||
}
|
||||
const formData = getState().explore?.form_data;
|
||||
try {
|
||||
const payload = await getSlicePayload(
|
||||
sliceName,
|
||||
formData,
|
||||
dashboards,
|
||||
editors as [],
|
||||
formDataFromSlice,
|
||||
);
|
||||
const savedFormData = JSON.parse(payload.params ?? '{}') as QueryFormData;
|
||||
// Hydration-time transitions that still hold, plus save-time drops of
|
||||
// keys the stash removed (mutually exclusive per control: a surviving
|
||||
// hydration transition implies the key is present in the payload, a
|
||||
// stash drop implies it is absent).
|
||||
const matchingTransitions = shouldAttachNormalization
|
||||
? {
|
||||
...matchingAutomaticNormalizationTransitions(
|
||||
tracking,
|
||||
savedFormData,
|
||||
),
|
||||
...stashDropNormalizationTransitions(
|
||||
(formDataFromSlice ?? {}) as Record<string, unknown>,
|
||||
initialState.explore?.hiddenFormData,
|
||||
savedFormData,
|
||||
),
|
||||
}
|
||||
: {};
|
||||
if (
|
||||
shouldAttachNormalization &&
|
||||
Object.keys(matchingTransitions).length
|
||||
) {
|
||||
payload.normalization_changes = Object.values(matchingTransitions);
|
||||
}
|
||||
const response = await SupersetClient.put({
|
||||
endpoint: `/api/v1/chart/${sliceId}`,
|
||||
jsonPayload: payload,
|
||||
jsonPayload: await getSlicePayload(
|
||||
sliceName,
|
||||
formData,
|
||||
dashboards,
|
||||
editors as [],
|
||||
formDataFromSlice,
|
||||
),
|
||||
});
|
||||
|
||||
if (shouldAttachNormalization) {
|
||||
dispatch(
|
||||
completeChartNormalizationSave(
|
||||
sliceId,
|
||||
tracking.hydrationSessionId,
|
||||
saveAttemptId,
|
||||
{},
|
||||
),
|
||||
);
|
||||
}
|
||||
dispatch(saveSliceSuccess(response.json));
|
||||
addToasts(false, sliceName, addedToDashboard).map(dispatch);
|
||||
return response.json;
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
/**
|
||||
* 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 {
|
||||
automaticNormalizationTransitions,
|
||||
isJsonValue,
|
||||
matchingAutomaticNormalizationTransitions,
|
||||
stashDropNormalizationTransitions,
|
||||
} from './normalization';
|
||||
|
||||
test('recognizes only values that JSON can represent faithfully', () => {
|
||||
expect(isJsonValue({ nested: [null, true, 3, 'value'] })).toBe(true);
|
||||
expect(isJsonValue(Number.NaN)).toBe(false);
|
||||
expect(isJsonValue(Number.POSITIVE_INFINITY)).toBe(false);
|
||||
expect(isJsonValue(new Date())).toBe(false);
|
||||
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic.self = cyclic;
|
||||
expect(isJsonValue(cyclic)).toBe(false);
|
||||
});
|
||||
|
||||
test('records hydration changes only when input matches persisted data', () => {
|
||||
expect(
|
||||
automaticNormalizationTransitions(
|
||||
{ row_limit: null },
|
||||
{ row_limit: null },
|
||||
{ row_limit: 10000, show_legend: true },
|
||||
),
|
||||
).toEqual({
|
||||
row_limit: {
|
||||
control: 'row_limit',
|
||||
from_present: true,
|
||||
from_value: null,
|
||||
to_present: true,
|
||||
to_value: 10000,
|
||||
},
|
||||
show_legend: {
|
||||
control: 'show_legend',
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
automaticNormalizationTransitions(
|
||||
{ row_limit: null },
|
||||
{ row_limit: 500 },
|
||||
{ row_limit: 10000 },
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('does not interpret a missing hydrated control as normalization', () => {
|
||||
expect(
|
||||
automaticNormalizationTransitions(
|
||||
{ obsolete_control: true },
|
||||
{ obsolete_control: true },
|
||||
{},
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('keeps only valid, unchanged transitions for a save', () => {
|
||||
const rowLimit = {
|
||||
control: 'row_limit',
|
||||
from_present: true as const,
|
||||
from_value: null,
|
||||
to_present: true as const,
|
||||
to_value: 10000,
|
||||
};
|
||||
const tracking = {
|
||||
chartId: 7,
|
||||
hydrationSessionId: 'hydration-a',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: { show_legend: true as const },
|
||||
transitions: {
|
||||
row_limit: rowLimit,
|
||||
show_legend: {
|
||||
control: 'show_legend',
|
||||
from_present: false as const,
|
||||
to_present: true as const,
|
||||
to_value: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
matchingAutomaticNormalizationTransitions(tracking, {
|
||||
row_limit: 10000,
|
||||
show_legend: true,
|
||||
}),
|
||||
).toEqual({ row_limit: rowLimit });
|
||||
});
|
||||
|
||||
test('covers a stash-removed key still equal to its persisted value', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions(
|
||||
{ order_desc: true, row_limit: 5000 },
|
||||
{ order_desc: true },
|
||||
{ row_limit: 5000 },
|
||||
),
|
||||
).toEqual({
|
||||
order_desc: {
|
||||
control: 'order_desc',
|
||||
from_present: true,
|
||||
from_value: true,
|
||||
to_present: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('does not cover a stashed value the user changed before it was hidden', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions(
|
||||
{ server_page_length: 10 },
|
||||
{ server_page_length: 25 },
|
||||
{},
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('does not cover stashed keys that were never persisted', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions({}, { totals_aggregate: 'SUM' }, {}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('does not cover keys the outgoing payload still carries', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions(
|
||||
{ order_desc: true },
|
||||
{ order_desc: true },
|
||||
{ order_desc: true },
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
test('drop coverage requires a stash', () => {
|
||||
expect(
|
||||
stashDropNormalizationTransitions({ order_desc: true }, undefined, {}),
|
||||
).toEqual({});
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* 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 isEqual from 'lodash-es/isEqual';
|
||||
import type {
|
||||
AutomaticNormalizationTransition,
|
||||
AutomaticNormalizationTransitions,
|
||||
ChartNormalizationTrackingState,
|
||||
JsonValue,
|
||||
} from './types';
|
||||
|
||||
const isJsonValueInternal = (
|
||||
value: unknown,
|
||||
ancestors: WeakSet<object>,
|
||||
): value is JsonValue => {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value);
|
||||
}
|
||||
if (typeof value !== 'object' || ancestors.has(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ancestors.add(value);
|
||||
let isJsonCompatible: boolean;
|
||||
if (Array.isArray(value)) {
|
||||
isJsonCompatible = value.every(item =>
|
||||
isJsonValueInternal(item, ancestors),
|
||||
);
|
||||
} else {
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
isJsonCompatible =
|
||||
(prototype === Object.prototype || prototype === null) &&
|
||||
Object.values(value).every(item => isJsonValueInternal(item, ancestors));
|
||||
}
|
||||
ancestors.delete(value);
|
||||
return isJsonCompatible;
|
||||
};
|
||||
|
||||
export const isJsonValue = (value: unknown): value is JsonValue =>
|
||||
isJsonValueInternal(value, new WeakSet());
|
||||
|
||||
/** Structural equality for JSON values, independent of object key order. */
|
||||
export const jsonValuesEqual = (left: unknown, right: unknown) =>
|
||||
isEqual(left, right);
|
||||
|
||||
interface NormalizationSnapshots {
|
||||
control: string;
|
||||
persisted: Record<string, unknown>;
|
||||
input: Record<string, unknown>;
|
||||
hydrated: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const automaticNormalizationTransition = ({
|
||||
control,
|
||||
persisted,
|
||||
input,
|
||||
hydrated,
|
||||
}: NormalizationSnapshots): AutomaticNormalizationTransition | undefined => {
|
||||
const fromPresent = Object.hasOwn(persisted, control);
|
||||
const inputPresent = Object.hasOwn(input, control);
|
||||
const toPresent = Object.hasOwn(hydrated, control);
|
||||
const fromValue = persisted[control];
|
||||
const inputValue = input[control];
|
||||
const toValue = hydrated[control];
|
||||
|
||||
const inputMatchesPersisted =
|
||||
fromPresent === inputPresent && jsonValuesEqual(fromValue, inputValue);
|
||||
const hydrationChangedValue =
|
||||
fromPresent !== toPresent || !jsonValuesEqual(fromValue, toValue);
|
||||
|
||||
// Disappearing keys (!toPresent) are deliberately not covered here:
|
||||
// hydration itself never removes keys from the merged snapshot. Machine
|
||||
// removals happen later, when StashFormDataContainer stashes invisible
|
||||
// controls out of form_data — those are covered at save time by
|
||||
// stashDropNormalizationTransitions, which uses the stash itself
|
||||
// (explore.hiddenFormData) as the proof the removal was not user-made.
|
||||
if (!inputMatchesPersisted || !toPresent || !hydrationChangedValue) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isJsonValue(toValue)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!fromPresent) {
|
||||
return {
|
||||
control,
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: toValue,
|
||||
};
|
||||
}
|
||||
if (!isJsonValue(fromValue)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
control,
|
||||
from_present: true,
|
||||
from_value: fromValue,
|
||||
to_present: true,
|
||||
to_value: toValue,
|
||||
};
|
||||
};
|
||||
|
||||
export const automaticNormalizationTransitions = (
|
||||
persisted: Record<string, unknown>,
|
||||
input: Record<string, unknown>,
|
||||
hydrated: Record<string, unknown>,
|
||||
): AutomaticNormalizationTransitions => {
|
||||
const transitions: AutomaticNormalizationTransitions = {};
|
||||
const controls = new Set([...Object.keys(input), ...Object.keys(hydrated)]);
|
||||
controls.forEach(control => {
|
||||
const transition = automaticNormalizationTransition({
|
||||
control,
|
||||
persisted,
|
||||
input,
|
||||
hydrated,
|
||||
});
|
||||
if (transition) {
|
||||
transitions[control] = transition;
|
||||
}
|
||||
});
|
||||
return transitions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Advisory transitions for keys the stash removed from form_data.
|
||||
*
|
||||
* StashFormDataContainer moves an invisible control's value out of
|
||||
* ``form_data`` into ``explore.hiddenFormData``. That removal is
|
||||
* machine-made by construction, but it happens in render effects after
|
||||
* hydration, so hydration-time tracking cannot see it. This computes the
|
||||
* matching drop transitions at save time: a key counts only when the stash
|
||||
* holds it, the stashed value still equals the persisted value (a user edit
|
||||
* before hiding breaks the equality and stays recorded), and the outgoing
|
||||
* payload no longer carries the key. Keys absent from the stash — e.g.
|
||||
* removed by a viz-type switch — are never covered.
|
||||
*/
|
||||
export const stashDropNormalizationTransitions = (
|
||||
persisted: Record<string, unknown>,
|
||||
hiddenFormData: Record<string, unknown> | undefined,
|
||||
outgoingFormData: Record<string, unknown>,
|
||||
): AutomaticNormalizationTransitions => {
|
||||
const transitions: AutomaticNormalizationTransitions = {};
|
||||
if (!hiddenFormData) {
|
||||
return transitions;
|
||||
}
|
||||
Object.keys(hiddenFormData).forEach(control => {
|
||||
if (!Object.hasOwn(persisted, control)) return;
|
||||
if (Object.hasOwn(outgoingFormData, control)) return;
|
||||
const fromValue = persisted[control];
|
||||
if (!isJsonValue(fromValue)) return;
|
||||
if (!jsonValuesEqual(hiddenFormData[control], fromValue)) return;
|
||||
transitions[control] = {
|
||||
control,
|
||||
from_present: true,
|
||||
from_value: fromValue,
|
||||
to_present: false,
|
||||
};
|
||||
});
|
||||
return transitions;
|
||||
};
|
||||
|
||||
export const matchingAutomaticNormalizationTransitions = (
|
||||
tracking: ChartNormalizationTrackingState | null | undefined,
|
||||
formData: Record<string, unknown>,
|
||||
): AutomaticNormalizationTransitions =>
|
||||
Object.fromEntries(
|
||||
Object.entries(tracking?.transitions ?? {}).filter(
|
||||
([control, transition]) =>
|
||||
!tracking?.invalidatedControls[control] &&
|
||||
Object.hasOwn(formData, control) === transition.to_present &&
|
||||
(!transition.to_present ||
|
||||
jsonValuesEqual(formData[control], transition.to_value)),
|
||||
),
|
||||
);
|
||||
@@ -18,14 +18,10 @@
|
||||
*/
|
||||
import versionHistoryReducer, {
|
||||
appendVersionSessionLog,
|
||||
beginChartNormalizationSave,
|
||||
clearVersionPreview,
|
||||
clearVersionSessionLog,
|
||||
completeChartNormalizationSave,
|
||||
closeVersionHistoryPanel,
|
||||
openVersionHistoryPanel,
|
||||
hydrateChartNormalization,
|
||||
invalidateChartNormalizationControls,
|
||||
selectIsChartVersionPreviewActive,
|
||||
selectIsDashboardVersionPreviewActive,
|
||||
selectVersionHistory,
|
||||
@@ -149,55 +145,3 @@ test('per-entity preview selectors only match their own entity type', () => {
|
||||
expect(selectIsChartVersionPreviewActive(state)).toBe(true);
|
||||
expect(selectIsDashboardVersionPreviewActive(state)).toBe(false);
|
||||
});
|
||||
|
||||
test('normalization tracking invalidates controls without re-adding transitions', () => {
|
||||
let state = versionHistoryReducer(
|
||||
initial,
|
||||
hydrateChartNormalization({
|
||||
chartId: 7,
|
||||
hydrationSessionId: 'session-a',
|
||||
transitions: {
|
||||
row_limit: {
|
||||
control: 'row_limit',
|
||||
from_present: true,
|
||||
from_value: null,
|
||||
to_present: true,
|
||||
to_value: 10000,
|
||||
},
|
||||
},
|
||||
invalidatedControls: {},
|
||||
saveAttemptId: null,
|
||||
}),
|
||||
);
|
||||
state = versionHistoryReducer(
|
||||
state,
|
||||
invalidateChartNormalizationControls(['row_limit']),
|
||||
);
|
||||
expect(state.chartNormalization?.invalidatedControls).toEqual({
|
||||
row_limit: true,
|
||||
});
|
||||
expect(state.chartNormalization?.transitions.row_limit).toBeDefined();
|
||||
});
|
||||
|
||||
test('late save completion cannot rebase another hydration session', () => {
|
||||
let state = versionHistoryReducer(
|
||||
initial,
|
||||
hydrateChartNormalization({
|
||||
chartId: 7,
|
||||
hydrationSessionId: 'session-b',
|
||||
transitions: {},
|
||||
invalidatedControls: {},
|
||||
saveAttemptId: null,
|
||||
}),
|
||||
);
|
||||
state = versionHistoryReducer(
|
||||
state,
|
||||
beginChartNormalizationSave(7, 'session-b', 'attempt-b'),
|
||||
);
|
||||
const unchanged = versionHistoryReducer(
|
||||
state,
|
||||
completeChartNormalizationSave(7, 'session-a', 'attempt-a', {}),
|
||||
);
|
||||
expect(unchanged).toBe(state);
|
||||
expect(unchanged.chartNormalization?.saveAttemptId).toBe('attempt-b');
|
||||
});
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
*/
|
||||
import type {
|
||||
ActivityInclude,
|
||||
AutomaticNormalizationTransitions,
|
||||
ChartNormalizationTrackingState,
|
||||
SessionLogEntry,
|
||||
VersionedEntityType,
|
||||
VersionHistoryState,
|
||||
@@ -35,12 +33,6 @@ export const VERSION_PREVIEW_APPLIED = 'VERSION_PREVIEW_APPLIED';
|
||||
export const VERSION_RESTORED = 'VERSION_RESTORED';
|
||||
export const APPEND_VERSION_SESSION_LOG = 'APPEND_VERSION_SESSION_LOG';
|
||||
export const CLEAR_VERSION_SESSION_LOG = 'CLEAR_VERSION_SESSION_LOG';
|
||||
export const HYDRATE_CHART_NORMALIZATION = 'HYDRATE_CHART_NORMALIZATION';
|
||||
export const INVALIDATE_CHART_NORMALIZATION_CONTROLS =
|
||||
'INVALIDATE_CHART_NORMALIZATION_CONTROLS';
|
||||
export const BEGIN_CHART_NORMALIZATION_SAVE = 'BEGIN_CHART_NORMALIZATION_SAVE';
|
||||
export const COMPLETE_CHART_NORMALIZATION_SAVE =
|
||||
'COMPLETE_CHART_NORMALIZATION_SAVE';
|
||||
|
||||
/** Upper bound on retained unsaved-edit entries; older ones drop off. */
|
||||
export const MAX_SESSION_LOG_ENTRIES = 50;
|
||||
@@ -94,31 +86,6 @@ interface ClearSessionLogAction {
|
||||
type: typeof CLEAR_VERSION_SESSION_LOG;
|
||||
}
|
||||
|
||||
interface HydrateChartNormalizationAction {
|
||||
type: typeof HYDRATE_CHART_NORMALIZATION;
|
||||
tracking: ChartNormalizationTrackingState;
|
||||
}
|
||||
|
||||
interface InvalidateChartNormalizationControlsAction {
|
||||
type: typeof INVALIDATE_CHART_NORMALIZATION_CONTROLS;
|
||||
controls: string[];
|
||||
}
|
||||
|
||||
interface BeginChartNormalizationSaveAction {
|
||||
type: typeof BEGIN_CHART_NORMALIZATION_SAVE;
|
||||
chartId: number;
|
||||
hydrationSessionId: string;
|
||||
saveAttemptId: string;
|
||||
}
|
||||
|
||||
interface CompleteChartNormalizationSaveAction {
|
||||
type: typeof COMPLETE_CHART_NORMALIZATION_SAVE;
|
||||
chartId: number;
|
||||
hydrationSessionId: string;
|
||||
saveAttemptId: string;
|
||||
transitions: AutomaticNormalizationTransitions;
|
||||
}
|
||||
|
||||
export type VersionHistoryAction =
|
||||
| OpenPanelAction
|
||||
| ClosePanelAction
|
||||
@@ -128,11 +95,7 @@ export type VersionHistoryAction =
|
||||
| PreviewAppliedAction
|
||||
| VersionRestoredAction
|
||||
| AppendSessionLogAction
|
||||
| ClearSessionLogAction
|
||||
| HydrateChartNormalizationAction
|
||||
| InvalidateChartNormalizationControlsAction
|
||||
| BeginChartNormalizationSaveAction
|
||||
| CompleteChartNormalizationSaveAction;
|
||||
| ClearSessionLogAction;
|
||||
|
||||
export const openVersionHistoryPanel = (
|
||||
entityType: VersionedEntityType,
|
||||
@@ -198,44 +161,6 @@ export const clearVersionSessionLog = (): ClearSessionLogAction => ({
|
||||
type: CLEAR_VERSION_SESSION_LOG,
|
||||
});
|
||||
|
||||
export const hydrateChartNormalization = (
|
||||
tracking: ChartNormalizationTrackingState,
|
||||
): HydrateChartNormalizationAction => ({
|
||||
type: HYDRATE_CHART_NORMALIZATION,
|
||||
tracking,
|
||||
});
|
||||
|
||||
export const invalidateChartNormalizationControls = (
|
||||
controls: string[],
|
||||
): InvalidateChartNormalizationControlsAction => ({
|
||||
type: INVALIDATE_CHART_NORMALIZATION_CONTROLS,
|
||||
controls,
|
||||
});
|
||||
|
||||
export const beginChartNormalizationSave = (
|
||||
chartId: number,
|
||||
hydrationSessionId: string,
|
||||
saveAttemptId: string,
|
||||
): BeginChartNormalizationSaveAction => ({
|
||||
type: BEGIN_CHART_NORMALIZATION_SAVE,
|
||||
chartId,
|
||||
hydrationSessionId,
|
||||
saveAttemptId,
|
||||
});
|
||||
|
||||
export const completeChartNormalizationSave = (
|
||||
chartId: number,
|
||||
hydrationSessionId: string,
|
||||
saveAttemptId: string,
|
||||
transitions: AutomaticNormalizationTransitions,
|
||||
): CompleteChartNormalizationSaveAction => ({
|
||||
type: COMPLETE_CHART_NORMALIZATION_SAVE,
|
||||
chartId,
|
||||
hydrationSessionId,
|
||||
saveAttemptId,
|
||||
transitions,
|
||||
});
|
||||
|
||||
const initialState: VersionHistoryState = {
|
||||
isPanelOpen: false,
|
||||
entityType: null,
|
||||
@@ -245,7 +170,6 @@ const initialState: VersionHistoryState = {
|
||||
sessionLog: [],
|
||||
restoreCount: 0,
|
||||
lastRestoredEntityUuid: null,
|
||||
chartNormalization: null,
|
||||
};
|
||||
|
||||
export default function versionHistoryReducer(
|
||||
@@ -316,58 +240,6 @@ export default function versionHistoryReducer(
|
||||
}
|
||||
case CLEAR_VERSION_SESSION_LOG:
|
||||
return { ...state, sessionLog: [] };
|
||||
case HYDRATE_CHART_NORMALIZATION:
|
||||
return { ...state, chartNormalization: action.tracking };
|
||||
case INVALIDATE_CHART_NORMALIZATION_CONTROLS: {
|
||||
if (!state.chartNormalization || action.controls.length === 0) {
|
||||
return state;
|
||||
}
|
||||
const invalidatedControls = {
|
||||
...state.chartNormalization.invalidatedControls,
|
||||
};
|
||||
action.controls.forEach(control => {
|
||||
invalidatedControls[control] = true;
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
chartNormalization: {
|
||||
...state.chartNormalization,
|
||||
invalidatedControls,
|
||||
},
|
||||
};
|
||||
}
|
||||
case BEGIN_CHART_NORMALIZATION_SAVE:
|
||||
if (
|
||||
state.chartNormalization?.chartId !== action.chartId ||
|
||||
state.chartNormalization.hydrationSessionId !==
|
||||
action.hydrationSessionId
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
chartNormalization: {
|
||||
...state.chartNormalization,
|
||||
saveAttemptId: action.saveAttemptId,
|
||||
},
|
||||
};
|
||||
case COMPLETE_CHART_NORMALIZATION_SAVE:
|
||||
if (
|
||||
state.chartNormalization?.chartId !== action.chartId ||
|
||||
state.chartNormalization.hydrationSessionId !==
|
||||
action.hydrationSessionId ||
|
||||
state.chartNormalization.saveAttemptId !== action.saveAttemptId
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
chartNormalization: {
|
||||
...state.chartNormalization,
|
||||
transitions: action.transitions,
|
||||
saveAttemptId: null,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -420,6 +292,3 @@ export const selectVersionLastRestoredUuid = (state: VersionHistoryRootState) =>
|
||||
|
||||
export const selectVersionSessionLog = (state: VersionHistoryRootState) =>
|
||||
selectVersionHistory(state).sessionLog;
|
||||
|
||||
export const selectChartNormalization = (state: VersionHistoryRootState) =>
|
||||
selectVersionHistory(state).chartNormalization;
|
||||
|
||||
@@ -21,7 +21,6 @@ import { versionSessionLogMiddleware } from './sessionLogMiddleware';
|
||||
import {
|
||||
APPEND_VERSION_SESSION_LOG,
|
||||
CLEAR_VERSION_SESSION_LOG,
|
||||
INVALIDATE_CHART_NORMALIZATION_CONTROLS,
|
||||
} from './reducer';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
@@ -78,7 +77,7 @@ test('falls back to a humanized control name when no label exists', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('programmatic writes invalidate normalization without logging an edit', async () => {
|
||||
test('skips programmatic control writes so untouched charts stay clean', async () => {
|
||||
// Effects rewrite controls with no user gesture (transferred-control
|
||||
// cleanup after load, derived margins); logging them would report unsaved
|
||||
// edits the user never made. Built with the REAL action creator so the
|
||||
@@ -90,54 +89,15 @@ test('programmatic writes invalidate normalization without logging an edit', asy
|
||||
explore: { controls: { metrics: { label: 'Metrics' } } },
|
||||
});
|
||||
run(store, setControlValue('metrics', [], undefined, { programmatic: true }));
|
||||
expect(store.dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(store.dispatch).toHaveBeenCalledWith({
|
||||
type: INVALIDATE_CHART_NORMALIZATION_CONTROLS,
|
||||
controls: ['metrics'],
|
||||
});
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
|
||||
// The same creator without the mark still logs.
|
||||
store.dispatch.mockClear();
|
||||
run(store, setControlValue('metrics', []));
|
||||
expect(store.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: APPEND_VERSION_SESSION_LOG }),
|
||||
);
|
||||
});
|
||||
|
||||
test('a write matching the hydrated default preserves normalization', async () => {
|
||||
const { setControlValue } =
|
||||
await import('src/explore/actions/exploreActions');
|
||||
const store = buildStore({
|
||||
explore: {
|
||||
controls: { show_totals: { label: 'Show totals' } },
|
||||
form_data: { show_totals: false },
|
||||
},
|
||||
versionHistory: {
|
||||
chartNormalization: {
|
||||
chartId: 7,
|
||||
hydrationSessionId: 'hydration-a',
|
||||
saveAttemptId: null,
|
||||
invalidatedControls: {},
|
||||
transitions: {
|
||||
show_totals: {
|
||||
control: 'show_totals',
|
||||
from_present: false,
|
||||
to_present: true,
|
||||
to_value: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
run(
|
||||
store,
|
||||
setControlValue('show_totals', false, undefined, { programmatic: true }),
|
||||
);
|
||||
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('clears the session log when the explore page hydrates', () => {
|
||||
const store = buildStore();
|
||||
run(store, { type: 'HYDRATE_EXPLORE', data: {} });
|
||||
@@ -228,7 +188,7 @@ test('a history step cannot collapse into an adjacent control entry', async () =
|
||||
await import('src/explore/actions/exploreActions');
|
||||
const store = buildStore({ explore: { controls: {} } });
|
||||
run(store, setExploreControls({} as never));
|
||||
const [[{ entry }]] = store.dispatch.mock.calls;
|
||||
const { entry } = store.dispatch.mock.calls[0][0];
|
||||
expect(entry.controlName).not.toMatch(/^[a-z]/);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,13 +19,7 @@
|
||||
import type { Middleware } from 'redux';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
appendVersionSessionLog,
|
||||
clearVersionSessionLog,
|
||||
invalidateChartNormalizationControls,
|
||||
} from './reducer';
|
||||
import { jsonValuesEqual } from './normalization';
|
||||
import type { ChartNormalizationTrackingState } from './types';
|
||||
import { appendVersionSessionLog, clearVersionSessionLog } from './reducer';
|
||||
|
||||
// Action types are inlined (rather than imported from the explore
|
||||
// module) so this middleware does not pull explore code into every
|
||||
@@ -53,54 +47,9 @@ interface SessionLogState {
|
||||
user?: { firstName?: string; lastName?: string };
|
||||
explore?: {
|
||||
controls?: Record<string, { label?: unknown } | undefined>;
|
||||
form_data?: Record<string, unknown>;
|
||||
};
|
||||
versionHistory?: {
|
||||
chartNormalization?: ChartNormalizationTrackingState | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** Untrusted Explore action shape; fields narrow only at this boundary. */
|
||||
interface ExploreBoundaryAction {
|
||||
type: unknown;
|
||||
controlName?: unknown;
|
||||
formData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const changedFormDataKeys = (
|
||||
before: Record<string, unknown> = {},
|
||||
after: Record<string, unknown> = {},
|
||||
) =>
|
||||
[...new Set([...Object.keys(before), ...Object.keys(after)])].filter(
|
||||
key => before[key] !== after[key],
|
||||
);
|
||||
|
||||
/**
|
||||
* Anti-corruption adapter from Explore's action vocabulary to the stable
|
||||
* versioning concept of controls whose user-intent evidence is no longer valid.
|
||||
*/
|
||||
export const normalizationControlsChangedByExplore = (
|
||||
action: ExploreBoundaryAction,
|
||||
before: Record<string, unknown> | undefined,
|
||||
after: Record<string, unknown> | undefined,
|
||||
) => {
|
||||
if (action.type === HYDRATE_EXPLORE) {
|
||||
return [];
|
||||
}
|
||||
const controls = changedFormDataKeys(before, after);
|
||||
if (
|
||||
action.type === SET_FIELD_VALUE &&
|
||||
typeof action.controlName === 'string'
|
||||
) {
|
||||
controls.push(action.controlName);
|
||||
} else if (action.type === SET_EXPLORE_CONTROLS && action.formData) {
|
||||
controls.push(...Object.keys(action.formData));
|
||||
} else if (action.type === UPDATE_FORM_DATA_BY_DATASOURCE) {
|
||||
controls.push(DATASOURCE_CONTROL_NAME);
|
||||
}
|
||||
return [...new Set(controls)];
|
||||
};
|
||||
|
||||
function controlLabel(state: SessionLogState, controlName: string): string {
|
||||
const label = state.explore?.controls?.[controlName]?.label;
|
||||
return typeof label === 'string' && label
|
||||
@@ -115,25 +64,6 @@ function userName(state: SessionLogState): string | null {
|
||||
return name || null;
|
||||
}
|
||||
|
||||
const normalizationControlsNoLongerMatching = (
|
||||
controls: string[],
|
||||
state: SessionLogState,
|
||||
) => {
|
||||
const formData = state.explore?.form_data ?? {};
|
||||
const transitions = state.versionHistory?.chartNormalization?.transitions;
|
||||
return controls.filter(control => {
|
||||
const transition = transitions?.[control];
|
||||
if (!transition) {
|
||||
return true;
|
||||
}
|
||||
const present = Object.hasOwn(formData, control);
|
||||
return (
|
||||
present !== transition.to_present ||
|
||||
(present && !jsonValuesEqual(formData[control], transition.to_value))
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Records unsaved explore control changes in the version history
|
||||
* session log ("Current version" section) and resets the log whenever
|
||||
@@ -141,7 +71,6 @@ const normalizationControlsNoLongerMatching = (
|
||||
*/
|
||||
export const versionSessionLogMiddleware: Middleware =
|
||||
store => next => action => {
|
||||
const before = (store.getState() as SessionLogState).explore?.form_data;
|
||||
const result = next(action);
|
||||
if (!isFeatureEnabled(FeatureFlag.VersionHistory)) {
|
||||
return result;
|
||||
@@ -222,17 +151,5 @@ export const versionSessionLogMiddleware: Middleware =
|
||||
}),
|
||||
);
|
||||
}
|
||||
const state = store.getState() as SessionLogState;
|
||||
const changedControls = normalizationControlsNoLongerMatching(
|
||||
normalizationControlsChangedByExplore(
|
||||
action,
|
||||
before,
|
||||
state.explore?.form_data,
|
||||
),
|
||||
state,
|
||||
);
|
||||
if (changedControls.length) {
|
||||
store.dispatch(invalidateChartNormalizationControls(changedControls));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -209,45 +209,6 @@ export interface SessionLogEntry {
|
||||
user: string | null;
|
||||
}
|
||||
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
type PresentNormalizationValue<Prefix extends 'from' | 'to'> =
|
||||
Prefix extends 'from'
|
||||
? { from_present: true; from_value: JsonValue }
|
||||
: { to_present: true; to_value: JsonValue };
|
||||
|
||||
type MissingNormalizationValue<Prefix extends 'from' | 'to'> =
|
||||
Prefix extends 'from'
|
||||
? { from_present: false; from_value?: never }
|
||||
: { to_present: false; to_value?: never };
|
||||
|
||||
/** One guarded hydration transition sent with an existing-chart overwrite. */
|
||||
export type AutomaticNormalizationTransition = { control: string } & (
|
||||
| PresentNormalizationValue<'from'>
|
||||
| MissingNormalizationValue<'from'>
|
||||
) &
|
||||
(PresentNormalizationValue<'to'> | MissingNormalizationValue<'to'>);
|
||||
|
||||
export type AutomaticNormalizationTransitions = Record<
|
||||
string,
|
||||
AutomaticNormalizationTransition
|
||||
>;
|
||||
|
||||
/** Identity-bound state for one chart hydration and its in-flight save. */
|
||||
export interface ChartNormalizationTrackingState {
|
||||
chartId: number;
|
||||
hydrationSessionId: string;
|
||||
transitions: AutomaticNormalizationTransitions;
|
||||
invalidatedControls: Record<string, true>;
|
||||
saveAttemptId: string | null;
|
||||
}
|
||||
|
||||
export interface VersionHistoryState {
|
||||
isPanelOpen: boolean;
|
||||
entityType: VersionedEntityType | null;
|
||||
@@ -267,6 +228,4 @@ export interface VersionHistoryState {
|
||||
* the one their page shows.
|
||||
*/
|
||||
lastRestoredEntityUuid: string | null;
|
||||
/** Advisory transitions for the active Explore chart hydration. */
|
||||
chartNormalization?: ChartNormalizationTrackingState | null;
|
||||
}
|
||||
|
||||
@@ -427,7 +427,7 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.deck_layers",
|
||||
action=lambda self, *args, **kwargs: (f"{self.__class__.__name__}.deck_layers"),
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def deck_layers(self, pk: int) -> Response:
|
||||
@@ -691,17 +691,13 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
|
||||
normalization_changes: object = item.pop("normalization_changes", None)
|
||||
|
||||
# Live version identifiers before the update (empty + query-free when
|
||||
# ``ENABLE_VERSIONING_CAPTURE`` is off, so this stays inert under the
|
||||
# kill-switch).
|
||||
old_info = current_entity_version_info(Slice, pk)
|
||||
|
||||
try:
|
||||
changed_model = UpdateChartCommand(
|
||||
pk, item, normalization_changes=normalization_changes
|
||||
).run()
|
||||
changed_model = UpdateChartCommand(pk, item).run()
|
||||
new_info = current_entity_version_info(
|
||||
Slice, changed_model.id, changed_model.uuid
|
||||
)
|
||||
|
||||
@@ -368,30 +368,6 @@ class ChartPutSchema(Schema):
|
||||
external_url = fields.String(allow_none=True, validate=utils.validate_external_url)
|
||||
tags = fields.List(fields.Integer(metadata={"description": tags_description}))
|
||||
uuid = fields.UUID(allow_none=True)
|
||||
normalization_changes: fields.Raw = fields.Raw(
|
||||
load_only=True,
|
||||
allow_none=True,
|
||||
metadata={
|
||||
"description": (
|
||||
"Optional advisory Explore hydration transitions used only to "
|
||||
"remove exact automatic normalization changes from human-readable "
|
||||
"version history. Invalid metadata is ignored."
|
||||
),
|
||||
"type": "array",
|
||||
"maxItems": 256,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["control", "from_present", "to_present"],
|
||||
"properties": {
|
||||
"control": {"type": "string", "maxLength": 256},
|
||||
"from_present": {"type": "boolean"},
|
||||
"from_value": {},
|
||||
"to_present": {"type": "boolean"},
|
||||
"to_value": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ChartGetDatasourceObjectDataResponseSchema(Schema):
|
||||
|
||||
@@ -44,15 +44,11 @@ from superset.commands.utils import (
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.extensions import db
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.tags.models import ObjectType
|
||||
from superset.utils import json
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
from superset.versioning.changes.normalization import (
|
||||
register_matching_normalization_context,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -64,16 +60,10 @@ def is_query_context_update(properties: dict[str, Any]) -> bool:
|
||||
|
||||
|
||||
class UpdateChartCommand(UpdateMixin, BaseCommand):
|
||||
def __init__(
|
||||
self,
|
||||
model_id: int,
|
||||
data: dict[str, Any],
|
||||
normalization_changes: object = None,
|
||||
) -> None:
|
||||
self._model_id: int = model_id
|
||||
self._properties: dict[str, Any] = data.copy()
|
||||
def __init__(self, model_id: int, data: dict[str, Any]):
|
||||
self._model_id = model_id
|
||||
self._properties = data.copy()
|
||||
self._model: Optional[Slice] = None
|
||||
self._normalization_changes: object = normalization_changes
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=ChartUpdateFailedError))
|
||||
def run(self) -> Model:
|
||||
@@ -88,15 +78,6 @@ class UpdateChartCommand(UpdateMixin, BaseCommand):
|
||||
self._properties["last_saved_at"] = datetime.now()
|
||||
self._properties["last_saved_by"] = g.user
|
||||
|
||||
if self._normalization_changes is not None and "params" in self._properties:
|
||||
register_matching_normalization_context(
|
||||
db.session,
|
||||
self._model.id,
|
||||
self._normalization_changes,
|
||||
self._model.params,
|
||||
self._properties["params"],
|
||||
)
|
||||
|
||||
return ChartDAO.update(self._model, self._properties)
|
||||
|
||||
def _validate_new_dashboard_access(
|
||||
|
||||
@@ -21,11 +21,7 @@ from flask import g
|
||||
from sqlalchemy.exc import NoResultFound
|
||||
|
||||
from superset.commands.tag.exceptions import TagNotFoundError
|
||||
from superset.commands.tag.utils import (
|
||||
current_user_can_modify_object,
|
||||
to_object_model,
|
||||
to_object_type,
|
||||
)
|
||||
from superset.commands.tag.utils import to_object_model, to_object_type
|
||||
from superset.daos.base import BaseDAO
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
@@ -349,6 +345,12 @@ class TagDAO(BaseDAO[Tag]):
|
||||
Returns:
|
||||
None.
|
||||
"""
|
||||
# Deferred: superset.commands.utils imports TagDAO, so a module-level
|
||||
# import here would be circular.
|
||||
from superset.commands.utils import ( # pylint: disable=import-outside-toplevel
|
||||
current_user_can_modify_object,
|
||||
)
|
||||
|
||||
tagged_objects = []
|
||||
if not tag:
|
||||
raise TagNotFoundError()
|
||||
|
||||
@@ -30,7 +30,6 @@ from superset.commands.temporary_cache.exceptions import (
|
||||
TemporaryCacheResourceNotFoundError,
|
||||
)
|
||||
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||
from superset.exceptions import SupersetTemplateException
|
||||
from superset.explore.form_data.schemas import FormDataPostSchema, FormDataPutSchema
|
||||
from superset.extensions import event_logger
|
||||
from superset.views.base_api import BaseSupersetApi, requires_json, statsd_metrics
|
||||
@@ -111,8 +110,6 @@ class ExploreFormDataRestApi(BaseSupersetApi):
|
||||
return self.response(403, message=str(ex))
|
||||
except TemporaryCacheResourceNotFoundError as ex:
|
||||
return self.response(404, message=str(ex))
|
||||
except SupersetTemplateException as ex:
|
||||
return self.response(ex.status, message=str(ex))
|
||||
|
||||
@expose("/form_data/<string:key>", methods=("PUT",))
|
||||
@protect()
|
||||
@@ -186,8 +183,6 @@ class ExploreFormDataRestApi(BaseSupersetApi):
|
||||
return self.response(403, message=str(ex))
|
||||
except TemporaryCacheResourceNotFoundError as ex:
|
||||
return self.response(404, message=str(ex))
|
||||
except SupersetTemplateException as ex:
|
||||
return self.response(ex.status, message=str(ex))
|
||||
|
||||
@expose("/form_data/<string:key>", methods=("GET",))
|
||||
@protect()
|
||||
@@ -239,8 +234,6 @@ class ExploreFormDataRestApi(BaseSupersetApi):
|
||||
return self.response(403, message=str(ex))
|
||||
except TemporaryCacheResourceNotFoundError as ex:
|
||||
return self.response(404, message=str(ex))
|
||||
except SupersetTemplateException as ex:
|
||||
return self.response(ex.status, message=str(ex))
|
||||
|
||||
@expose("/form_data/<string:key>", methods=("DELETE",))
|
||||
@protect()
|
||||
@@ -293,5 +286,3 @@ class ExploreFormDataRestApi(BaseSupersetApi):
|
||||
return self.response(403, message=str(ex))
|
||||
except TemporaryCacheResourceNotFoundError as ex:
|
||||
return self.response(404, message=str(ex))
|
||||
except SupersetTemplateException as ex:
|
||||
return self.response(ex.status, message=str(ex))
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
# under the License.
|
||||
from typing import Optional
|
||||
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.chart.exceptions import (
|
||||
ChartAccessDeniedError,
|
||||
@@ -35,7 +33,6 @@ from superset.commands.exceptions import (
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
from superset.daos.query import QueryDAO
|
||||
from superset.exceptions import SupersetTemplateException
|
||||
from superset.utils.core import DatasourceType
|
||||
|
||||
|
||||
@@ -56,13 +53,7 @@ def check_query_access(query_id: int) -> Optional[bool]:
|
||||
# Access checks below, no need to validate them twice as they can be expensive.
|
||||
query = QueryDAO.find_by_id(query_id, skip_base_filter=True)
|
||||
if query:
|
||||
try:
|
||||
security_manager.raise_for_access(query=query)
|
||||
except TemplateError as ex:
|
||||
# raise_for_access() Jinja-renders the query's SQL to resolve
|
||||
# the tables it touches; a malformed template surfaces here as
|
||||
# a raw jinja2 exception rather than a Superset one.
|
||||
raise SupersetTemplateException(str(ex)) from ex
|
||||
security_manager.raise_for_access(query=query)
|
||||
return True
|
||||
raise QueryNotFoundValidationError()
|
||||
|
||||
|
||||
@@ -50,7 +50,6 @@ from sqlalchemy import event
|
||||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||||
from sqlalchemy.orm import Session, SessionTransaction
|
||||
|
||||
from superset.versioning.changes.normalization import NORMALIZATION_CONTEXT_KEY
|
||||
from superset.versioning.changes.shadow_queries import (
|
||||
_dashboard_child_records_for_tx_from_shadows,
|
||||
_dataset_child_records_for_tx_from_shadows,
|
||||
@@ -218,7 +217,6 @@ def _reset_transaction_state(session: Session) -> None:
|
||||
session.info.pop(ACTION_META_KEY, None)
|
||||
session.info.pop(_INITIAL_STATES_KEY, None)
|
||||
session.info.pop(_FINALIZING_KEY, None)
|
||||
session.info.pop(NORMALIZATION_CONTEXT_KEY, None)
|
||||
|
||||
|
||||
def _reset_after_outer_transaction(
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
# 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.
|
||||
"""Types and bounds for chart normalization change summaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Final, NotRequired, TypeAlias, TypedDict, TypeGuard
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from superset.utils import json
|
||||
from superset.versioning.diff import ChangeRecord
|
||||
|
||||
JsonScalar: TypeAlias = None | bool | int | float | str
|
||||
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
|
||||
|
||||
MAX_NORMALIZATION_TRANSITIONS: Final[int] = 256
|
||||
MAX_CONTROL_NAME_BYTES: Final[int] = 256
|
||||
MAX_NORMALIZATION_METADATA_BYTES: Final[int] = 256 * 1024
|
||||
MAX_NORMALIZATION_VALUE_DEPTH: Final[int] = 20
|
||||
|
||||
NORMALIZATION_CONTEXT_KEY: Final[str] = "_versioning_chart_normalization_context"
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NormalizationTransitionPayload(TypedDict):
|
||||
"""Presence-aware transition received as advisory request metadata."""
|
||||
|
||||
control: str
|
||||
from_present: bool
|
||||
from_value: NotRequired[JsonValue]
|
||||
to_present: bool
|
||||
to_value: NotRequired[JsonValue]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NormalizationTransition:
|
||||
"""Validated top-level chart params transition."""
|
||||
|
||||
control: str
|
||||
from_present: bool
|
||||
from_value: JsonValue
|
||||
to_present: bool
|
||||
to_value: JsonValue
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NormalizationContext:
|
||||
"""Consume-once evidence scoped to one chart update operation."""
|
||||
|
||||
chart_id: int
|
||||
operation_token: str
|
||||
transitions: tuple[NormalizationTransition, ...]
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizationContextRegistry:
|
||||
"""Operation-token registry retained for the active transaction."""
|
||||
|
||||
contexts: dict[tuple[int, str], NormalizationContext]
|
||||
active_tokens: dict[int, str | None]
|
||||
|
||||
|
||||
class _InvalidNormalizationEnvelopeError(ValueError):
|
||||
"""Advisory metadata whose ambiguity requires rejecting all transitions."""
|
||||
|
||||
|
||||
def _json_depth(value: JsonValue) -> int:
|
||||
if isinstance(value, list):
|
||||
return 1 + max((_json_depth(item) for item in value), default=0)
|
||||
if isinstance(value, dict):
|
||||
return 1 + max((_json_depth(item) for item in value.values()), default=0)
|
||||
return 0
|
||||
|
||||
|
||||
def _is_json_value(value: object) -> TypeGuard[JsonValue]:
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return True
|
||||
if isinstance(value, list):
|
||||
return all(_is_json_value(item) for item in value)
|
||||
if isinstance(value, dict):
|
||||
return all(
|
||||
isinstance(key, str) and _is_json_value(item) for key, item in value.items()
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _json_equal(left: JsonValue, right: JsonValue) -> bool:
|
||||
"""Compare JSON values without Python's ``True == 1`` coercion."""
|
||||
if type(left) is not type(right):
|
||||
return False
|
||||
if isinstance(left, list) and isinstance(right, list):
|
||||
return len(left) == len(right) and all(
|
||||
_json_equal(a, b) for a, b in zip(left, right, strict=False)
|
||||
)
|
||||
if isinstance(left, dict) and isinstance(right, dict):
|
||||
return left.keys() == right.keys() and all(
|
||||
_json_equal(left[key], right[key]) for key in left
|
||||
)
|
||||
return left == right
|
||||
|
||||
|
||||
def _parse_normalization_transition(
|
||||
item: object,
|
||||
) -> NormalizationTransition | None:
|
||||
"""Parse one transition, skipping malformed entries without ambiguity."""
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
control: object = item.get("control")
|
||||
from_present: object = item.get("from_present")
|
||||
to_present: object = item.get("to_present")
|
||||
if (
|
||||
not isinstance(control, str)
|
||||
or not control
|
||||
or len(control.encode()) > MAX_CONTROL_NAME_BYTES
|
||||
or not isinstance(from_present, bool)
|
||||
or not isinstance(to_present, bool)
|
||||
):
|
||||
return None
|
||||
if (from_present != ("from_value" in item)) or (to_present != ("to_value" in item)):
|
||||
return None
|
||||
from_value: object = item.get("from_value")
|
||||
to_value: object = item.get("to_value")
|
||||
if not _is_json_value(from_value) or not _is_json_value(to_value):
|
||||
return None
|
||||
if (
|
||||
_json_depth(from_value) > MAX_NORMALIZATION_VALUE_DEPTH
|
||||
or _json_depth(to_value) > MAX_NORMALIZATION_VALUE_DEPTH
|
||||
):
|
||||
raise _InvalidNormalizationEnvelopeError
|
||||
return NormalizationTransition(
|
||||
control=control,
|
||||
from_present=from_present,
|
||||
from_value=from_value,
|
||||
to_present=to_present,
|
||||
to_value=to_value,
|
||||
)
|
||||
|
||||
|
||||
def sanitize_normalization_changes(
|
||||
raw: object,
|
||||
) -> tuple[NormalizationTransition, ...]:
|
||||
"""Return bounded valid entries, or no exclusions for an invalid envelope."""
|
||||
try:
|
||||
encoded: bytes = json.dumps(
|
||||
raw, ensure_ascii=False, separators=(",", ":")
|
||||
).encode()
|
||||
if (
|
||||
not isinstance(raw, list)
|
||||
or len(raw) > MAX_NORMALIZATION_TRANSITIONS
|
||||
or len(encoded) > MAX_NORMALIZATION_METADATA_BYTES
|
||||
):
|
||||
return ()
|
||||
transitions: list[NormalizationTransition] = []
|
||||
controls: set[str] = set()
|
||||
for item in raw:
|
||||
transition: NormalizationTransition | None = (
|
||||
_parse_normalization_transition(item)
|
||||
)
|
||||
if transition is None:
|
||||
continue
|
||||
if transition.control in controls:
|
||||
return ()
|
||||
controls.add(transition.control)
|
||||
transitions.append(transition)
|
||||
return tuple(transitions)
|
||||
except (
|
||||
_InvalidNormalizationEnvelopeError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
UnicodeError,
|
||||
RecursionError,
|
||||
):
|
||||
return ()
|
||||
|
||||
|
||||
def matching_normalization_context(
|
||||
chart_id: int,
|
||||
raw: object,
|
||||
before_params: dict[str, JsonValue],
|
||||
after_params: dict[str, JsonValue],
|
||||
) -> NormalizationContext | None:
|
||||
"""Match sanitized advisory transitions against exact params states."""
|
||||
matching: list[NormalizationTransition] = []
|
||||
for transition in sanitize_normalization_changes(raw):
|
||||
before_present: bool = transition.control in before_params
|
||||
after_present: bool = transition.control in after_params
|
||||
if (
|
||||
before_present != transition.from_present
|
||||
or after_present != transition.to_present
|
||||
):
|
||||
continue
|
||||
if before_present and not _json_equal(
|
||||
before_params[transition.control], transition.from_value
|
||||
):
|
||||
continue
|
||||
if after_present and not _json_equal(
|
||||
after_params[transition.control], transition.to_value
|
||||
):
|
||||
continue
|
||||
matching.append(transition)
|
||||
if not matching:
|
||||
return None
|
||||
return NormalizationContext(chart_id, str(uuid4()), tuple(matching))
|
||||
|
||||
|
||||
def register_matching_normalization_context(
|
||||
session: Session,
|
||||
chart_id: int,
|
||||
raw: object,
|
||||
before_params_json: str | bytes | bytearray | None,
|
||||
after_params_json: str | bytes | bytearray | None,
|
||||
) -> None:
|
||||
"""Validate and register advisory evidence for one chart update."""
|
||||
if raw is None:
|
||||
return
|
||||
try:
|
||||
before_params: object = json.loads(before_params_json or "{}")
|
||||
after_params: object = json.loads(after_params_json or "{}")
|
||||
if not isinstance(before_params, dict) or not isinstance(after_params, dict):
|
||||
return
|
||||
context: NormalizationContext | None = matching_normalization_context(
|
||||
chart_id, raw, before_params, after_params
|
||||
)
|
||||
if context is not None:
|
||||
store_normalization_context(session, context)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception(
|
||||
"Ignoring chart normalization metadata for chart id=%s", chart_id
|
||||
)
|
||||
|
||||
|
||||
def store_normalization_context(
|
||||
session: Session, context: NormalizationContext
|
||||
) -> None:
|
||||
"""Store one operation's evidence, invalidating ambiguous same-chart evidence."""
|
||||
registry: NormalizationContextRegistry = session.info.setdefault(
|
||||
NORMALIZATION_CONTEXT_KEY,
|
||||
NormalizationContextRegistry(contexts={}, active_tokens={}),
|
||||
)
|
||||
existing_token: str | None = registry.active_tokens.get(context.chart_id)
|
||||
if existing_token is not None:
|
||||
registry.contexts.pop((context.chart_id, existing_token), None)
|
||||
registry.active_tokens[context.chart_id] = None
|
||||
return
|
||||
if context.chart_id in registry.active_tokens:
|
||||
return
|
||||
registry.contexts[(context.chart_id, context.operation_token)] = context
|
||||
registry.active_tokens[context.chart_id] = context.operation_token
|
||||
|
||||
|
||||
def consume_normalization_context(
|
||||
session: Session, chart_id: int
|
||||
) -> NormalizationContext | None:
|
||||
"""Consume chart-scoped evidence at most once."""
|
||||
registry: NormalizationContextRegistry | None = session.info.get(
|
||||
NORMALIZATION_CONTEXT_KEY
|
||||
)
|
||||
if registry is None:
|
||||
return None
|
||||
operation_token: str | None = registry.active_tokens.pop(chart_id, None)
|
||||
if operation_token is None:
|
||||
return None
|
||||
return registry.contexts.pop((chart_id, operation_token), None)
|
||||
|
||||
|
||||
def filter_normalization_records(
|
||||
records: list[ChangeRecord], context: NormalizationContext | None
|
||||
) -> list[ChangeRecord]:
|
||||
"""Return a fresh readable diff with exact normalization controls omitted."""
|
||||
if context is None:
|
||||
return list(records)
|
||||
controls: set[str] = {transition.control for transition in context.transitions}
|
||||
return [
|
||||
record
|
||||
for record in records
|
||||
if not (
|
||||
len(record.path) >= 2
|
||||
and record.path[0] == "params"
|
||||
and record.path[1] in controls
|
||||
)
|
||||
]
|
||||
@@ -46,10 +46,6 @@ import sqlalchemy as sa
|
||||
from flask_appbuilder import Model
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from superset.versioning.changes.normalization import (
|
||||
consume_normalization_context,
|
||||
filter_normalization_records,
|
||||
)
|
||||
from superset.versioning.changes.table import version_changes_table
|
||||
from superset.versioning.diff import (
|
||||
cap_records,
|
||||
@@ -217,16 +213,6 @@ def bulk_insert_records(
|
||||
return
|
||||
rows = []
|
||||
for (entity_kind, entity_id), records in buffered.items():
|
||||
if entity_kind == "chart":
|
||||
try:
|
||||
records = filter_normalization_records(
|
||||
records, consume_normalization_context(session, entity_id)
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception(
|
||||
"version_changes: normalization filtering failed for chart id=%s",
|
||||
entity_id,
|
||||
)
|
||||
# Bound a single save's output: collapse field-level record explosions
|
||||
# and truncate over-large values before they hit version_changes.
|
||||
for seq, r in enumerate(cap_records(records)):
|
||||
|
||||
@@ -52,7 +52,6 @@ from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.utils import json as _json
|
||||
from tests.integration_tests.base_tests import SupersetTestCase
|
||||
from tests.integration_tests.constants import ADMIN_USERNAME
|
||||
from tests.integration_tests.fixtures.birth_names_dashboard import ( # noqa: F401
|
||||
load_birth_names_dashboard_with_slices,
|
||||
load_birth_names_data,
|
||||
@@ -164,142 +163,6 @@ class TestChartChangeRecords(SupersetTestCase):
|
||||
assert path == ["slice_name"]
|
||||
assert rows[0]["sequence"] == 0
|
||||
|
||||
def test_matching_hydration_metadata_omits_only_normalization_noise(
|
||||
self,
|
||||
) -> None:
|
||||
"""Readable history omits exact null/default and missing/default changes."""
|
||||
_persist_fixture_state()
|
||||
chart: Slice | None = db.session.query(Slice).first()
|
||||
assert chart is not None
|
||||
before_params: dict[str, Any] = {
|
||||
"viz_type": "table",
|
||||
"granularity_sqla": "ds",
|
||||
"row_limit": None,
|
||||
}
|
||||
after_params: dict[str, Any] = {
|
||||
"viz_type": "table",
|
||||
"granularity_sqla": None,
|
||||
"row_limit": 10000,
|
||||
"show_legend": True,
|
||||
}
|
||||
chart.params = _json.dumps(before_params)
|
||||
db.session.commit()
|
||||
|
||||
metadata: list[dict[str, Any]] = [
|
||||
{
|
||||
"control": "granularity_sqla",
|
||||
"from_present": True,
|
||||
"from_value": "ds",
|
||||
"to_present": True,
|
||||
"to_value": None,
|
||||
},
|
||||
{
|
||||
"control": "row_limit",
|
||||
"from_present": True,
|
||||
"from_value": None,
|
||||
"to_present": True,
|
||||
"to_value": 10000,
|
||||
},
|
||||
{
|
||||
"control": "show_legend",
|
||||
"from_present": False,
|
||||
"to_present": True,
|
||||
"to_value": True,
|
||||
},
|
||||
]
|
||||
updated_name: str = f"{chart.slice_name[:64]}_intentional"
|
||||
self.login(ADMIN_USERNAME)
|
||||
response: Any = self.client.put(
|
||||
f"/api/v1/chart/{chart.id}",
|
||||
json={
|
||||
"params": _json.dumps(after_params),
|
||||
"slice_name": updated_name,
|
||||
"normalization_changes": metadata,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.data
|
||||
response_body: dict[str, Any] = _json.loads(response.data)
|
||||
assert "normalization_changes" not in response_body["result"]
|
||||
db.session.refresh(chart)
|
||||
|
||||
ver_cls: Any = version_class(Slice)
|
||||
update_tx_id: int = (
|
||||
db.session.query(ver_cls.transaction_id)
|
||||
.filter(ver_cls.id == chart.id)
|
||||
.filter(ver_cls.operation_type == 1)
|
||||
.order_by(ver_cls.transaction_id.desc())
|
||||
.first()
|
||||
.transaction_id
|
||||
)
|
||||
rows: list[dict[str, Any]] = _change_rows_for(
|
||||
update_tx_id, entity_kind="chart", entity_id=chart.id
|
||||
)
|
||||
paths: list[list[str]] = [
|
||||
_json.loads(row["path"]) if isinstance(row["path"], str) else row["path"]
|
||||
for row in rows
|
||||
]
|
||||
assert paths == [["slice_name"]]
|
||||
assert _json.loads(chart.params) == after_params
|
||||
|
||||
def test_null_normalization_metadata_is_ignored_by_chart_put(self) -> None:
|
||||
"""Explicit null advisory metadata cannot reject an otherwise valid save."""
|
||||
_persist_fixture_state()
|
||||
chart: Slice | None = db.session.query(Slice).first()
|
||||
assert chart is not None
|
||||
self.login(ADMIN_USERNAME)
|
||||
response: Any = self.client.put(
|
||||
f"/api/v1/chart/{chart.id}",
|
||||
json={
|
||||
"slice_name": f"{chart.slice_name[:64]}_null_metadata",
|
||||
"normalization_changes": None,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.data
|
||||
|
||||
def test_stale_normalization_metadata_fails_open_through_chart_put(self) -> None:
|
||||
"""Mismatched advisory evidence preserves the real params change."""
|
||||
_persist_fixture_state()
|
||||
chart: Slice | None = db.session.query(Slice).first()
|
||||
assert chart is not None
|
||||
before_params: dict[str, Any] = {"viz_type": "table", "row_limit": 100}
|
||||
after_params: dict[str, Any] = {"viz_type": "table", "row_limit": 200}
|
||||
chart.params = _json.dumps(before_params)
|
||||
db.session.commit()
|
||||
self.login(ADMIN_USERNAME)
|
||||
response: Any = self.client.put(
|
||||
f"/api/v1/chart/{chart.id}",
|
||||
json={
|
||||
"params": _json.dumps(after_params),
|
||||
"normalization_changes": [
|
||||
{
|
||||
"control": "row_limit",
|
||||
"from_present": True,
|
||||
"from_value": 999,
|
||||
"to_present": True,
|
||||
"to_value": 200,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.data
|
||||
ver_cls: Any = version_class(Slice)
|
||||
transaction_id: int = (
|
||||
db.session.query(ver_cls.transaction_id)
|
||||
.filter(ver_cls.id == chart.id)
|
||||
.filter(ver_cls.operation_type == 1)
|
||||
.order_by(ver_cls.transaction_id.desc())
|
||||
.first()
|
||||
.transaction_id
|
||||
)
|
||||
rows: list[dict[str, Any]] = _change_rows_for(
|
||||
transaction_id, entity_kind="chart", entity_id=chart.id
|
||||
)
|
||||
paths: list[list[str]] = [
|
||||
_json.loads(row["path"]) if isinstance(row["path"], str) else row["path"]
|
||||
for row in rows
|
||||
]
|
||||
assert ["params", "row_limit"] in paths
|
||||
|
||||
def test_last_saved_at_is_excluded_as_audit_noise(self) -> None:
|
||||
"""``last_saved_at`` / ``last_saved_by_fk`` are save-side-effect
|
||||
fields stamped by ``UpdateChartCommand`` and must not produce
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
from jinja2.exceptions import TemplateSyntaxError
|
||||
from pytest import raises # noqa: PT013
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
@@ -31,7 +30,7 @@ from superset.commands.exceptions import (
|
||||
DatasourceNotFoundValidationError,
|
||||
QueryNotFoundValidationError,
|
||||
)
|
||||
from superset.exceptions import SupersetSecurityException, SupersetTemplateException
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.utils.core import DatasourceType, override_user
|
||||
|
||||
dataset_find_by_id = "superset.daos.dataset.DatasetDAO.find_by_id"
|
||||
@@ -341,28 +340,6 @@ def test_query_has_access(mocker: MockerFixture) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_query_malformed_jinja_template(mocker: MockerFixture) -> None:
|
||||
"""
|
||||
``raise_for_access(query=...)`` Jinja-renders the query's SQL to resolve
|
||||
the tables it touches. A malformed template must surface as a
|
||||
``SupersetTemplateException``, not the raw ``jinja2`` exception.
|
||||
"""
|
||||
from superset.explore.utils import check_datasource_access
|
||||
from superset.models.sql_lab import Query
|
||||
|
||||
mocker.patch(query_find_by_id, return_value=Query())
|
||||
mocker.patch(
|
||||
raise_for_access,
|
||||
side_effect=TemplateSyntaxError("unexpected end of template", lineno=1),
|
||||
)
|
||||
|
||||
with raises(SupersetTemplateException): # noqa: PT012
|
||||
check_datasource_access(
|
||||
datasource_id=1,
|
||||
datasource_type=DatasourceType.QUERY,
|
||||
)
|
||||
|
||||
|
||||
def test_query_no_access(mocker: MockerFixture, client) -> None:
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.explore.utils import check_datasource_access
|
||||
|
||||
@@ -323,7 +323,7 @@ def test_update_command_skips_removal_of_inaccessible_objects(
|
||||
side_effect=can_modify,
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.daos.tag.current_user_can_modify_object",
|
||||
"superset.commands.utils.current_user_can_modify_object",
|
||||
side_effect=can_modify,
|
||||
)
|
||||
|
||||
@@ -397,7 +397,7 @@ def test_update_command_empty_objects_to_tag_only_removes_accessible(
|
||||
side_effect=can_modify,
|
||||
)
|
||||
mocker.patch(
|
||||
"superset.daos.tag.current_user_can_modify_object",
|
||||
"superset.commands.utils.current_user_can_modify_object",
|
||||
side_effect=can_modify,
|
||||
)
|
||||
|
||||
|
||||
@@ -188,7 +188,6 @@ def test_terminal_event_clears_transaction_state(
|
||||
listener.ACTION_META_KEY: {"headline": "restored"},
|
||||
listener._INITIAL_STATES_KEY: {("chart", 7): object()},
|
||||
listener._FINALIZING_KEY: True,
|
||||
listener.NORMALIZATION_CONTEXT_KEY: {"pending": True},
|
||||
"unrelated": "preserved",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
# 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.
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from superset.versioning.changes.normalization import (
|
||||
consume_normalization_context,
|
||||
filter_normalization_records,
|
||||
matching_normalization_context,
|
||||
MAX_NORMALIZATION_TRANSITIONS,
|
||||
NormalizationContext,
|
||||
NormalizationTransition,
|
||||
sanitize_normalization_changes,
|
||||
store_normalization_context,
|
||||
)
|
||||
from superset.versioning.diff import ChangeRecord
|
||||
|
||||
|
||||
def _transition(
|
||||
control: str = "row_limit", from_value: object = None, to_value: object = 10000
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"control": control,
|
||||
"from_present": True,
|
||||
"from_value": from_value,
|
||||
"to_present": True,
|
||||
"to_value": to_value,
|
||||
}
|
||||
|
||||
|
||||
def test_sanitizer_preserves_missing_and_null_as_distinct_states() -> None:
|
||||
missing: dict[str, object] = {
|
||||
"control": "show_legend",
|
||||
"from_present": False,
|
||||
"to_present": True,
|
||||
"to_value": True,
|
||||
}
|
||||
null: dict[str, object] = _transition("row_limit")
|
||||
|
||||
transitions: tuple[NormalizationTransition, ...] = sanitize_normalization_changes(
|
||||
[missing, null]
|
||||
)
|
||||
|
||||
assert len(transitions) == 2
|
||||
assert not transitions[0].from_present
|
||||
assert transitions[1].from_present
|
||||
assert transitions[1].from_value is None
|
||||
|
||||
|
||||
def test_sanitizer_ignores_bad_entries_but_rejects_duplicate_envelope() -> None:
|
||||
assert len(sanitize_normalization_changes([{"bad": True}, _transition()])) == 1
|
||||
assert sanitize_normalization_changes([_transition(), _transition()]) == ()
|
||||
assert sanitize_normalization_changes({"not": "a list"}) == ()
|
||||
|
||||
|
||||
def test_sanitizer_rejects_bounded_envelope_failures() -> None:
|
||||
too_many: list[dict[str, object]] = [
|
||||
_transition(control=f"control_{index}")
|
||||
for index in range(MAX_NORMALIZATION_TRANSITIONS + 1)
|
||||
]
|
||||
deep_value: object = None
|
||||
for _index in range(22):
|
||||
deep_value = [deep_value]
|
||||
|
||||
assert sanitize_normalization_changes(too_many) == ()
|
||||
assert sanitize_normalization_changes([_transition(control="x" * 257)]) == ()
|
||||
assert sanitize_normalization_changes([_transition(from_value=deep_value)]) == ()
|
||||
assert sanitize_normalization_changes([object()]) == ()
|
||||
|
||||
|
||||
def test_matching_requires_exact_presence_and_json_value_types() -> None:
|
||||
raw: list[dict[str, object]] = [
|
||||
{
|
||||
"control": "show_legend",
|
||||
"from_present": False,
|
||||
"to_present": True,
|
||||
"to_value": True,
|
||||
},
|
||||
_transition(),
|
||||
]
|
||||
|
||||
context: NormalizationContext | None = matching_normalization_context(
|
||||
7, raw, {"row_limit": None}, {"show_legend": True, "row_limit": 10000}
|
||||
)
|
||||
|
||||
assert context is not None
|
||||
assert {item.control for item in context.transitions} == {
|
||||
"show_legend",
|
||||
"row_limit",
|
||||
}
|
||||
assert (
|
||||
matching_normalization_context(
|
||||
7,
|
||||
[_transition(from_value=True, to_value=2)],
|
||||
{"row_limit": 1},
|
||||
{"row_limit": 2},
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_filter_returns_fresh_records_without_matching_params_control() -> None:
|
||||
records: list[ChangeRecord] = [
|
||||
ChangeRecord("field", "edit", ["params", "row_limit"], None, 10000),
|
||||
ChangeRecord("field", "edit", ["slice_name"], "Old", "New"),
|
||||
]
|
||||
context: NormalizationContext | None = matching_normalization_context(
|
||||
7, [_transition()], {"row_limit": None}, {"row_limit": 10000}
|
||||
)
|
||||
|
||||
filtered: list[ChangeRecord] = filter_normalization_records(records, context)
|
||||
|
||||
assert filtered == [records[1]]
|
||||
assert filtered is not records
|
||||
|
||||
|
||||
def test_context_is_consumed_once_and_same_chart_ambiguity_fails_open() -> None:
|
||||
session: Session = Session()
|
||||
context: NormalizationContext | None = matching_normalization_context(
|
||||
7, [_transition()], {"row_limit": None}, {"row_limit": 10000}
|
||||
)
|
||||
assert context is not None
|
||||
store_normalization_context(session, context)
|
||||
assert consume_normalization_context(session, 7) == context
|
||||
assert consume_normalization_context(session, 7) is None
|
||||
|
||||
store_normalization_context(session, context)
|
||||
store_normalization_context(session, context)
|
||||
assert consume_normalization_context(session, 7) is None
|
||||
|
||||
|
||||
def test_drop_transition_matches_and_filters_a_remove_record() -> None:
|
||||
"""A stash-time drop (present -> absent) suppresses its remove record."""
|
||||
raw: list[dict[str, object]] = [
|
||||
{
|
||||
"control": "order_desc",
|
||||
"from_present": True,
|
||||
"from_value": True,
|
||||
"to_present": False,
|
||||
},
|
||||
]
|
||||
|
||||
context: NormalizationContext | None = matching_normalization_context(
|
||||
7, raw, {"order_desc": True, "row_limit": 100}, {"row_limit": 100}
|
||||
)
|
||||
|
||||
assert context is not None
|
||||
assert [item.control for item in context.transitions] == ["order_desc"]
|
||||
|
||||
records: list[ChangeRecord] = [
|
||||
ChangeRecord("field", "remove", ["params", "order_desc"], True, None),
|
||||
ChangeRecord("field", "edit", ["params", "row_limit"], 100, 50),
|
||||
]
|
||||
assert filter_normalization_records(records, context) == [records[1]]
|
||||
|
||||
|
||||
def test_drop_transition_requires_the_key_to_be_absent_after() -> None:
|
||||
"""A drop advisory does not match when the key survived the save."""
|
||||
raw: list[dict[str, object]] = [
|
||||
{
|
||||
"control": "order_desc",
|
||||
"from_present": True,
|
||||
"from_value": True,
|
||||
"to_present": False,
|
||||
},
|
||||
]
|
||||
assert (
|
||||
matching_normalization_context(
|
||||
7, raw, {"order_desc": True}, {"order_desc": False}
|
||||
)
|
||||
is None
|
||||
)
|
||||
Reference in New Issue
Block a user