Compare commits

..
Author SHA1 Message Date
Claude Code 1f0ccab62d fix(daos): correct broken import of current_user_can_modify_object
superset/daos/tag.py imported current_user_can_modify_object from
superset.commands.tag.utils, which has never defined or re-exported it -
the function only exists in superset.commands.utils. This broke app boot
entirely, since superset.commands.utils itself imports TagDAO, so any
request through ChartRestApi -> commands/chart/create.py ->
commands/utils.py -> daos/tag.py hit the bad import at module load time.

Importing it directly from commands.utils at module level would create a
circular import (commands.utils -> daos.tag -> commands.utils), so the
import is deferred to call time in create_tag_relationship, the one
place daos/tag.py needs it - matching the existing deferred-import
pattern used elsewhere in this codebase for the same reason.

Introduced by #43390.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 09:11:59 -07:00
28 changed files with 49 additions and 2036 deletions
@@ -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;
}
+2 -6
View File
@@ -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
)
-24
View File
@@ -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):
+3 -22
View File
@@ -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(
+7 -5
View File
@@ -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()
-9
View File
@@ -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))
+1 -10
View File
@@ -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()
-2
View File
@@ -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
)
]
-14
View File
@@ -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
+1 -24
View File
@@ -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
)