mirror of
https://github.com/apache/superset.git
synced 2026-08-29 11:31:16 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74f57cb0f2 | ||
|
|
5d399aeeca | ||
|
|
1c8d58a77b | ||
|
|
de33efae98 | ||
|
|
b30d569028 | ||
|
|
6f69fc6dba | ||
|
|
b2a8842551 | ||
|
|
c7cc22f4fe | ||
|
|
058eaf78ab | ||
|
|
30402b412c | ||
|
|
782a57fbfc | ||
|
|
99a1f756cd | ||
|
|
0eda633b45 | ||
|
|
5c24f72d92 | ||
|
|
60479fb958 |
+17
-8
@@ -24,14 +24,6 @@ updates:
|
||||
- dependency-name: "@types/react-dom"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "react-icons"
|
||||
# JSDOM v30 doesn't play well with Jest v30
|
||||
# Source: https://jestjs.io/blog#known-issues
|
||||
# GH thread: https://github.com/jsdom/jsdom/issues/3492
|
||||
- dependency-name: "jest-environment-jsdom"
|
||||
# `@swc/plugin-transform-imports` doesn't work with current Webpack-SWC hybrid setup
|
||||
# See https://github.com/apache/superset/pull/37384#issuecomment-3793991389
|
||||
# TODO: remove the plugin once Lodash usage has been migrated to a more readily tree-shakeable alternative
|
||||
- dependency-name: "@swc/plugin-transform-imports"
|
||||
# deck.gl and luma.gl share strict peer constraints across the root and
|
||||
# plugin workspaces, and root overrides pin their transitive versions.
|
||||
# Upgrade both families together in a manually validated change.
|
||||
@@ -87,6 +79,23 @@ updates:
|
||||
patterns:
|
||||
- "ag-grid-react"
|
||||
- "ag-grid-community"
|
||||
swc:
|
||||
patterns:
|
||||
- "@swc/core"
|
||||
- "@swc/plugin-emotion"
|
||||
- "@swc/plugin-transform-imports"
|
||||
jsonforms:
|
||||
patterns:
|
||||
- "@jsonforms/*"
|
||||
visx:
|
||||
patterns:
|
||||
- "@visx/*"
|
||||
emotion:
|
||||
patterns:
|
||||
- "@emotion/*"
|
||||
fontsource:
|
||||
patterns:
|
||||
- "@fontsource/*"
|
||||
open-pull-requests-limit: 30
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
|
||||
@@ -44,20 +44,12 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: write # to post the approving review via `gh pr review`
|
||||
steps:
|
||||
- name: Fetch Dependabot metadata
|
||||
id: metadata
|
||||
# This exact SHA is on ASF Infra's action allowlist
|
||||
# (apache/infrastructure-actions approved_patterns.yml) as of this
|
||||
# writing. Do not bump without opening an Infra ticket to allow
|
||||
# the new SHA first!
|
||||
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
|
||||
|
||||
# Dependabot automatically attaches semver labels (major, minor and patch) to its generated PRs
|
||||
- name: Approve patch-level bump
|
||||
if: steps.metadata.outputs.update-type == 'version-update:semver-patch'
|
||||
if: contains(github.event.pull_request.labels.*.name, 'patch')
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
DEPENDENCY_NAMES: ${{ steps.metadata.outputs.dependency-names }}
|
||||
run: |
|
||||
gh pr review --approve "$PR_URL" \
|
||||
--body "Auto-approved: patch-level bump only ($DEPENDENCY_NAMES)."
|
||||
--body "Auto-approved: patch-level bump only."
|
||||
|
||||
@@ -66,6 +66,27 @@ jobs:
|
||||
|
||||
- name: "Set up liccheck"
|
||||
run: |
|
||||
# liccheck (as of 0.9.2) still does a bare `import pkg_resources`
|
||||
# without declaring setuptools as a dependency, relying on it
|
||||
# having historically been bundled. setuptools 81+ (installed
|
||||
# above via requirements/base.txt) dropped the pkg_resources
|
||||
# subpackage entirely, so liccheck's own import breaks outright.
|
||||
#
|
||||
# Reinstalling an older setuptools would restore pkg_resources but
|
||||
# would also downgrade the *real* setuptools install, which then
|
||||
# trips liccheck's own working_set.resolve() -- it cross-checks
|
||||
# requirements/base.txt's declared `setuptools==84.0.0` against
|
||||
# what's actually installed, and a downgrade makes those disagree.
|
||||
#
|
||||
# Instead, vendor just the pkg_resources/ package files from an
|
||||
# old setuptools wheel into site-packages, leaving the real
|
||||
# setuptools install (and its dist-info metadata) untouched. This
|
||||
# gives liccheck an importable pkg_resources whose own working-set
|
||||
# scan still correctly reports the real installed setuptools
|
||||
# version, so no conflict is raised.
|
||||
pip download "setuptools<81" --no-deps -d /tmp/old-setuptools
|
||||
python -m zipfile -e /tmp/old-setuptools/setuptools-*.whl /tmp/old-setuptools-extracted/
|
||||
cp -r /tmp/old-setuptools-extracted/pkg_resources "$(python -c 'import site; print(site.getsitepackages()[0])')/"
|
||||
uv pip install --system liccheck
|
||||
- name: "Run liccheck"
|
||||
run: |
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
# under the License.
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=40.9.0", "wheel"]
|
||||
requires = ["setuptools>=84.0.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
|
||||
@@ -52,11 +52,11 @@ marshmallow-sqlalchemy>=1.5.0
|
||||
# needed for python 3.12 support
|
||||
openapi-schema-validator>=0.6.3
|
||||
|
||||
# Pin setuptools <81 until all dependencies migrate from pkg_resources to importlib.metadata
|
||||
# Pin setuptools <85 until all dependencies migrate from pkg_resources to importlib.metadata
|
||||
# pkg_resources is deprecated and will be removed in setuptools 81+ (around 2025-11-30)
|
||||
# Known affected packages: Preset's 'clients' package
|
||||
# See docs/docs/contributing/pkg-resources-migration.md for details
|
||||
setuptools<81
|
||||
setuptools<85
|
||||
|
||||
# google-auth 2.53+ dropped its transitive dependency on cachetools, which is
|
||||
# imported directly by superset.db_engine_specs.aws_iam. We declare cachetools
|
||||
|
||||
@@ -366,7 +366,7 @@ rpds-py==0.25.0
|
||||
# via
|
||||
# jsonschema
|
||||
# referencing
|
||||
setuptools==80.9.0
|
||||
setuptools==84.0.0
|
||||
# via -r requirements/base.in
|
||||
shillelagh==1.4.5
|
||||
# via apache-superset (pyproject.toml)
|
||||
|
||||
@@ -663,7 +663,7 @@ pillow==12.3.0
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
# matplotlib
|
||||
pip==25.1.1
|
||||
pip==26.2.1
|
||||
# via apache-superset
|
||||
platformdirs==4.3.8
|
||||
# via
|
||||
@@ -920,7 +920,7 @@ secretstorage==3.5.0
|
||||
# via keyring
|
||||
semver==3.0.4
|
||||
# via apache-superset-extensions-cli
|
||||
setuptools==80.9.0
|
||||
setuptools==84.0.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# nodeenv
|
||||
|
||||
@@ -77,7 +77,7 @@ module.exports = {
|
||||
// @ant-design/colors and @ant-design/fast-color are allowed through because
|
||||
// @ant-design/icons >= 6.3 deep-imports the ESM build of @ant-design/colors
|
||||
// from its CJS output, so babel-jest must transform those files.
|
||||
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge)',
|
||||
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format|selection)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|@x0k/.*|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|hastscript|refractor|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|character-reference-invalid|is-alphanumerical|is-alphabetical|is-decimal|is-hexadecimal|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact|@x0k/json-schema-merge|content-disposition)',
|
||||
],
|
||||
preset: 'ts-jest',
|
||||
transform: {
|
||||
|
||||
Generated
+8
-8
@@ -84,7 +84,7 @@
|
||||
"antd": "^6.6.1",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
"content-disposition": "^3.0.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.23",
|
||||
"dom-to-image-more": "^3.10.2",
|
||||
@@ -17451,12 +17451,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
|
||||
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-3.0.0.tgz",
|
||||
"integrity": "sha512-ZH/0Xs9rMIFWCOmGdmS9eHBTF62qqQYNz4nVjQhkdIO/a0fCP4UIM3mRz/wiqL0L14YgAz/1xio4OaSY4+ON/A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -30733,9 +30733,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.23",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
|
||||
"integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
|
||||
"version": "2.2.24",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
|
||||
"integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
"antd": "^6.6.1",
|
||||
"chrono-node": "^2.10.1",
|
||||
"classnames": "^2.2.5",
|
||||
"content-disposition": "^2.0.1",
|
||||
"content-disposition": "^3.0.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"dayjs": "^1.11.23",
|
||||
"dom-to-image-more": "^3.10.2",
|
||||
@@ -416,7 +416,7 @@
|
||||
"brace-expansion": ">=5.0.8"
|
||||
},
|
||||
"nanoid@>=3 <4": "3.3.18",
|
||||
"nwsapi": "^2.2.13",
|
||||
"nwsapi": "^2.2.24",
|
||||
"puppeteer": "^22.4.1",
|
||||
"tar": "^7.5.16",
|
||||
"typescript-json-schema": "^0.68.0",
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
QueryFormData,
|
||||
} from '../query';
|
||||
import type { JsonResponse } from '../connection';
|
||||
import type { MenuItem } from '../components/Menu';
|
||||
|
||||
/**
|
||||
* A function which returns text (or marked-up text)
|
||||
@@ -164,6 +165,13 @@ export interface SliceHeaderExtension {
|
||||
dashboardId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for extensions to the Slice Header more-options menu
|
||||
*/
|
||||
export interface SliceHeaderMenuExtension extends SliceHeaderExtension {
|
||||
sliceName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for extensions to Embed Modal
|
||||
*/
|
||||
@@ -262,6 +270,9 @@ export type Extensions = Partial<{
|
||||
'sqleditor.extension.form': ComponentType<SQLFormExtensionProps>;
|
||||
'sqleditor.extension.resultTable': ComponentType<SQLResultTableExtensionProps>;
|
||||
'dashboard.slice.header': ComponentType<SliceHeaderExtension>;
|
||||
'dashboard.slice.header.menu': (
|
||||
context: SliceHeaderMenuExtension,
|
||||
) => MenuItem[];
|
||||
'sqleditor.extension.customAutocomplete': (
|
||||
args: CustomAutoCompleteArgs,
|
||||
) => CustomAutocomplete[] | undefined;
|
||||
|
||||
@@ -462,13 +462,14 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
|
||||
// only take relevant page size options
|
||||
const pageSizeOptions = useMemo(() => {
|
||||
const getServerPagination = (n: number) => n <= rowCount;
|
||||
const getServerPagination = (n: number) =>
|
||||
n <= Math.max(rowCount, serverPageLength);
|
||||
return (
|
||||
serverPagination ? SERVER_PAGE_SIZE_OPTIONS : PAGE_SIZE_OPTIONS
|
||||
).filter(([n]) =>
|
||||
serverPagination ? getServerPagination(n) : n <= 2 * data.length,
|
||||
) as SizeOption[];
|
||||
}, [data.length, rowCount, serverPagination]);
|
||||
}, [data.length, rowCount, serverPageLength, serverPagination]);
|
||||
|
||||
const getValueRange = useCallback(
|
||||
function getValueRange(key: string, alignPositiveNegative: boolean) {
|
||||
|
||||
+135
-8
@@ -67,14 +67,23 @@ async function renderAndWait(props = mockedProps) {
|
||||
container = renderedContainer;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// A modal that wasn't handed an `etag` reads the dataset itself and can't save
|
||||
// until that lands, so tests must wait before acting on the Save button.
|
||||
async function waitForSaveEnabled() {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('datasource-modal-save')).toBeEnabled(),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
cleanup();
|
||||
renderAndWait();
|
||||
fetchMock.post(SAVE_ENDPOINT, SAVE_PAYLOAD);
|
||||
fetchMock.put(SAVE_DATASOURCE_ENDPOINT, {});
|
||||
fetchMock.get(GET_DATASOURCE_ENDPOINT, { result: {} });
|
||||
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
|
||||
renderAndWait();
|
||||
await waitForSaveEnabled();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
@@ -118,6 +127,7 @@ describe('DatasourceModal', () => {
|
||||
onDatasourceSave:
|
||||
onDatasourceSave as unknown as typeof mockedProps.onDatasourceSave,
|
||||
});
|
||||
await waitForSaveEnabled();
|
||||
const saveButton = screen.getByTestId('datasource-modal-save');
|
||||
fireEvent.click(saveButton);
|
||||
const okButton = await screen.findByRole('button', { name: 'Confirm' });
|
||||
@@ -151,6 +161,96 @@ describe('DatasourceModal', () => {
|
||||
putSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('sends the supplied etag as If-Match so a stale save is refused', async () => {
|
||||
cleanup();
|
||||
renderAndWait({ ...mockedProps, etag: '"v1"' } as typeof mockedProps);
|
||||
|
||||
fireEvent.click(screen.getByTestId('datasource-modal-save'));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const putCall = fetchMock.callHistory
|
||||
.calls()
|
||||
.find(call => call.options?.method === 'put');
|
||||
expect(
|
||||
new Headers(putCall?.options?.headers as HeadersInit).get('If-Match'),
|
||||
).toEqual('"v1"');
|
||||
});
|
||||
});
|
||||
|
||||
test('reads the etag from the dataset when the caller supplies none', async () => {
|
||||
cleanup();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
fetchMock.put(SAVE_DATASOURCE_ENDPOINT, {});
|
||||
fetchMock.get(GET_DATASOURCE_ENDPOINT, {
|
||||
body: { result: {} },
|
||||
headers: { ETag: '"v2"' },
|
||||
});
|
||||
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
|
||||
|
||||
renderAndWait();
|
||||
|
||||
// The form is seeded from the same read as the validator, so saving is
|
||||
// unavailable until it lands.
|
||||
expect(screen.getByTestId('datasource-modal-save')).toBeDisabled();
|
||||
await screen.findByTestId('datasource-editor');
|
||||
|
||||
fireEvent.click(screen.getByTestId('datasource-modal-save'));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const putCall = fetchMock.callHistory
|
||||
.calls()
|
||||
.find(call => call.options?.method === 'put');
|
||||
expect(
|
||||
new Headers(putCall?.options?.headers as HeadersInit).get('If-Match'),
|
||||
).toEqual('"v2"');
|
||||
});
|
||||
});
|
||||
|
||||
test('never saves unguarded while the validator read is in flight', async () => {
|
||||
cleanup();
|
||||
fetchMock.clearHistory().removeRoutes();
|
||||
fetchMock.put(SAVE_DATASOURCE_ENDPOINT, {});
|
||||
// A read that never resolves: the save path must stay closed rather than
|
||||
// fall through to an unconditional PUT.
|
||||
fetchMock.get(GET_DATASOURCE_ENDPOINT, new Promise(() => {}));
|
||||
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
|
||||
|
||||
renderAndWait();
|
||||
|
||||
const saveButton = await screen.findByTestId('datasource-modal-save');
|
||||
expect(saveButton).toBeDisabled();
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
expect(
|
||||
fetchMock.callHistory
|
||||
.calls()
|
||||
.find(call => call.options?.method === 'put'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('shows a conflict dialog instead of a generic error on 412', async () => {
|
||||
const putSpy = jest
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockRejectedValue(new Response('', { status: 412 }));
|
||||
|
||||
try {
|
||||
fireEvent.click(screen.getByTestId('datasource-modal-save'));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Confirm' }));
|
||||
|
||||
const conflictElements = await screen.findAllByText(
|
||||
'Dataset changed since you opened it',
|
||||
);
|
||||
expect(conflictElements.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.queryByText('Error saving dataset'),
|
||||
).not.toBeInTheDocument();
|
||||
} finally {
|
||||
putSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('shows sync columns checkbox when SQL changes', async () => {
|
||||
cleanup();
|
||||
const datasourceWithSQL = {
|
||||
@@ -163,15 +263,24 @@ describe('DatasourceModal', () => {
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
|
||||
<DatasourceModal
|
||||
{...mockedProps}
|
||||
datasource={datasourceWithSQL}
|
||||
etag='"v1"'
|
||||
/>,
|
||||
{ store, useRouter: true },
|
||||
);
|
||||
|
||||
// Update with modified SQL
|
||||
rerender(
|
||||
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
|
||||
<DatasourceModal
|
||||
{...mockedProps}
|
||||
datasource={modifiedDatasource}
|
||||
etag='"v1"'
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitForSaveEnabled();
|
||||
const saveButton = screen.getByTestId('datasource-modal-save');
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
@@ -208,15 +317,24 @@ describe('DatasourceModal', () => {
|
||||
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
|
||||
|
||||
const { rerender } = render(
|
||||
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
|
||||
<DatasourceModal
|
||||
{...mockedProps}
|
||||
datasource={datasourceWithSQL}
|
||||
etag='"v1"'
|
||||
/>,
|
||||
{ store, useRouter: true },
|
||||
);
|
||||
|
||||
// Update with modified SQL to trigger checkbox
|
||||
rerender(
|
||||
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
|
||||
<DatasourceModal
|
||||
{...mockedProps}
|
||||
datasource={modifiedDatasource}
|
||||
etag='"v1"'
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitForSaveEnabled();
|
||||
const saveButton = screen.getByTestId('datasource-modal-save');
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
@@ -269,15 +387,24 @@ describe('DatasourceModal', () => {
|
||||
fetchMock.get(GET_DATABASE_ENDPOINT, { result: [] });
|
||||
|
||||
const { rerender } = render(
|
||||
<DatasourceModal {...mockedProps} datasource={datasourceWithSQL} />,
|
||||
<DatasourceModal
|
||||
{...mockedProps}
|
||||
datasource={datasourceWithSQL}
|
||||
etag='"v1"'
|
||||
/>,
|
||||
{ store, useRouter: true },
|
||||
);
|
||||
|
||||
// Update with modified SQL to trigger checkbox
|
||||
rerender(
|
||||
<DatasourceModal {...mockedProps} datasource={modifiedDatasource} />,
|
||||
<DatasourceModal
|
||||
{...mockedProps}
|
||||
datasource={modifiedDatasource}
|
||||
etag='"v1"'
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitForSaveEnabled();
|
||||
const saveButton = screen.getByTestId('datasource-modal-save');
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
|
||||
+4
@@ -21,6 +21,7 @@ import {
|
||||
screen,
|
||||
fireEvent,
|
||||
act,
|
||||
waitFor,
|
||||
defaultStore as store,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import fetchMock from 'fetch-mock';
|
||||
@@ -72,6 +73,9 @@ test('DatasourceModal - should handle sync columns state without imperative moda
|
||||
render(<DatasourceModal {...mockedProps} />, { store });
|
||||
|
||||
const saveButton = screen.getByTestId('datasource-modal-save');
|
||||
// The modal fetches the current dataset version on open; save stays disabled
|
||||
// until that settles
|
||||
await waitFor(() => expect(saveButton).toBeEnabled());
|
||||
|
||||
// This should not throw any DOM errors
|
||||
await act(async () => {
|
||||
|
||||
@@ -33,12 +33,14 @@ import {
|
||||
Icons,
|
||||
Button,
|
||||
Checkbox,
|
||||
Loading,
|
||||
Modal,
|
||||
AsyncEsmComponent,
|
||||
} from '@superset-ui/core/components';
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import { ErrorMessageWithStackTrace } from 'src/components';
|
||||
import type { DatasetObject } from 'src/features/datasets/types';
|
||||
import { withCertificationFields } from '../utils';
|
||||
import { mapSubjectValuesToIds } from 'src/features/subjects/SubjectPicker';
|
||||
import type { DatasourceModalProps } from '../types';
|
||||
|
||||
@@ -91,12 +93,18 @@ export function buildExtraJsonObject(
|
||||
const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
addSuccessToast,
|
||||
datasource,
|
||||
etag,
|
||||
onDatasourceSave,
|
||||
onHide,
|
||||
show,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [currentDatasource, setCurrentDatasource] = useState(datasource);
|
||||
// SQL of the server snapshot the form started from. The caller's, unless
|
||||
// this modal read the dataset itself — then "did the SQL change?" has to be
|
||||
// asked against the snapshot the payload is actually built from.
|
||||
const [seededSql, setSeededSql] = useState<string | undefined>();
|
||||
const [versionEtag, setVersionEtag] = useState(etag);
|
||||
const [syncColumns, setSyncColumns] = useState(false);
|
||||
const currencies = useSelector<
|
||||
{
|
||||
@@ -111,6 +119,52 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
const [confirmModalOpen, setConfirmModalOpen] = useState(false);
|
||||
const [isLoadingDatasource, setIsLoadingDatasource] = useState(false);
|
||||
|
||||
// Callers that read the dataset themselves (the dataset list) hand down the
|
||||
// ETag of that read. The rest — Explore, where `datasource` comes from the
|
||||
// page's bootstrap state — read it here, and must seed the form from the
|
||||
// *same* response: a payload built from an older snapshot than the ETag
|
||||
// guarding it would still be accepted, and would still clobber.
|
||||
useEffect(() => {
|
||||
setVersionEtag(etag);
|
||||
if (etag || !show || !datasource.id) {
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
setIsLoadingDatasource(true);
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/dataset/${datasource.id}`,
|
||||
})
|
||||
.then(({ json, response }) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const seeded = {
|
||||
...datasource,
|
||||
...json.result,
|
||||
columns: withCertificationFields(json.result.columns),
|
||||
};
|
||||
setSeededSql(seeded.sql);
|
||||
setCurrentDatasource(seeded);
|
||||
setVersionEtag(response.headers.get('ETag') ?? undefined);
|
||||
})
|
||||
.catch(() => {
|
||||
// The read failed outright, so there is no fresher snapshot to edit
|
||||
// and no validator to send. Fall back to the caller's snapshot and an
|
||||
// unconditional save, which is what this modal did before the guard.
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoadingDatasource(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [datasource.id, etag, show]);
|
||||
const baselineSql = seededSql ?? datasource.sql;
|
||||
|
||||
const buildPayload = (datasource: Record<string, any>) => {
|
||||
const payload: Record<string, any> = {
|
||||
table_name: datasource.table_name,
|
||||
@@ -197,11 +251,13 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
await SupersetClient.put({
|
||||
endpoint: `/api/v1/dataset/${currentDatasource.id}?override_columns=${syncColumns}`,
|
||||
jsonPayload: buildPayload(currentDatasource),
|
||||
...(versionEtag ? { headers: { 'If-Match': versionEtag } } : {}),
|
||||
});
|
||||
|
||||
const { json } = await SupersetClient.get({
|
||||
const { json, response } = await SupersetClient.get({
|
||||
endpoint: `/api/v1/dataset/${currentDatasource?.id}`,
|
||||
});
|
||||
setVersionEtag(response.headers.get('ETag') ?? undefined);
|
||||
|
||||
addSuccessToast(t('The dataset has been saved'));
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
@@ -213,6 +269,19 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
onHide();
|
||||
} catch (response) {
|
||||
setIsSaving(false);
|
||||
if ((response as Response)?.status === 412) {
|
||||
modal.error({
|
||||
title: t('Dataset changed since you opened it'),
|
||||
okButtonProps: { danger: true, className: 'btn-danger' },
|
||||
content: t(
|
||||
'Someone else, or another one of your browser tabs, saved this ' +
|
||||
'dataset after you opened it. Saving now would undo those ' +
|
||||
'changes, so it was cancelled. Copy your edits, close this ' +
|
||||
'dialog, and reopen the dataset to reapply them.',
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const error = await getClientErrorObject(response);
|
||||
let errorResponse: SupersetError | undefined;
|
||||
let errorText: string | undefined;
|
||||
@@ -264,7 +333,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
here may affect other charts
|
||||
in undesirable ways.`)}
|
||||
/>
|
||||
{datasource.sql !== currentDatasource.sql && (
|
||||
{baselineSql !== currentDatasource.sql && (
|
||||
<div
|
||||
css={theme => ({
|
||||
marginBottom: theme.marginMD,
|
||||
@@ -298,14 +367,14 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
{t('Are you sure you want to save and apply changes?')}
|
||||
</div>
|
||||
),
|
||||
[currentDatasource.sql, datasource.sql, syncColumns],
|
||||
[currentDatasource.sql, baselineSql, syncColumns],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (datasource.sql !== currentDatasource.sql) {
|
||||
if (baselineSql !== currentDatasource.sql) {
|
||||
setSyncColumns(true);
|
||||
}
|
||||
}, [datasource.sql, currentDatasource.sql]);
|
||||
}, [baselineSql, currentDatasource.sql]);
|
||||
|
||||
const onClickSave = () => {
|
||||
setConfirmModalOpen(true);
|
||||
@@ -356,6 +425,7 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
onClick={onClickSave}
|
||||
disabled={
|
||||
isSaving ||
|
||||
isLoadingDatasource ||
|
||||
errors.length > 0 ||
|
||||
currentDatasource.is_managed_externally
|
||||
}
|
||||
@@ -381,14 +451,18 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
||||
}}
|
||||
draggable
|
||||
>
|
||||
<DatasourceEditor
|
||||
showLoadingForImport
|
||||
height={500}
|
||||
datasource={currentDatasource}
|
||||
onChange={onDatasourceChange}
|
||||
setIsEditing={setIsEditing}
|
||||
currencies={currencies}
|
||||
/>
|
||||
{isLoadingDatasource ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<DatasourceEditor
|
||||
showLoadingForImport
|
||||
height={500}
|
||||
datasource={currentDatasource}
|
||||
onChange={onDatasourceChange}
|
||||
setIsEditing={setIsEditing}
|
||||
currencies={currencies}
|
||||
/>
|
||||
)}
|
||||
{contextHolder}
|
||||
<Modal
|
||||
title={t('Confirm save')}
|
||||
|
||||
@@ -20,4 +20,5 @@ import ChangeDatasourceModal from './ChangeDatasourceModal';
|
||||
import DatasourceModal from './DatasourceModal';
|
||||
|
||||
export { ChangeDatasourceModal, DatasourceModal };
|
||||
export { withCertificationFields } from './utils';
|
||||
export type { DatasourceModalProps, ChangeDatasourceModalProps } from './types';
|
||||
|
||||
@@ -29,6 +29,12 @@ export interface DatasourceModalProps {
|
||||
addSuccessToast: (msg: string) => void;
|
||||
addDangerToast: (msg: string) => void;
|
||||
datasource: DatasetObject;
|
||||
/**
|
||||
* ETag of the dataset read the form was seeded from. Replayed as `If-Match`
|
||||
* on save so a stale form can't clobber a newer write. Fetched by the modal
|
||||
* when the caller doesn't already have one.
|
||||
*/
|
||||
etag?: string;
|
||||
onChange: () => {};
|
||||
onDatasourceSave: (datasource: object, errors?: Array<any>) => {};
|
||||
onHide: () => {};
|
||||
|
||||
@@ -27,6 +27,7 @@ import { nanoid } from 'nanoid';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { tn } from '@apache-superset/core/translation';
|
||||
import rison from 'rison';
|
||||
import type { ColumnObject } from 'src/features/datasets/types';
|
||||
|
||||
// Type definitions
|
||||
|
||||
@@ -248,3 +249,29 @@ export async function fetchSyncedColumns(
|
||||
const { json } = await SupersetClient.get({ endpoint, signal });
|
||||
return json as ColumnMetadata[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift each column's certification out of its `extra` JSON into the flat
|
||||
* fields the datasource editor binds to.
|
||||
*/
|
||||
export function withCertificationFields(columns: ColumnObject[] = []) {
|
||||
return columns.map(column => {
|
||||
// Malformed `extra` must not take out the whole column list, the way an
|
||||
// uncaught parse would — same fallback as `hydrateMetricExtra`.
|
||||
let parsedExtra;
|
||||
try {
|
||||
parsedExtra = JSON.parse(column.extra || '{}') || {};
|
||||
} catch {
|
||||
parsedExtra = {};
|
||||
}
|
||||
const {
|
||||
certification: { details = '', certified_by: certifiedBy = '' } = {},
|
||||
} = parsedExtra;
|
||||
return {
|
||||
...column,
|
||||
certification_details: details || '',
|
||||
certified_by: certifiedBy || '',
|
||||
is_certified: details || certifiedBy,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
+56
-1
@@ -23,7 +23,7 @@ import {
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { FeatureFlag, VizType } from '@superset-ui/core';
|
||||
import { FeatureFlag, VizType, getExtensionsRegistry } from '@superset-ui/core';
|
||||
import mockState from 'spec/fixtures/mockState';
|
||||
import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
|
||||
import downloadAsImage from 'src/utils/downloadAsImage';
|
||||
@@ -165,6 +165,9 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(document, 'fullscreenElement');
|
||||
// TypedRegistry has no remove(); reset to a no-op so a registered slot does
|
||||
// not leak into other tests (the empty array is guarded, so nothing injects).
|
||||
getExtensionsRegistry().set('dashboard.slice.header.menu', () => []);
|
||||
});
|
||||
|
||||
test('Should render', () => {
|
||||
@@ -173,6 +176,58 @@ test('Should render', () => {
|
||||
expect(screen.getByTestId(`slice_${SLICE_ID}-menu`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Injects dashboard.slice.header.menu items at the top of the menu', () => {
|
||||
getExtensionsRegistry().set('dashboard.slice.header.menu', () => [
|
||||
{ key: 'custom-ext', label: 'Custom Menu Extension' },
|
||||
]);
|
||||
renderWrapper();
|
||||
openMenu();
|
||||
|
||||
const injected = screen.getByText('Custom Menu Extension');
|
||||
expect(injected).toBeInTheDocument();
|
||||
// Sits above the built-in entries.
|
||||
const forceRefresh = screen.getByText('Force refresh');
|
||||
expect(
|
||||
injected.compareDocumentPosition(forceRefresh) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Injects nothing when dashboard.slice.header.menu returns no items', () => {
|
||||
getExtensionsRegistry().set('dashboard.slice.header.menu', () => []);
|
||||
renderWrapper();
|
||||
openMenu();
|
||||
|
||||
expect(screen.queryByText('Custom Menu Extension')).not.toBeInTheDocument();
|
||||
// The menu still renders its built-in entries unchanged (no dangling divider
|
||||
// is added since the empty array is guarded).
|
||||
expect(screen.getByText('Force refresh')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Menu survives a dashboard.slice.header.menu extension that throws', () => {
|
||||
getExtensionsRegistry().set('dashboard.slice.header.menu', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
renderWrapper();
|
||||
openMenu();
|
||||
|
||||
// The throw is isolated: the built-in menu still renders.
|
||||
expect(screen.getByText('Force refresh')).toBeInTheDocument();
|
||||
expect(screen.getByText('Enter fullscreen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Injects nothing when the extension returns a non-array', () => {
|
||||
getExtensionsRegistry().set(
|
||||
'dashboard.slice.header.menu',
|
||||
// JS registrations bypass the MenuItem[] type; a bad return must not crash.
|
||||
(() => undefined) as never,
|
||||
);
|
||||
renderWrapper();
|
||||
openMenu();
|
||||
|
||||
expect(screen.getByText('Force refresh')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Should render default props', () => {
|
||||
const props = createProps();
|
||||
|
||||
|
||||
@@ -34,11 +34,13 @@ import {
|
||||
isFeatureEnabled,
|
||||
FeatureFlag,
|
||||
getChartMetadataRegistry,
|
||||
getExtensionsRegistry,
|
||||
VizType,
|
||||
BinaryQueryObjectFilterClause,
|
||||
JsonObject,
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { css, useTheme, styled } from '@apache-superset/core/theme';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
@@ -165,6 +167,8 @@ const queueChartResize = () => {
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
|
||||
const SliceHeaderControls = (
|
||||
props: SliceHeaderControlsPropsWithRouter | SliceHeaderControlsProps,
|
||||
) => {
|
||||
@@ -514,6 +518,26 @@ const SliceHeaderControls = (
|
||||
},
|
||||
];
|
||||
|
||||
const sliceHeaderMenuExtension = extensionsRegistry.get(
|
||||
'dashboard.slice.header.menu',
|
||||
);
|
||||
if (sliceHeaderMenuExtension) {
|
||||
// Isolate the extension: a bad registration (throwing, or returning a
|
||||
// non-array) must not take down the whole dashboard render.
|
||||
try {
|
||||
const extensionItems = sliceHeaderMenuExtension({
|
||||
sliceId: slice.slice_id,
|
||||
sliceName: slice.slice_name,
|
||||
dashboardId,
|
||||
});
|
||||
if (Array.isArray(extensionItems) && extensionItems.length) {
|
||||
newMenuItems.unshift(...extensionItems, { type: 'divider' });
|
||||
}
|
||||
} catch (error) {
|
||||
logging.error('dashboard.slice.header.menu extension failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (slice.description) {
|
||||
newMenuItems.push({
|
||||
key: MenuKeys.ToggleChartDescription,
|
||||
|
||||
+9
-1
@@ -297,6 +297,12 @@ test('Click on Edit dataset', async () => {
|
||||
const props = createProps();
|
||||
fetchMock.removeRoute(getDbWithQuery);
|
||||
fetchMock.get(getDbWithQuery, { result: [] }, { name: getDbWithQuery });
|
||||
fetchMock.removeRoute(getDatasetWithAllMockRouteName);
|
||||
fetchMock.get(
|
||||
getDatasetWithAll,
|
||||
{ result: {} },
|
||||
{ name: getDatasetWithAllMockRouteName },
|
||||
);
|
||||
render(<DatasourceControl {...props} />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
@@ -307,7 +313,9 @@ test('Click on Edit dataset', async () => {
|
||||
await userEvent.click(screen.getByText('Edit dataset'));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('mock-datasource-editor')).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByTestId('mock-datasource-editor'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Edit dataset should be disabled when user is not admin', async () => {
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
} from 'src/views/CRUD/utils';
|
||||
import { SUBJECT_OPTION_FILTER_PROPS } from 'src/features/subjects/SubjectSelectLabel';
|
||||
import { SubjectPile } from 'src/features/subjects/SubjectPile';
|
||||
import { ColumnObject } from 'src/features/datasets/types';
|
||||
import { useListViewResource } from 'src/views/CRUD/hooks';
|
||||
import {
|
||||
ActionButton,
|
||||
@@ -62,6 +61,7 @@ import {
|
||||
} from '@superset-ui/core/components';
|
||||
import {
|
||||
DatasourceModal,
|
||||
withCertificationFields,
|
||||
GenericLink,
|
||||
ImportModal as ImportModelsModal,
|
||||
ModifiedInfo,
|
||||
@@ -496,6 +496,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
|
||||
const [datasetCurrentlyEditing, setDatasetCurrentlyEditing] =
|
||||
useState<Dataset | null>(null);
|
||||
const [datasetCurrentlyEditingEtag, setDatasetCurrentlyEditingEtag] =
|
||||
useState<string | undefined>();
|
||||
|
||||
const [datasetCurrentlyDuplicating, setDatasetCurrentlyDuplicating] =
|
||||
useState<VirtualDataset | null>(null);
|
||||
@@ -565,24 +567,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/dataset/${id}`,
|
||||
})
|
||||
.then(({ json = {} }) => {
|
||||
const addCertificationFields = json.result.columns.map(
|
||||
(column: ColumnObject) => {
|
||||
const {
|
||||
certification: {
|
||||
details = '',
|
||||
certified_by: certifiedBy = '',
|
||||
} = {},
|
||||
} = JSON.parse(column.extra || '{}') || {};
|
||||
return {
|
||||
...column,
|
||||
certification_details: details || '',
|
||||
certified_by: certifiedBy || '',
|
||||
is_certified: details || certifiedBy,
|
||||
};
|
||||
},
|
||||
.then(({ json = {}, response }) => {
|
||||
setDatasetCurrentlyEditingEtag(
|
||||
response.headers.get('ETag') ?? undefined,
|
||||
);
|
||||
json.result.columns = [...addCertificationFields];
|
||||
json.result.columns = withCertificationFields(json.result.columns);
|
||||
setDatasetCurrentlyEditing(json.result);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -1524,6 +1513,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
{datasetCurrentlyEditing && (
|
||||
<DatasourceModal
|
||||
datasource={datasetCurrentlyEditing}
|
||||
etag={datasetCurrentlyEditingEtag}
|
||||
onDatasourceSave={refreshData}
|
||||
onHide={closeDatasetEditModal}
|
||||
show
|
||||
|
||||
@@ -18,7 +18,6 @@ import logging
|
||||
|
||||
from flask import Response
|
||||
from flask_appbuilder.api import expose, protect, safe
|
||||
from flask_appbuilder.security.decorators import has_access_api
|
||||
|
||||
from superset.commands.dashboard.filter_state.create import CreateFilterStateCommand
|
||||
from superset.commands.dashboard.filter_state.delete import DeleteFilterStateCommand
|
||||
@@ -26,7 +25,6 @@ from superset.commands.dashboard.filter_state.get import GetFilterStateCommand
|
||||
from superset.commands.dashboard.filter_state.update import UpdateFilterStateCommand
|
||||
from superset.extensions import event_logger
|
||||
from superset.temporary_cache.api import TemporaryCacheRestApi
|
||||
from superset.views.base import api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,8 +46,6 @@ class DashboardFilterStateRestApi(TemporaryCacheRestApi):
|
||||
def get_delete_command(self) -> type[DeleteFilterStateCommand]:
|
||||
return DeleteFilterStateCommand
|
||||
|
||||
@api
|
||||
@has_access_api
|
||||
@expose("/<int:pk>/filter_state", methods=("POST",))
|
||||
@protect()
|
||||
@safe
|
||||
@@ -173,8 +169,6 @@ class DashboardFilterStateRestApi(TemporaryCacheRestApi):
|
||||
"""
|
||||
return super().post(pk)
|
||||
|
||||
@api
|
||||
@has_access_api
|
||||
@expose("/<int:pk>/filter_state/<string:key>", methods=("PUT",))
|
||||
@protect()
|
||||
@safe
|
||||
|
||||
@@ -29,7 +29,7 @@ from flask_appbuilder.api import expose, protect, rison as parse_rison, safe
|
||||
from flask_appbuilder.api.schemas import get_item_schema
|
||||
from flask_appbuilder.const import API_RESULT_RES_KEY, API_SELECT_COLUMNS_RIS_KEY
|
||||
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||
from flask_babel import ngettext
|
||||
from flask_babel import gettext as _, ngettext
|
||||
from jinja2.exceptions import TemplateError
|
||||
from marshmallow import ValidationError
|
||||
from sqlalchemy.orm.exc import MultipleResultsFound
|
||||
@@ -95,13 +95,20 @@ from superset.subjects.filters import FilterRelatedSubjects, subject_type_filter
|
||||
from superset.utils import json
|
||||
from superset.utils.core import parse_boolean_string, send_export_zip
|
||||
from superset.versioning.api_helpers import (
|
||||
current_entity_etag_uuid,
|
||||
concurrency_token_from,
|
||||
current_entity_version_info,
|
||||
entity_concurrency_token,
|
||||
get_version_endpoint,
|
||||
list_versions_endpoint,
|
||||
lock_entity_for_update,
|
||||
restore_version_endpoint,
|
||||
)
|
||||
from superset.versioning.etag import set_version_etag
|
||||
from superset.versioning.etag import (
|
||||
is_conditional_write,
|
||||
raise_for_stale_write,
|
||||
set_version_etag,
|
||||
StaleEntityError,
|
||||
)
|
||||
from superset.versioning.schemas import VersionListItemSchema
|
||||
from superset.views.base import DatasourceFilter
|
||||
from superset.views.base_api import (
|
||||
@@ -542,6 +549,14 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
schema:
|
||||
type: boolean
|
||||
name: override_columns
|
||||
- in: header
|
||||
schema:
|
||||
type: string
|
||||
name: If-Match
|
||||
description: >-
|
||||
Optional optimistic-concurrency guard. Pass the ``ETag`` returned
|
||||
by a prior read of this dataset; the update is rejected with 412
|
||||
if the dataset has changed since.
|
||||
requestBody:
|
||||
description: Dataset schema
|
||||
required: true
|
||||
@@ -618,6 +633,17 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
412:
|
||||
description: >-
|
||||
The dataset changed since the version identified by the
|
||||
request's ``If-Match`` header; the update was not applied.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
@@ -634,10 +660,32 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
|
||||
# Serialise conditional saves on this dataset: the guard below reads
|
||||
# the live version, the command writes, and the two must not interleave
|
||||
# with another request's. Only a conditional save pays for the lock; an
|
||||
# unconditional PUT behaves exactly as it did before the guard existed.
|
||||
if is_conditional_write():
|
||||
lock_entity_for_update(SqlaTable, pk)
|
||||
|
||||
# Live version identifiers before the update (empty + query-free when
|
||||
# ``ENABLE_VERSIONING_CAPTURE`` is off).
|
||||
old_info = current_entity_version_info(SqlaTable, pk)
|
||||
|
||||
try:
|
||||
raise_for_stale_write(concurrency_token_from(old_info))
|
||||
except StaleEntityError:
|
||||
return set_version_etag(
|
||||
self.response(
|
||||
412,
|
||||
message=_(
|
||||
"The dataset was changed by another user or browser tab "
|
||||
"after you opened it. Reopen it to pick up the latest "
|
||||
"version, then reapply your changes."
|
||||
),
|
||||
),
|
||||
concurrency_token_from(old_info),
|
||||
)
|
||||
|
||||
try:
|
||||
# Two commands, two commits, two Continuum transactions for an
|
||||
# ``override_columns`` save — deliberately NOT merged into one
|
||||
@@ -661,13 +709,13 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
new_info = current_entity_version_info(
|
||||
SqlaTable, changed_model.id, changed_model.uuid
|
||||
)
|
||||
etag_version_uuid = new_info.version_uuid
|
||||
etag_version_uuid = concurrency_token_from(new_info)
|
||||
if override_columns:
|
||||
RefreshDatasetCommand(pk).run()
|
||||
# The ETag must reflect the entity's *current live* version,
|
||||
# which after the refresh is the refresh's transaction —
|
||||
# re-read it rather than reusing the pre-refresh uuid.
|
||||
etag_version_uuid = current_entity_etag_uuid(
|
||||
etag_version_uuid = entity_concurrency_token(
|
||||
SqlaTable, changed_model.id, changed_model.uuid
|
||||
)
|
||||
response = self.response(
|
||||
@@ -1700,7 +1748,7 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
|
||||
|
||||
return set_version_etag(
|
||||
self.response(200, **response),
|
||||
current_entity_etag_uuid(SqlaTable, table.id, table.uuid),
|
||||
entity_concurrency_token(SqlaTable, table.id, table.uuid),
|
||||
)
|
||||
|
||||
@expose("/<int:pk>/drill_info/", methods=("GET",))
|
||||
|
||||
@@ -26,7 +26,7 @@ from collections import defaultdict, deque
|
||||
from datetime import datetime
|
||||
from re import Pattern
|
||||
from textwrap import dedent
|
||||
from typing import Any, cast, Optional, TYPE_CHECKING
|
||||
from typing import Any, Callable, cast, Optional, TYPE_CHECKING
|
||||
from urllib import parse
|
||||
|
||||
import pandas as pd
|
||||
@@ -182,6 +182,15 @@ class PrestoBaseEngineSpec(BaseEngineSpec, metaclass=ABCMeta):
|
||||
# which raises a query error. Use = true/false instead.
|
||||
use_equality_for_boolean_filters = True
|
||||
|
||||
# Presto/Trino's coordinator sends query results as JSON, which has no
|
||||
# literal for NaN/Infinity/-Infinity, so REAL/DOUBLE columns holding
|
||||
# those values arrive as quoted strings. Coerce them back to real
|
||||
# floats so numeric post-processing (e.g. a pivot's mean) doesn't choke
|
||||
# on a string value.
|
||||
column_type_mutators: dict[types.TypeEngine, Callable[[Any], Any]] = {
|
||||
types.FLOAT: lambda val: float(val) if isinstance(val, str) else val
|
||||
}
|
||||
|
||||
column_type_mappings = (
|
||||
(
|
||||
re.compile(r"^boolean.*", re.IGNORECASE),
|
||||
|
||||
@@ -22,7 +22,8 @@ import math
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, TYPE_CHECKING
|
||||
from decimal import Decimal
|
||||
from typing import Any, Callable, TYPE_CHECKING
|
||||
|
||||
import requests
|
||||
from flask import copy_current_request_context, ctx, current_app as app, Flask, g
|
||||
@@ -30,6 +31,7 @@ from flask_babel import gettext as __
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.engine.url import URL
|
||||
from sqlalchemy.exc import NoSuchTableError
|
||||
from sqlalchemy.types import DECIMAL, TypeEngine
|
||||
|
||||
from superset import cache_manager, db
|
||||
from superset.common.db_query_status import QueryStatus
|
||||
@@ -78,6 +80,15 @@ class TrinoEngineSpec(PrestoBaseEngineSpec):
|
||||
"$.oauth2_client_info.secret": "OAuth2 client secret",
|
||||
}
|
||||
|
||||
# Trino's DBAPI driver can return DECIMAL columns as plain strings
|
||||
# (e.g. when a value's precision/scale can't be inferred from the
|
||||
# column type alone), which later breaks numeric post-processing
|
||||
# (e.g. pivot with a mean aggregate). Coerce them back to Decimal.
|
||||
column_type_mutators: dict[TypeEngine, Callable[[Any], Any]] = {
|
||||
**PrestoBaseEngineSpec.column_type_mutators,
|
||||
DECIMAL: lambda val: Decimal(val) if isinstance(val, str) else val,
|
||||
}
|
||||
|
||||
# The full set of columns Trino's "<table>$partitions" exposes for an
|
||||
# Iceberg table. The real partition keys are nested in the "partition" ROW,
|
||||
# so none of these are user partition columns.
|
||||
|
||||
@@ -30,6 +30,7 @@ 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
|
||||
@@ -110,6 +111,8 @@ 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()
|
||||
@@ -183,6 +186,8 @@ 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()
|
||||
@@ -234,6 +239,8 @@ 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()
|
||||
@@ -286,3 +293,5 @@ 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,6 +16,8 @@
|
||||
# under the License.
|
||||
from typing import Optional
|
||||
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.chart.exceptions import (
|
||||
ChartAccessDeniedError,
|
||||
@@ -33,6 +35,7 @@ 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
|
||||
|
||||
|
||||
@@ -53,7 +56,13 @@ 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:
|
||||
security_manager.raise_for_access(query=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
|
||||
return True
|
||||
raise QueryNotFoundValidationError()
|
||||
|
||||
|
||||
@@ -4823,6 +4823,18 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
template_processor=template_processor
|
||||
)
|
||||
is_metric_filter = True
|
||||
elif (
|
||||
col_obj is None
|
||||
and isinstance(flt_col, str)
|
||||
and flt_col in adhoc_columns_by_label
|
||||
):
|
||||
sqla_col, _unused = self.adhoc_column_to_sqla(
|
||||
col=adhoc_columns_by_label[flt_col],
|
||||
template_processor=template_processor,
|
||||
)
|
||||
if isinstance(sqla_col, ColumnElement):
|
||||
applied_adhoc_filters_columns.append(flt_col)
|
||||
|
||||
filter_grain = flt.get("grain")
|
||||
|
||||
# Check if this filter should be skipped because it was handled in
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
# under the License.
|
||||
from typing import Any, Optional
|
||||
|
||||
import pandas as pd
|
||||
from flask_babel import gettext as _
|
||||
from pandas import DataFrame
|
||||
|
||||
@@ -26,6 +27,116 @@ from superset.utils.pandas_postprocessing.utils import (
|
||||
validate_column_args,
|
||||
)
|
||||
|
||||
_PERCENT_MODES = frozenset({"percent_row", "percent_col", "percent_total"})
|
||||
|
||||
# Aggregate operator names that produce additive results across groups —
|
||||
# the sum of the per-cell values equals the row/column/grand rollup the
|
||||
# database would compute over the underlying rows. ``show_values_as``
|
||||
# percent transforms divide each cell by that sum, so they are only
|
||||
# meaningful when the sum IS the rollup. For non-additive operators
|
||||
# (mean, median, min, max, etc.) the "percent of row" the exports would
|
||||
# show is not the "percent of row" the chart's DB rollup would show,
|
||||
# and the two disagree. See sadpandajoe's finding on #42976.
|
||||
_ADDITIVE_OPERATORS = frozenset({"sum", "nansum", "count", "count_nonzero"})
|
||||
|
||||
|
||||
def _div_preserving_nan(numerator: DataFrame, denominator: Any, axis: int) -> DataFrame:
|
||||
"""Divide ``numerator`` by ``denominator``, preserving NaN numerators.
|
||||
|
||||
A genuine SQL NULL numerator must stay NaN (rendered blank) rather than
|
||||
become ``0.0`` — matches the client-side #42810 semantics guarding
|
||||
against measured "0.0%" values for values that should stay blank.
|
||||
"""
|
||||
result = numerator.div(denominator, axis=axis)
|
||||
return result.mask(numerator.isna(), other=float("nan"))
|
||||
|
||||
|
||||
def _apply_percent_transform_to_group(g: DataFrame, mode: str) -> DataFrame:
|
||||
"""Apply a percent-of-{row,col,total} transform to a single-metric block.
|
||||
|
||||
Called both for the whole DataFrame when it holds one metric, and
|
||||
per-metric-group for MultiIndex / flat-multi-metric pivots. A zero or
|
||||
NaN denominator produces NaN cells rather than ``Infinity``/``NaN``
|
||||
from division-by-zero, matching the client's ``if (acc === null)
|
||||
return null`` guard in ``fractionOf``.
|
||||
"""
|
||||
if mode == "percent_row":
|
||||
row_totals = g.sum(axis=PandasAxis.COLUMN, skipna=True).replace(0, float("nan"))
|
||||
return _div_preserving_nan(g, row_totals, axis=PandasAxis.ROW)
|
||||
if mode == "percent_col":
|
||||
col_totals = g.sum(axis=PandasAxis.ROW, skipna=True).replace(0, float("nan"))
|
||||
return _div_preserving_nan(g, col_totals, axis=PandasAxis.COLUMN)
|
||||
# percent_total
|
||||
grand = g.sum(skipna=True).sum(skipna=True)
|
||||
if pd.isna(grand) or grand == 0:
|
||||
return g * float("nan")
|
||||
return _div_preserving_nan(g, grand, axis=PandasAxis.ROW)
|
||||
|
||||
|
||||
def _apply_show_values_as(df: DataFrame, mode: str) -> DataFrame:
|
||||
"""Divide each metric cell by the appropriate rollup total.
|
||||
|
||||
Mirrors the client-side ``fractionOf`` semantic in
|
||||
``plugin-chart-pivot-table/src/react-pivottable/utilities.ts:739``:
|
||||
|
||||
- ``percent_row``: cell / row-total (sum across the columns axis)
|
||||
- ``percent_col``: cell / column-total (sum across the rows axis)
|
||||
- ``percent_total``: cell / grand-total (sum of the metric block)
|
||||
|
||||
Per-metric isolation — the totals are computed *within each metric* so
|
||||
one metric's numerator is never divided by another metric's total,
|
||||
matching the client's ``metricAxis`` handling. This applies to both
|
||||
shapes ``pivot_table`` can produce:
|
||||
|
||||
- **MultiIndex columns** (level 0 = metric): iterate the level-0
|
||||
groups explicitly. Explicit iteration avoids the deprecated
|
||||
``df.groupby(level=0, axis=1)`` pattern (removed in pandas 3.x).
|
||||
- **Flat columns with >1 column** (multi-metric pivot with no
|
||||
``columns`` groupby — each column IS a metric): treat each column
|
||||
as its own single-column metric block.
|
||||
- **Flat columns with 1 column** (single-metric pivot with no
|
||||
``columns`` groupby): the whole block is one metric.
|
||||
|
||||
NULL preservation is *structural* only: cells that ``pivot_table``
|
||||
left as ``NaN`` because the (row, column) group had no input rows
|
||||
at all stay ``NaN`` in the output. Cells whose input rows all held
|
||||
SQL NULL values have already collapsed to ``0.0`` inside
|
||||
``pivot_table`` (pandas ``sum([NaN]) == 0``), so they render as
|
||||
``0%``, not blank — reconstructing "blank vs measured zero" from an
|
||||
aggregated value is not possible at this layer.
|
||||
"""
|
||||
if df.empty:
|
||||
# Empty pivots can carry an empty ``MultiIndex`` with zero level-0
|
||||
# groups; iterating and concatenating produces
|
||||
# ``ValueError: No objects to concatenate``. Nothing to transform.
|
||||
return df
|
||||
|
||||
is_multi_metric_wide = isinstance(df.columns, pd.MultiIndex)
|
||||
is_flat_multi_metric = not is_multi_metric_wide and df.shape[1] > 1
|
||||
|
||||
if is_multi_metric_wide:
|
||||
# Iterate level-0 groups explicitly (pandas-3-safe).
|
||||
metrics = df.columns.get_level_values(0).unique()
|
||||
parts = []
|
||||
for metric in metrics:
|
||||
block = df.xs(metric, axis=PandasAxis.COLUMN, level=0, drop_level=False)
|
||||
parts.append(_apply_percent_transform_to_group(block, mode))
|
||||
# ``concat`` along columns preserves the MultiIndex; reorder to
|
||||
# match the original column layout deterministically.
|
||||
combined = pd.concat(parts, axis=PandasAxis.COLUMN)
|
||||
return combined[df.columns]
|
||||
|
||||
if is_flat_multi_metric:
|
||||
# Each flat column is its own metric — process independently.
|
||||
parts = []
|
||||
for col in df.columns:
|
||||
block = df[[col]]
|
||||
parts.append(_apply_percent_transform_to_group(block, mode))
|
||||
return pd.concat(parts, axis=PandasAxis.COLUMN)[df.columns]
|
||||
|
||||
# Flat single-metric block — the whole DataFrame is one metric.
|
||||
return _apply_percent_transform_to_group(df, mode)
|
||||
|
||||
|
||||
def _restore_dropped_metric_columns(
|
||||
df: DataFrame,
|
||||
@@ -77,7 +188,7 @@ def _restore_dropped_metric_columns(
|
||||
|
||||
|
||||
@validate_column_args("index", "columns")
|
||||
def pivot( # pylint: disable=too-many-arguments
|
||||
def pivot( # pylint: disable=too-many-arguments # noqa: C901
|
||||
df: DataFrame,
|
||||
index: list[str],
|
||||
aggregates: dict[str, dict[str, Any]],
|
||||
@@ -88,6 +199,7 @@ def pivot( # pylint: disable=too-many-arguments
|
||||
combine_value_with_metric: bool = False,
|
||||
marginal_distributions: Optional[bool] = None,
|
||||
marginal_distribution_name: Optional[str] = None,
|
||||
show_values_as: Optional[str] = None,
|
||||
) -> DataFrame:
|
||||
"""
|
||||
Perform a pivot operation on a DataFrame.
|
||||
@@ -111,6 +223,13 @@ def pivot( # pylint: disable=too-many-arguments
|
||||
:param marginal_distributions: Add totals for row/column. Default to False
|
||||
:param marginal_distribution_name: Name of row/column with marginal distribution.
|
||||
Default to 'All'.
|
||||
:param show_values_as: Optional post-pivot transform that expresses each
|
||||
metric cell as a fraction of the row / column / grand total.
|
||||
One of ``"percent_row"``, ``"percent_col"``, ``"percent_total"`` or
|
||||
``None`` / ``"actual"`` (no-op, default). Mirrors the pivot chart's
|
||||
client-side ``fractionOf`` semantic so server-side rendering paths
|
||||
(CSV / XLSX exports, scheduled reports) can reproduce the browser
|
||||
output. See #42809.
|
||||
:return: A pivot table
|
||||
:raises InvalidPostProcessingError: If the request in incorrect
|
||||
"""
|
||||
@@ -123,6 +242,62 @@ def pivot( # pylint: disable=too-many-arguments
|
||||
_("Pivot operation must include at least one aggregate")
|
||||
)
|
||||
|
||||
# Fail fast on ``show_values_as`` misconfiguration *before* running the
|
||||
# (potentially expensive) ``pivot_table`` call: an unknown mode should
|
||||
# not silently perform a full pivot only to raise at the end, and the
|
||||
# ``marginal_distributions`` combination should reject before pandas
|
||||
# gets a chance to raise its own margins-related errors. ``None`` /
|
||||
# ``""`` / ``"actual"`` are the no-op sentinels — anything else must
|
||||
# be a known percent mode.
|
||||
percent_mode: Optional[str] = None
|
||||
if show_values_as not in (None, "", "actual"):
|
||||
if show_values_as not in _PERCENT_MODES:
|
||||
raise InvalidPostProcessingError(
|
||||
_(
|
||||
"Unsupported show_values_as value: %(mode)s. "
|
||||
"Expected one of: percent_row, percent_col, percent_total, actual.",
|
||||
mode=show_values_as,
|
||||
)
|
||||
)
|
||||
if marginal_distributions:
|
||||
# The pivot would carry an "All" margin row and/or column;
|
||||
# summing across the axis would double-count by including the
|
||||
# margin as part of its own denominator. Combining ``margins``
|
||||
# with ``show_values_as`` needs a first-class design (probably
|
||||
# computing percentages on the non-margin subset and then
|
||||
# re-inserting the margin totals as-is), which is out of scope
|
||||
# here. Reject explicitly rather than silently returning wrong
|
||||
# numbers.
|
||||
raise InvalidPostProcessingError(
|
||||
_(
|
||||
"show_values_as is not yet supported when "
|
||||
"marginal_distributions is enabled."
|
||||
)
|
||||
)
|
||||
# ``show_values_as`` divides each cell by the sum of its axis, so
|
||||
# it is only meaningful when that sum equals the rollup the DB
|
||||
# would compute. For non-additive aggregates (mean, median, min,
|
||||
# max, distinct count, …) the summed per-cell values are not the
|
||||
# row/column/grand rollup, and the exports would disagree with
|
||||
# the chart. Reject up front rather than emit numbers that mix
|
||||
# with the DB rollup incorrectly.
|
||||
non_additive = [
|
||||
name
|
||||
for name, cfg in aggregates.items()
|
||||
if not isinstance(cfg.get("operator"), str)
|
||||
or cfg["operator"] not in _ADDITIVE_OPERATORS
|
||||
]
|
||||
if non_additive:
|
||||
raise InvalidPostProcessingError(
|
||||
_(
|
||||
"show_values_as is only supported for additive aggregates "
|
||||
"(sum, count); got non-additive operator(s) for: "
|
||||
"%(metrics)s.",
|
||||
metrics=", ".join(non_additive),
|
||||
)
|
||||
)
|
||||
percent_mode = show_values_as
|
||||
|
||||
if columns and column_fill_value:
|
||||
df[columns] = df[columns].fillna(value=column_fill_value)
|
||||
|
||||
@@ -163,6 +338,19 @@ def pivot( # pylint: disable=too-many-arguments
|
||||
elif pivot_key_set and not df.empty:
|
||||
df = df.drop(df.columns.difference(pivot_key_set), axis=PandasAxis.COLUMN)
|
||||
|
||||
# Apply the ``show_values_as`` percent transform BEFORE the
|
||||
# ``combine_value_with_metric`` reshape, not after. The reshape below
|
||||
# swaps the column ``MultiIndex`` level order from ``(metric, category)``
|
||||
# to ``(category, metric)``; if the percent transform runs against the
|
||||
# post-reshape shape, its per-metric iteration
|
||||
# (``df.columns.get_level_values(0).unique()``) walks categories thinking
|
||||
# they are metrics, mixing metrics and producing wrong percentages. See
|
||||
# sadpandajoe's finding on #42976. Running percent first keeps the
|
||||
# per-metric-isolation invariant intact; the reshape then runs on the
|
||||
# already-normalized values without changing them further.
|
||||
if percent_mode is not None:
|
||||
df = _apply_show_values_as(df, percent_mode)
|
||||
|
||||
if combine_value_with_metric:
|
||||
# dropna=False preserves restored all-NaN metric rows that would otherwise
|
||||
# be silently dropped by stack's default dropna=True behavior.
|
||||
|
||||
@@ -86,16 +86,24 @@ ALLOWLIST_CUMULATIVE_FUNCTIONS = (
|
||||
|
||||
PROPHET_TIME_GRAIN_MAP: dict[str, str] = {
|
||||
TimeGrain.SECOND: "s",
|
||||
TimeGrain.FIVE_SECONDS: "5s",
|
||||
TimeGrain.THIRTY_SECONDS: "30s",
|
||||
TimeGrain.MINUTE: "min",
|
||||
TimeGrain.FIVE_MINUTES: "5min",
|
||||
TimeGrain.TEN_MINUTES: "10min",
|
||||
TimeGrain.FIFTEEN_MINUTES: "15min",
|
||||
TimeGrain.THIRTY_MINUTES: "30min",
|
||||
# An alternate ISO-8601 spelling of THIRTY_MINUTES that a number of engine
|
||||
# specs expose instead; the two denote the same interval.
|
||||
TimeGrain.HALF_HOUR: "30min",
|
||||
TimeGrain.HOUR: "h",
|
||||
TimeGrain.SIX_HOURS: "6h",
|
||||
TimeGrain.DAY: "D",
|
||||
TimeGrain.WEEK: "W",
|
||||
TimeGrain.MONTH: "ME" if _PANDAS_VERSION >= (2, 2) else "M",
|
||||
TimeGrain.QUARTER: "QE" if _PANDAS_VERSION >= (2, 2) else "Q",
|
||||
# An alternate ISO-8601 spelling of QUARTER, as with HALF_HOUR above.
|
||||
TimeGrain.QUARTER_YEAR: "QE" if _PANDAS_VERSION >= (2, 2) else "Q",
|
||||
TimeGrain.YEAR: "YE" if _PANDAS_VERSION >= (2, 2) else "A",
|
||||
TimeGrain.WEEK_STARTING_SUNDAY: "W-SUN",
|
||||
TimeGrain.WEEK_STARTING_MONDAY: "W-MON",
|
||||
|
||||
@@ -72,6 +72,11 @@ class EntityVersionInfo:
|
||||
version: int | None = None
|
||||
transaction_id: int | None = None
|
||||
version_uuid: str | None = None
|
||||
#: Resolved uuid of the entity itself, carried so callers that need a
|
||||
#: concurrency token for an entity with no version rows yet don't have to
|
||||
#: re-run the ``SELECT uuid`` this helper already issued. Not part of the
|
||||
#: API response.
|
||||
entity_uuid: UUID | None = None
|
||||
|
||||
|
||||
def _capture_enabled() -> bool:
|
||||
@@ -123,6 +128,7 @@ def current_entity_version_info(
|
||||
version=version,
|
||||
transaction_id=transaction_id,
|
||||
version_uuid=str(version_uuid) if version_uuid else None,
|
||||
entity_uuid=entity_uuid,
|
||||
)
|
||||
|
||||
|
||||
@@ -144,6 +150,77 @@ def current_entity_etag_uuid(
|
||||
return str(version_uuid) if version_uuid else None
|
||||
|
||||
|
||||
# Sentinel Continuum transaction id for an entity that has no version rows
|
||||
# yet. Continuum sequences start at 1, so it can never collide with a real
|
||||
# one, and the derived uuid stops matching the moment the first version row
|
||||
# lands — which is exactly the transition a concurrency guard must catch.
|
||||
_UNVERSIONED_TRANSACTION_ID = 0
|
||||
|
||||
|
||||
def unversioned_entity_token(entity_uuid: UUID) -> str:
|
||||
"""Concurrency token for an entity Continuum hasn't versioned yet."""
|
||||
return str(VersionDAO.derive_version_uuid(entity_uuid, _UNVERSIONED_TRANSACTION_ID))
|
||||
|
||||
|
||||
def entity_concurrency_token(
|
||||
model_cls: type[Model],
|
||||
entity_id: int | None,
|
||||
entity_uuid: UUID | None,
|
||||
) -> str | None:
|
||||
"""Resolve the optimistic-concurrency validator for *entity*.
|
||||
|
||||
Differs from :func:`current_entity_etag_uuid` in what it does for an
|
||||
entity with no version rows: baseline rows are written lazily, on the
|
||||
first update after the versioning migration, so a never-since-saved
|
||||
entity has none. Reporting ``None`` there would leave the *first*
|
||||
concurrent save on every such entity unguarded — the exact case a
|
||||
two-tab race hits on a pristine entity. Those entities get a
|
||||
deterministic unversioned token instead.
|
||||
|
||||
``None`` still means "no validator exists": capture is off, or the
|
||||
entity is missing.
|
||||
"""
|
||||
if entity_id is None or entity_uuid is None or not _capture_enabled():
|
||||
return None
|
||||
return current_entity_etag_uuid(
|
||||
model_cls, entity_id, entity_uuid
|
||||
) or unversioned_entity_token(entity_uuid)
|
||||
|
||||
|
||||
def lock_entity_for_update(model_cls: type[Model], entity_id: int | None) -> None:
|
||||
"""Row-lock *entity* so a conditional write's check and its update are atomic.
|
||||
|
||||
``If-Match`` is verified against a read taken before the update command
|
||||
runs. Without a lock two overlapping requests can both read the same live
|
||||
version, both pass the check, and then commit one after the other,
|
||||
reintroducing the lost update the check exists to prevent. The lock is
|
||||
held until the command commits, because both run in the same scoped
|
||||
session.
|
||||
|
||||
Renders no ``FOR UPDATE`` on SQLite, which serialises writers anyway.
|
||||
"""
|
||||
try:
|
||||
# The PUT route declares ``/<pk>`` (a string segment), so a non-numeric
|
||||
# id must not raise a SQL cast error ahead of the command's 404.
|
||||
entity_id = int(entity_id) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
db.session.execute(
|
||||
sa.select(model_cls.id).where(model_cls.id == entity_id).with_for_update()
|
||||
)
|
||||
|
||||
|
||||
def concurrency_token_from(info: EntityVersionInfo) -> str | None:
|
||||
"""Concurrency token for an already-resolved :class:`EntityVersionInfo`.
|
||||
|
||||
Lets a write endpoint reuse the pre-update version lookup it already
|
||||
made rather than issuing a second one.
|
||||
"""
|
||||
if info.entity_uuid is None:
|
||||
return None
|
||||
return info.version_uuid or unversioned_entity_token(info.entity_uuid)
|
||||
|
||||
|
||||
# Maps the versioned model class name to the keyword argument
|
||||
# ``security_manager.raise_for_access`` expects for the per-resource
|
||||
# gate. Slice → ``chart=``, Dashboard → ``dashboard=``, SqlaTable →
|
||||
|
||||
@@ -22,6 +22,7 @@ from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import request
|
||||
from flask_appbuilder import Model
|
||||
|
||||
from superset.extensions import db
|
||||
@@ -76,3 +77,46 @@ def set_version_etag_by_uuid(
|
||||
response,
|
||||
VersionDAO.current_live_version_uuid(model_cls, entity_id, entity_uuid),
|
||||
)
|
||||
|
||||
|
||||
class StaleEntityError(Exception):
|
||||
"""The request's ``If-Match`` doesn't match the entity's live version."""
|
||||
|
||||
|
||||
def _entity_tag(tag: str) -> str:
|
||||
"""Strip the content-coding suffix ``Flask-Compress`` appends to ETags.
|
||||
|
||||
A compressed response legitimately carries a different validator than the
|
||||
identity one — Flask-Compress rewrites ``"<uuid>"`` to ``"<uuid>:zstd"``
|
||||
(see ``flask_compress``) — so a client replaying the ETag it read never
|
||||
matches the raw version uuid. Version uuids contain no ``:``, so cutting
|
||||
at the first one recovers the entity identity from either form.
|
||||
"""
|
||||
return tag.split(":", 1)[0]
|
||||
|
||||
|
||||
def is_conditional_write() -> bool:
|
||||
"""Whether the request carries an ``If-Match`` precondition."""
|
||||
return bool(request.if_match)
|
||||
|
||||
|
||||
def raise_for_stale_write(current_version_uuid: str | None) -> None:
|
||||
"""Enforce ``If-Match`` on a write request, if the client sent one.
|
||||
|
||||
Clients that read an entity's ``ETag`` may replay it as ``If-Match`` on a
|
||||
subsequent write to get optimistic concurrency: the write is rejected when
|
||||
the entity moved on in the meantime, instead of silently clobbering
|
||||
whatever landed in between.
|
||||
|
||||
The condition is skipped — rather than failing closed — when the caller
|
||||
has no validator to offer (``ENABLE_VERSIONING_CAPTURE`` off). Failing
|
||||
closed there would block every conditional write on deployments running
|
||||
without version capture, and those are no worse off than before they sent
|
||||
the header.
|
||||
"""
|
||||
if_match = request.if_match
|
||||
if not if_match or if_match.star_tag or current_version_uuid is None:
|
||||
return
|
||||
live = _entity_tag(str(current_version_uuid))
|
||||
if not any(_entity_tag(tag) == live for tag in if_match.as_set(True)):
|
||||
raise StaleEntityError()
|
||||
|
||||
@@ -687,7 +687,6 @@ class Superset(BaseSupersetView):
|
||||
return json_success(json.dumps(sanitize_datasource_data(datasource.data)))
|
||||
|
||||
@event_logger.log_this
|
||||
@has_access
|
||||
@expose("/language_pack/<lang>/")
|
||||
def language_pack(self, lang: str) -> FlaskResponse:
|
||||
# Only allow expected language formats like "en", "pt_BR", etc.
|
||||
|
||||
@@ -1,295 +1,340 @@
|
||||
# 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 unittest.mock import patch # noqa: F401
|
||||
|
||||
import pytest
|
||||
from flask.ctx import AppContext
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
from sqlalchemy.orm import Session # noqa: F401
|
||||
|
||||
from superset import db
|
||||
from superset.commands.dashboard.exceptions import (
|
||||
DashboardAccessDeniedError, # noqa: F401
|
||||
)
|
||||
from superset.commands.temporary_cache.entry import Entry
|
||||
from superset.extensions import cache_manager
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.temporary_cache.utils import cache_key
|
||||
from superset.utils import json
|
||||
from tests.integration_tests.fixtures.world_bank_dashboard import (
|
||||
load_world_bank_dashboard_with_slices, # noqa: F401
|
||||
load_world_bank_data, # noqa: F401
|
||||
)
|
||||
from tests.integration_tests.test_app import app # noqa: F401
|
||||
|
||||
KEY = "test-key"
|
||||
INITIAL_VALUE = json.dumps({"test": "initial value"})
|
||||
UPDATED_VALUE = json.dumps({"test": "updated value"})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dashboard_id(app_context: AppContext, load_world_bank_dashboard_with_slices) -> int: # noqa: F811
|
||||
dashboard = db.session.query(Dashboard).filter_by(slug="world_health").one()
|
||||
return dashboard.id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_id(app_context: AppContext) -> int:
|
||||
admin = db.session.query(User).filter_by(username="admin").one_or_none()
|
||||
return admin.id
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cache(dashboard_id, admin_id):
|
||||
entry: Entry = {"owner": admin_id, "value": INITIAL_VALUE}
|
||||
cache_manager.filter_state_cache.set(cache_key(dashboard_id, KEY), entry)
|
||||
|
||||
|
||||
def test_post(test_client, login_as_admin, dashboard_id: int):
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state",
|
||||
json={
|
||||
"value": INITIAL_VALUE,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
def test_post_bad_request_non_string(test_client, login_as_admin, dashboard_id: int):
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state",
|
||||
json={
|
||||
"value": 1234,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_post_bad_request_non_json_string(
|
||||
test_client, login_as_admin, dashboard_id: int
|
||||
):
|
||||
payload = {
|
||||
"value": "foo",
|
||||
}
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_post_access_denied(test_client, login_as, dashboard_id: int):
|
||||
login_as("gamma")
|
||||
payload = {
|
||||
"value": INITIAL_VALUE,
|
||||
}
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_post_same_key_for_same_tab_id(test_client, login_as_admin, dashboard_id: int):
|
||||
payload = {
|
||||
"value": INITIAL_VALUE,
|
||||
}
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state?tab_id=1", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
first_key = data.get("key")
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state?tab_id=1", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
second_key = data.get("key")
|
||||
assert first_key == second_key
|
||||
|
||||
|
||||
def test_post_different_key_for_different_tab_id(
|
||||
test_client, login_as_admin, dashboard_id: int
|
||||
):
|
||||
payload = {
|
||||
"value": INITIAL_VALUE,
|
||||
}
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state?tab_id=1", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
first_key = data.get("key")
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state?tab_id=2", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
second_key = data.get("key")
|
||||
assert first_key != second_key
|
||||
|
||||
|
||||
def test_post_different_key_for_no_tab_id(
|
||||
test_client, login_as_admin, dashboard_id: int
|
||||
):
|
||||
payload = {
|
||||
"value": INITIAL_VALUE,
|
||||
}
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
first_key = data.get("key")
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
second_key = data.get("key")
|
||||
assert first_key != second_key
|
||||
|
||||
|
||||
def test_put(test_client, login_as_admin, dashboard_id: int):
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}",
|
||||
json={
|
||||
"value": UPDATED_VALUE,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_put_same_key_for_same_tab_id(test_client, login_as_admin, dashboard_id: int):
|
||||
payload = {
|
||||
"value": INITIAL_VALUE,
|
||||
}
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}?tab_id=1", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
first_key = data.get("key")
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}?tab_id=1", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
second_key = data.get("key")
|
||||
assert first_key == second_key
|
||||
|
||||
|
||||
def test_put_different_key_for_different_tab_id(
|
||||
test_client, login_as_admin, dashboard_id: int
|
||||
):
|
||||
payload = {
|
||||
"value": INITIAL_VALUE,
|
||||
}
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}?tab_id=1", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
first_key = data.get("key")
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}?tab_id=2", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
second_key = data.get("key")
|
||||
assert first_key != second_key
|
||||
|
||||
|
||||
def test_put_different_key_for_no_tab_id(
|
||||
test_client, login_as_admin, dashboard_id: int
|
||||
):
|
||||
payload = {
|
||||
"value": INITIAL_VALUE,
|
||||
}
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
first_key = data.get("key")
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
second_key = data.get("key")
|
||||
assert first_key != second_key
|
||||
|
||||
|
||||
def test_put_bad_request_non_string(test_client, login_as_admin, dashboard_id: int):
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}",
|
||||
json={
|
||||
"value": 1234,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_put_bad_request_non_json_string(
|
||||
test_client, login_as_admin, dashboard_id: int
|
||||
):
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}",
|
||||
json={
|
||||
"value": "foo",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_put_access_denied(test_client, login_as, dashboard_id: int):
|
||||
login_as("gamma")
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}",
|
||||
json={
|
||||
"value": UPDATED_VALUE,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_get_key_not_found(test_client, login_as_admin, dashboard_id: int):
|
||||
resp = test_client.get(f"api/v1/dashboard/{dashboard_id}/filter_state/unknown-key/")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_get_dashboard_not_found(test_client, login_as_admin):
|
||||
resp = test_client.get(f"api/v1/dashboard/{-1}/filter_state/{KEY}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_get_dashboard_filter_state(test_client, login_as_admin, dashboard_id: int):
|
||||
resp = test_client.get(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
|
||||
assert resp.status_code == 200
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
assert INITIAL_VALUE == data.get("value")
|
||||
|
||||
|
||||
def test_get_access_denied(test_client, login_as, dashboard_id):
|
||||
login_as("gamma")
|
||||
resp = test_client.get(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_delete(test_client, login_as_admin, dashboard_id: int):
|
||||
resp = test_client.delete(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_delete_access_denied(test_client, login_as, dashboard_id: int):
|
||||
login_as("gamma")
|
||||
resp = test_client.delete(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_delete_not_owner(test_client, login_as, dashboard_id: int):
|
||||
login_as("gamma")
|
||||
resp = test_client.delete(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
|
||||
assert resp.status_code == 404
|
||||
# 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 unittest.mock import patch # noqa: F401
|
||||
|
||||
import pytest
|
||||
from flask.ctx import AppContext
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
from sqlalchemy.orm import Session # noqa: F401
|
||||
|
||||
from superset import db
|
||||
from superset.commands.dashboard.exceptions import (
|
||||
DashboardAccessDeniedError, # noqa: F401
|
||||
)
|
||||
from superset.commands.temporary_cache.entry import Entry
|
||||
from superset.extensions import cache_manager
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.temporary_cache.utils import cache_key
|
||||
from superset.utils import json
|
||||
from tests.integration_tests.fixtures.world_bank_dashboard import (
|
||||
load_world_bank_dashboard_with_slices, # noqa: F401
|
||||
load_world_bank_data, # noqa: F401
|
||||
)
|
||||
from tests.integration_tests.test_app import app # noqa: F401
|
||||
|
||||
KEY = "test-key"
|
||||
INITIAL_VALUE = json.dumps({"test": "initial value"})
|
||||
UPDATED_VALUE = json.dumps({"test": "updated value"})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dashboard_id(app_context: AppContext, load_world_bank_dashboard_with_slices) -> int: # noqa: F811
|
||||
dashboard = db.session.query(Dashboard).filter_by(slug="world_health").one()
|
||||
return dashboard.id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_id(app_context: AppContext) -> int:
|
||||
admin = db.session.query(User).filter_by(username="admin").one_or_none()
|
||||
return admin.id
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cache(dashboard_id, admin_id):
|
||||
entry: Entry = {"owner": admin_id, "value": INITIAL_VALUE}
|
||||
cache_manager.filter_state_cache.set(cache_key(dashboard_id, KEY), entry)
|
||||
|
||||
|
||||
def test_post(test_client, login_as_admin, dashboard_id: int):
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state",
|
||||
json={
|
||||
"value": INITIAL_VALUE,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
def test_post_bad_request_non_string(test_client, login_as_admin, dashboard_id: int):
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state",
|
||||
json={
|
||||
"value": 1234,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_post_bad_request_non_json_string(
|
||||
test_client, login_as_admin, dashboard_id: int
|
||||
):
|
||||
payload = {
|
||||
"value": "foo",
|
||||
}
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_post_access_denied(test_client, login_as, dashboard_id: int):
|
||||
login_as("gamma")
|
||||
payload = {
|
||||
"value": INITIAL_VALUE,
|
||||
}
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_post_same_key_for_same_tab_id(test_client, login_as_admin, dashboard_id: int):
|
||||
payload = {
|
||||
"value": INITIAL_VALUE,
|
||||
}
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state?tab_id=1", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
first_key = data.get("key")
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state?tab_id=1", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
second_key = data.get("key")
|
||||
assert first_key == second_key
|
||||
|
||||
|
||||
def test_post_different_key_for_different_tab_id(
|
||||
test_client, login_as_admin, dashboard_id: int
|
||||
):
|
||||
payload = {
|
||||
"value": INITIAL_VALUE,
|
||||
}
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state?tab_id=1", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
first_key = data.get("key")
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state?tab_id=2", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
second_key = data.get("key")
|
||||
assert first_key != second_key
|
||||
|
||||
|
||||
def test_post_different_key_for_no_tab_id(
|
||||
test_client, login_as_admin, dashboard_id: int
|
||||
):
|
||||
payload = {
|
||||
"value": INITIAL_VALUE,
|
||||
}
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
first_key = data.get("key")
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
second_key = data.get("key")
|
||||
assert first_key != second_key
|
||||
|
||||
|
||||
def test_put(test_client, login_as_admin, dashboard_id: int):
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}",
|
||||
json={
|
||||
"value": UPDATED_VALUE,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_put_same_key_for_same_tab_id(test_client, login_as_admin, dashboard_id: int):
|
||||
payload = {
|
||||
"value": INITIAL_VALUE,
|
||||
}
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}?tab_id=1", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
first_key = data.get("key")
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}?tab_id=1", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
second_key = data.get("key")
|
||||
assert first_key == second_key
|
||||
|
||||
|
||||
def test_put_different_key_for_different_tab_id(
|
||||
test_client, login_as_admin, dashboard_id: int
|
||||
):
|
||||
payload = {
|
||||
"value": INITIAL_VALUE,
|
||||
}
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}?tab_id=1", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
first_key = data.get("key")
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}?tab_id=2", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
second_key = data.get("key")
|
||||
assert first_key != second_key
|
||||
|
||||
|
||||
def test_put_different_key_for_no_tab_id(
|
||||
test_client, login_as_admin, dashboard_id: int
|
||||
):
|
||||
payload = {
|
||||
"value": INITIAL_VALUE,
|
||||
}
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
first_key = data.get("key")
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}", json=payload
|
||||
)
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
second_key = data.get("key")
|
||||
assert first_key != second_key
|
||||
|
||||
|
||||
def test_put_bad_request_non_string(test_client, login_as_admin, dashboard_id: int):
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}",
|
||||
json={
|
||||
"value": 1234,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_put_bad_request_non_json_string(
|
||||
test_client, login_as_admin, dashboard_id: int
|
||||
):
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}",
|
||||
json={
|
||||
"value": "foo",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_put_access_denied(test_client, login_as, dashboard_id: int):
|
||||
login_as("gamma")
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}",
|
||||
json={
|
||||
"value": UPDATED_VALUE,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@patch("superset.commands.dashboard.filter_state.create.check_access")
|
||||
def test_post_authenticated_user_with_access(
|
||||
mock_check_access, test_client, login_as, dashboard_id: int
|
||||
):
|
||||
login_as("alpha")
|
||||
payload = {
|
||||
"value": INITIAL_VALUE,
|
||||
}
|
||||
resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
mock_check_access.assert_called_once_with(dashboard_id)
|
||||
|
||||
|
||||
@patch("superset.commands.dashboard.filter_state.create.check_access")
|
||||
@patch("superset.commands.dashboard.filter_state.update.check_access")
|
||||
def test_put_authenticated_user_with_access(
|
||||
mock_update_check_access,
|
||||
mock_create_check_access,
|
||||
test_client,
|
||||
login_as,
|
||||
dashboard_id: int,
|
||||
):
|
||||
login_as("alpha")
|
||||
payload = {
|
||||
"value": INITIAL_VALUE,
|
||||
}
|
||||
post_resp = test_client.post(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state", json=payload
|
||||
)
|
||||
assert post_resp.status_code == 201
|
||||
key = json.loads(post_resp.data.decode("utf-8"))["key"]
|
||||
|
||||
put_payload = {
|
||||
"value": UPDATED_VALUE,
|
||||
}
|
||||
resp = test_client.put(
|
||||
f"api/v1/dashboard/{dashboard_id}/filter_state/{key}", json=put_payload
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
mock_create_check_access.assert_called_once_with(dashboard_id)
|
||||
mock_update_check_access.assert_called_once_with(dashboard_id)
|
||||
|
||||
|
||||
def test_get_key_not_found(test_client, login_as_admin, dashboard_id: int):
|
||||
resp = test_client.get(f"api/v1/dashboard/{dashboard_id}/filter_state/unknown-key/")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_get_dashboard_not_found(test_client, login_as_admin):
|
||||
resp = test_client.get(f"api/v1/dashboard/{-1}/filter_state/{KEY}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_get_dashboard_filter_state(test_client, login_as_admin, dashboard_id: int):
|
||||
resp = test_client.get(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
|
||||
assert resp.status_code == 200
|
||||
data = json.loads(resp.data.decode("utf-8"))
|
||||
assert INITIAL_VALUE == data.get("value")
|
||||
|
||||
|
||||
def test_get_access_denied(test_client, login_as, dashboard_id):
|
||||
login_as("gamma")
|
||||
resp = test_client.get(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_delete(test_client, login_as_admin, dashboard_id: int):
|
||||
resp = test_client.delete(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_delete_access_denied(test_client, login_as, dashboard_id: int):
|
||||
login_as("gamma")
|
||||
resp = test_client.delete(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_delete_not_owner(test_client, login_as, dashboard_id: int):
|
||||
login_as("gamma")
|
||||
resp = test_client.delete(f"api/v1/dashboard/{dashboard_id}/filter_state/{KEY}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
@@ -1750,6 +1750,10 @@ class TestRolePermission(SupersetTestCase):
|
||||
# user/tenant data) as content-addressed scripts; must load for
|
||||
# anonymous principals (login page, embedded dashboards).
|
||||
["Superset", "language_pack_script"],
|
||||
# Language pack endpoint serves JS bundle translations, no auth
|
||||
# needed; embedded dashboards fetch this without a guest-token
|
||||
# header, so it must be reachable unauthenticated.
|
||||
["Superset", "language_pack"],
|
||||
]
|
||||
unsecured_views = []
|
||||
for view_class in appbuilder.baseviews:
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# 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.
|
||||
"""Unit tests for DashboardFilterStateRestApi."""
|
||||
|
||||
import inspect
|
||||
|
||||
from superset.commands.dashboard.filter_state.create import CreateFilterStateCommand
|
||||
from superset.commands.dashboard.filter_state.delete import DeleteFilterStateCommand
|
||||
from superset.commands.dashboard.filter_state.get import GetFilterStateCommand
|
||||
from superset.commands.dashboard.filter_state.update import UpdateFilterStateCommand
|
||||
from superset.dashboards.filter_state.api import DashboardFilterStateRestApi
|
||||
from superset.temporary_cache.api import TemporaryCacheRestApi
|
||||
|
||||
|
||||
def test_dashboard_filter_state_rest_api_inheritance():
|
||||
"""Ensure DashboardFilterStateRestApi correctly subclasses TemporaryCacheRestApi."""
|
||||
assert issubclass(DashboardFilterStateRestApi, TemporaryCacheRestApi)
|
||||
assert (
|
||||
DashboardFilterStateRestApi.class_permission_name
|
||||
== "DashboardFilterStateRestApi"
|
||||
)
|
||||
assert DashboardFilterStateRestApi.resource_name == "dashboard"
|
||||
assert DashboardFilterStateRestApi.openapi_spec_tag == "Dashboard Filter State"
|
||||
|
||||
|
||||
def test_dashboard_filter_state_command_factories():
|
||||
"""Ensure factory methods return the expected command classes."""
|
||||
api = DashboardFilterStateRestApi()
|
||||
assert api.get_create_command() is CreateFilterStateCommand
|
||||
assert api.get_update_command() is UpdateFilterStateCommand
|
||||
assert api.get_get_command() is GetFilterStateCommand
|
||||
assert api.get_delete_command() is DeleteFilterStateCommand
|
||||
|
||||
|
||||
def test_post_put_methods_have_no_has_access_api_or_api_decorator():
|
||||
"""
|
||||
Ensure post and put methods are not decorated with @has_access_api or @api.
|
||||
|
||||
Because DashboardFilterStateRestApi is a temporary cache API, permission
|
||||
verification is handled dynamically at the command level via
|
||||
CheckAccessDataCommand. @has_access_api causes 401 Unauthorized for regular
|
||||
users due to missing FAB permissions. The @api wrapper would catch and convert
|
||||
uncaught auth errors into 500s.
|
||||
"""
|
||||
source_post = inspect.getsource(DashboardFilterStateRestApi.post)
|
||||
source_put = inspect.getsource(DashboardFilterStateRestApi.put)
|
||||
|
||||
assert "has_access_api" not in source_post
|
||||
assert "has_access_api" not in source_put
|
||||
|
||||
assert not any(line.strip().startswith("@api") for line in source_post.splitlines())
|
||||
assert not any(line.strip().startswith("@api") for line in source_put.splitlines())
|
||||
@@ -217,6 +217,91 @@ def test_handle_filters_args_returns_request_scoped_filters(
|
||||
assert fresh_filters.get_joined_filters.call_count == 2
|
||||
|
||||
|
||||
def _create_dataset(name: str) -> Any:
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.models.core import Database
|
||||
|
||||
SqlaTable.metadata.create_all(db.session.get_bind())
|
||||
dataset = SqlaTable(
|
||||
table_name=name,
|
||||
database=Database(database_name=f"{name}_db", sqlalchemy_uri="sqlite://"),
|
||||
)
|
||||
db.session.add(dataset)
|
||||
db.session.flush()
|
||||
return dataset
|
||||
|
||||
|
||||
def test_put_dataset_rejects_stale_if_match(
|
||||
session: Session,
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
) -> None:
|
||||
"""
|
||||
A PUT carrying an ``If-Match`` from an older version is refused with 412.
|
||||
"""
|
||||
from superset.versioning.api_helpers import EntityVersionInfo
|
||||
|
||||
dataset = _create_dataset("test_put_stale_if_match")
|
||||
|
||||
with patch(
|
||||
"superset.datasets.api.current_entity_version_info",
|
||||
return_value=EntityVersionInfo(
|
||||
version=1,
|
||||
transaction_id=2,
|
||||
version_uuid="new",
|
||||
entity_uuid=dataset.uuid,
|
||||
),
|
||||
):
|
||||
response = client.put(
|
||||
f"/api/v1/dataset/{dataset.id}",
|
||||
json={"description": "from a stale tab"},
|
||||
headers={"If-Match": '"old"'},
|
||||
)
|
||||
|
||||
assert response.status_code == 412
|
||||
assert response.headers["ETag"] == '"new"'
|
||||
db.session.expire(dataset)
|
||||
assert dataset.description is None
|
||||
|
||||
|
||||
def test_put_dataset_guards_a_dataset_with_no_version_rows(
|
||||
session: Session,
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
) -> None:
|
||||
"""Baseline rows are written lazily on the first update, so a dataset that
|
||||
has never been saved has no version rows — it must still be guarded, or
|
||||
the first concurrent save on every pristine dataset goes unprotected.
|
||||
"""
|
||||
from superset.versioning.api_helpers import (
|
||||
EntityVersionInfo,
|
||||
unversioned_entity_token,
|
||||
)
|
||||
|
||||
dataset = _create_dataset("test_put_unversioned_guard")
|
||||
entity_uuid = dataset.uuid
|
||||
|
||||
with patch(
|
||||
"superset.datasets.api.current_entity_version_info",
|
||||
# A dataset that has since been versioned by another tab's save.
|
||||
return_value=EntityVersionInfo(
|
||||
version=0,
|
||||
transaction_id=1,
|
||||
version_uuid="written-by-the-other-tab",
|
||||
entity_uuid=entity_uuid,
|
||||
),
|
||||
):
|
||||
response = client.put(
|
||||
f"/api/v1/dataset/{dataset.id}",
|
||||
json={"description": "from the tab that opened first"},
|
||||
headers={"If-Match": f'"{unversioned_entity_token(entity_uuid)}"'},
|
||||
)
|
||||
|
||||
assert response.status_code == 412
|
||||
db.session.expire(dataset)
|
||||
assert dataset.description is None
|
||||
|
||||
|
||||
def test_get_dataset_exposes_certification_metadata(
|
||||
session: Session,
|
||||
client: Any,
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from unittest import mock
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import pytz
|
||||
@@ -90,6 +92,37 @@ def test_get_column_spec(
|
||||
assert_column_spec(spec, native_type, sqla_type, attrs, generic_type, is_dttm)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"string_value,expected_float",
|
||||
[
|
||||
("NaN", math.nan),
|
||||
("Infinity", math.inf),
|
||||
("-Infinity", -math.inf),
|
||||
],
|
||||
)
|
||||
def test_column_type_mutator_double_special_values(
|
||||
string_value: str, expected_float: float
|
||||
) -> None:
|
||||
"""
|
||||
Presto's coordinator sends results as JSON, which has no literal for
|
||||
NaN/Infinity/-Infinity, so REAL/DOUBLE columns holding those values
|
||||
arrive as quoted strings. They must be coerced back to real floats
|
||||
(inherited from PrestoBaseEngineSpec, shared with TrinoEngineSpec).
|
||||
"""
|
||||
from superset.db_engine_specs.presto import PrestoEngineSpec
|
||||
|
||||
mock_cursor = Mock()
|
||||
mock_cursor.fetchall.return_value = [[string_value]]
|
||||
mock_cursor.description = [("val", "double")]
|
||||
|
||||
(result_value,) = PrestoEngineSpec.fetch_data(mock_cursor)[0]
|
||||
assert isinstance(result_value, float)
|
||||
if math.isnan(expected_float):
|
||||
assert math.isnan(result_value)
|
||||
else:
|
||||
assert result_value == expected_float
|
||||
|
||||
|
||||
def test_get_schema_from_engine_params() -> None:
|
||||
"""
|
||||
Test the ``get_schema_from_engine_params`` method.
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import math
|
||||
from collections import namedtuple
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Optional
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
@@ -326,6 +328,96 @@ def test_convert_dttm(
|
||||
assert_convert_dttm(TrinoEngineSpec, target_type, expected_result, dttm)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data,description,expected_result",
|
||||
[
|
||||
(
|
||||
[["1.846619834", "abc"]],
|
||||
[("dec", "decimal(12,9)"), ("str", "varchar(3)")],
|
||||
[(Decimal("1.846619834"), "abc")],
|
||||
),
|
||||
(
|
||||
[[Decimal("1.846619834"), "abc"]],
|
||||
[("dec", "decimal(12,9)"), ("str", "varchar(3)")],
|
||||
[(Decimal("1.846619834"), "abc")],
|
||||
),
|
||||
(
|
||||
[["1.846619834", "abc"]],
|
||||
[("dec", "decimal(12)"), ("str", "varchar(3)")],
|
||||
[(Decimal("1.846619834"), "abc")],
|
||||
),
|
||||
(
|
||||
[["1.846619834", "abc"]],
|
||||
[("dec", "decimal"), ("str", "varchar(3)")],
|
||||
[(Decimal("1.846619834"), "abc")],
|
||||
),
|
||||
(
|
||||
[["1.846619834", "abc"]],
|
||||
[("dec", "varchar(255)"), ("str", "varchar(3)")],
|
||||
[["1.846619834", "abc"]],
|
||||
),
|
||||
(
|
||||
[["1.846619834", "abc"]],
|
||||
[("val", "double"), ("str", "varchar(3)")],
|
||||
[(1.846619834, "abc")],
|
||||
),
|
||||
(
|
||||
[[1.846619834, "abc"]],
|
||||
[("val", "real"), ("str", "varchar(3)")],
|
||||
[(1.846619834, "abc")],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_column_type_mutator(
|
||||
data: list[Any],
|
||||
description: list[Any],
|
||||
expected_result: list[Any],
|
||||
) -> None:
|
||||
"""
|
||||
Trino's DBAPI driver can return DECIMAL columns as plain strings.
|
||||
Superset must coerce those back to ``Decimal`` at fetch time so that
|
||||
downstream numeric post-processing (e.g. a pivot with a mean
|
||||
aggregate) doesn't choke on a string value.
|
||||
"""
|
||||
from superset.db_engine_specs.trino import TrinoEngineSpec
|
||||
|
||||
mock_cursor = Mock()
|
||||
mock_cursor.fetchall.return_value = data
|
||||
mock_cursor.description = description
|
||||
|
||||
assert TrinoEngineSpec.fetch_data(mock_cursor) == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"string_value,expected_float",
|
||||
[
|
||||
("NaN", math.nan),
|
||||
("Infinity", math.inf),
|
||||
("-Infinity", -math.inf),
|
||||
],
|
||||
)
|
||||
def test_column_type_mutator_double_special_values(
|
||||
string_value: str, expected_float: float
|
||||
) -> None:
|
||||
"""
|
||||
Trino's wire protocol has no JSON literal for NaN/Infinity/-Infinity, so
|
||||
REAL/DOUBLE columns holding those values arrive as quoted strings. They
|
||||
must be coerced back to real floats, same as string-typed DECIMALs.
|
||||
"""
|
||||
from superset.db_engine_specs.trino import TrinoEngineSpec
|
||||
|
||||
mock_cursor = Mock()
|
||||
mock_cursor.fetchall.return_value = [[string_value]]
|
||||
mock_cursor.description = [("val", "double")]
|
||||
|
||||
(result_value,) = TrinoEngineSpec.fetch_data(mock_cursor)[0]
|
||||
assert isinstance(result_value, float)
|
||||
if math.isnan(expected_float):
|
||||
assert math.isnan(result_value)
|
||||
else:
|
||||
assert result_value == expected_float
|
||||
|
||||
|
||||
def test_get_extra_table_metadata(mocker: MockerFixture) -> None:
|
||||
from superset.db_engine_specs.trino import TrinoEngineSpec
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
# 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
|
||||
|
||||
@@ -30,7 +31,7 @@ from superset.commands.exceptions import (
|
||||
DatasourceNotFoundValidationError,
|
||||
QueryNotFoundValidationError,
|
||||
)
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.exceptions import SupersetSecurityException, SupersetTemplateException
|
||||
from superset.utils.core import DatasourceType, override_user
|
||||
|
||||
dataset_find_by_id = "superset.daos.dataset.DatasetDAO.find_by_id"
|
||||
@@ -340,6 +341,28 @@ 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
|
||||
|
||||
@@ -5054,3 +5054,56 @@ def test_adhoc_type_probe_does_not_get_sampling_retry(
|
||||
table.adhoc_column_to_sqla(adhoc_col)
|
||||
|
||||
retry.assert_not_called()
|
||||
|
||||
|
||||
def test_filter_adhoc_column(database: Database) -> None:
|
||||
"""
|
||||
Test that filter works with adhoc column labels.
|
||||
When filter contains a string that matches the label of an adhoc column
|
||||
in the columns list, it should correctly convert to a SQLAlchemy column
|
||||
instead of raising QueryObjectValidationError.
|
||||
"""
|
||||
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
||||
|
||||
table = SqlaTable(
|
||||
table_name="test_table",
|
||||
database=database,
|
||||
columns=[
|
||||
TableColumn(column_name="name", type="TEXT"),
|
||||
TableColumn(column_name="real_name", type="TEXT"),
|
||||
],
|
||||
)
|
||||
|
||||
# Should not raise QueryObjectValidationError
|
||||
result = table.get_sqla_query(
|
||||
columns=[
|
||||
"name",
|
||||
{
|
||||
"expressionType": "SQL",
|
||||
"label": "full_name",
|
||||
"sqlExpression": "real_name",
|
||||
},
|
||||
],
|
||||
orderby=[],
|
||||
metrics=[],
|
||||
extras={},
|
||||
filter=[
|
||||
{"col": "full_name", "op": "ILIKE", "val": "Zona%"}
|
||||
], # Filter by adhoc column label
|
||||
granularity=None,
|
||||
is_timeseries=False,
|
||||
)
|
||||
assert result is not None
|
||||
|
||||
# Verify the WHERE predicate uses the resolved adhoc expression and value.
|
||||
with database.get_sqla_engine() as engine:
|
||||
sql = str(
|
||||
result.sqla_query.compile(
|
||||
dialect=engine.dialect,
|
||||
compile_kwargs={"literal_binds": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert "real_name AS full_name" in sql
|
||||
assert "WHERE" in sql
|
||||
assert "lower(real_name) LIKE lower('Zona%')" in sql
|
||||
|
||||
@@ -446,3 +446,428 @@ def test_pivot_only_entirely_absent_metrics_are_restored():
|
||||
assert ("metric_partial", "A") in df.columns
|
||||
assert ("metric_partial", "B") not in df.columns
|
||||
assert df[("metric_partial", "A")].iloc[0] == 1.0
|
||||
|
||||
|
||||
# --- show_values_as regression tests (#42809) --------------------------------
|
||||
#
|
||||
# ``show_values_as`` expresses each metric cell as a fraction of the row,
|
||||
# column, or grand total after pivoting. Mirrors the client-side
|
||||
# ``fractionOf`` semantic in
|
||||
# ``plugin-chart-pivot-table/src/react-pivottable/utilities.ts:739`` so
|
||||
# server-side rendering paths (CSV / XLSX exports, scheduled reports)
|
||||
# match the browser output. See #42809.
|
||||
#
|
||||
# Fixture: a tiny 3-column DataFrame that keeps row/col/grand totals easy
|
||||
# to eyeball. Two rows (``r1``, ``r2``), two columns (``c1``, ``c2``),
|
||||
# single metric ``v``. Grand total is 100 so every percent-of-total
|
||||
# assertion is trivially checkable.
|
||||
|
||||
|
||||
def _show_values_as_fixture() -> DataFrame:
|
||||
"""Long-format input that pivots to::
|
||||
|
||||
v
|
||||
col c1 c2
|
||||
row
|
||||
r1 10 20
|
||||
r2 30 40
|
||||
|
||||
row totals: r1=30, r2=70; col totals: c1=40, c2=60; grand=100.
|
||||
"""
|
||||
return DataFrame(
|
||||
{
|
||||
"row": ["r1", "r1", "r2", "r2"],
|
||||
"col": ["c1", "c2", "c1", "c2"],
|
||||
"v": [10, 20, 30, 40],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_pivot_show_values_as_actual_is_noop() -> None:
|
||||
"""``show_values_as='actual'`` (and ``None``) leaves values unchanged."""
|
||||
df = _show_values_as_fixture()
|
||||
aggregates = {"v": {"operator": "sum"}}
|
||||
baseline = pivot(df=df, index=["row"], columns=["col"], aggregates=aggregates)
|
||||
|
||||
for mode in (None, "actual"):
|
||||
result = pivot(
|
||||
df=df,
|
||||
index=["row"],
|
||||
columns=["col"],
|
||||
aggregates=aggregates,
|
||||
show_values_as=mode,
|
||||
)
|
||||
pd.testing.assert_frame_equal(result, baseline)
|
||||
|
||||
|
||||
def test_pivot_show_values_as_percent_row() -> None:
|
||||
"""Each cell = cell / row-total; each row sums to 1.0."""
|
||||
result = pivot(
|
||||
df=_show_values_as_fixture(),
|
||||
index=["row"],
|
||||
columns=["col"],
|
||||
aggregates={"v": {"operator": "sum"}},
|
||||
show_values_as="percent_row",
|
||||
)
|
||||
# r1: 10/30, 20/30; r2: 30/70, 40/70
|
||||
assert result.loc["r1", ("v", "c1")] == pytest.approx(10 / 30)
|
||||
assert result.loc["r1", ("v", "c2")] == pytest.approx(20 / 30)
|
||||
assert result.loc["r2", ("v", "c1")] == pytest.approx(30 / 70)
|
||||
assert result.loc["r2", ("v", "c2")] == pytest.approx(40 / 70)
|
||||
assert result.sum(axis=1).tolist() == pytest.approx([1.0, 1.0])
|
||||
|
||||
|
||||
def test_pivot_show_values_as_percent_col() -> None:
|
||||
"""Each cell = cell / column-total; each column sums to 1.0."""
|
||||
result = pivot(
|
||||
df=_show_values_as_fixture(),
|
||||
index=["row"],
|
||||
columns=["col"],
|
||||
aggregates={"v": {"operator": "sum"}},
|
||||
show_values_as="percent_col",
|
||||
)
|
||||
# c1 total=40: 10/40, 30/40; c2 total=60: 20/60, 40/60
|
||||
assert result.loc["r1", ("v", "c1")] == pytest.approx(10 / 40)
|
||||
assert result.loc["r2", ("v", "c1")] == pytest.approx(30 / 40)
|
||||
assert result.loc["r1", ("v", "c2")] == pytest.approx(20 / 60)
|
||||
assert result.loc["r2", ("v", "c2")] == pytest.approx(40 / 60)
|
||||
assert result.sum(axis=0).tolist() == pytest.approx([1.0, 1.0])
|
||||
|
||||
|
||||
def test_pivot_show_values_as_percent_total() -> None:
|
||||
"""Each cell = cell / grand-total; the whole frame sums to 1.0."""
|
||||
result = pivot(
|
||||
df=_show_values_as_fixture(),
|
||||
index=["row"],
|
||||
columns=["col"],
|
||||
aggregates={"v": {"operator": "sum"}},
|
||||
show_values_as="percent_total",
|
||||
)
|
||||
# grand=100: each cell divided by 100
|
||||
assert result.loc["r1", ("v", "c1")] == pytest.approx(0.10)
|
||||
assert result.loc["r1", ("v", "c2")] == pytest.approx(0.20)
|
||||
assert result.loc["r2", ("v", "c1")] == pytest.approx(0.30)
|
||||
assert result.loc["r2", ("v", "c2")] == pytest.approx(0.40)
|
||||
assert result.values.sum() == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_pivot_show_values_as_preserves_nan_numerator() -> None:
|
||||
"""A NaN/NULL numerator stays NaN — matches the client-side #42810 guard
|
||||
that a genuine SQL NULL should render blank, not "0.0%".
|
||||
|
||||
The fixture uses a **missing** (row, col) combination — ``r1`` has no
|
||||
``c2`` row — so ``pivot_table`` produces a genuine NaN cell for
|
||||
(``r1``, ``c2``). Using a ``NaN`` *input value* with ``operator='sum'``
|
||||
would not exercise this path because ``pandas`` ``.sum(skipna=True)``
|
||||
on a single-value ``[NaN]`` group returns ``0.0``, not ``NaN``.
|
||||
"""
|
||||
df = DataFrame(
|
||||
{
|
||||
"row": ["r1", "r2", "r2"], # r1 has no c2 row → post-pivot NaN
|
||||
"col": ["c1", "c1", "c2"],
|
||||
"v": [10, 30, 40],
|
||||
}
|
||||
)
|
||||
result = pivot(
|
||||
df=df,
|
||||
index=["row"],
|
||||
columns=["col"],
|
||||
aggregates={"v": {"operator": "sum"}},
|
||||
show_values_as="percent_row",
|
||||
)
|
||||
# The genuinely-NaN cell stays NaN through the percent transform.
|
||||
assert pd.isna(result.loc["r1", ("v", "c2")])
|
||||
# The other cell in the same row divides correctly against just its
|
||||
# own value (row total is 10 since c2 is NaN and skipna=True).
|
||||
assert result.loc["r1", ("v", "c1")] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_pivot_show_values_as_percent_total_zero_grand_total_yields_nan() -> None:
|
||||
"""Grand total of zero yields NaN cells rather than Infinity — matches the
|
||||
client's ``if (acc === null) return null`` division-by-zero guard."""
|
||||
df = DataFrame(
|
||||
{
|
||||
"row": ["r1", "r1", "r2", "r2"],
|
||||
"col": ["c1", "c2", "c1", "c2"],
|
||||
"v": [0, 0, 0, 0],
|
||||
}
|
||||
)
|
||||
result = pivot(
|
||||
df=df,
|
||||
index=["row"],
|
||||
columns=["col"],
|
||||
aggregates={"v": {"operator": "sum"}},
|
||||
show_values_as="percent_total",
|
||||
)
|
||||
# No cell should be Infinity or a real number; all should be NaN.
|
||||
assert result.isna().values.all()
|
||||
|
||||
|
||||
def test_pivot_show_values_as_percent_row_multi_metric_keeps_metrics_separate() -> None:
|
||||
"""On a multi-metric pivot (``MultiIndex`` columns), per-row totals are
|
||||
computed *within each metric*. Metric A's percentages must sum to 1.0
|
||||
per row independent of metric B's values."""
|
||||
df = DataFrame(
|
||||
{
|
||||
"row": ["r1", "r1", "r2", "r2"],
|
||||
"col": ["c1", "c2", "c1", "c2"],
|
||||
"a": [10, 20, 30, 40],
|
||||
"b": [1, 3, 5, 7],
|
||||
}
|
||||
)
|
||||
result = pivot(
|
||||
df=df,
|
||||
index=["row"],
|
||||
columns=["col"],
|
||||
aggregates={"a": {"operator": "sum"}, "b": {"operator": "sum"}},
|
||||
show_values_as="percent_row",
|
||||
)
|
||||
# Metric ``a``: row totals 30 and 70; each row of ``a`` sums to 1.
|
||||
assert result["a"].sum(axis=1).tolist() == pytest.approx([1.0, 1.0])
|
||||
# Metric ``b``: row totals 4 and 12; each row of ``b`` sums to 1.
|
||||
assert result["b"].sum(axis=1).tolist() == pytest.approx([1.0, 1.0])
|
||||
# Metric ``a`` percentages must not be contaminated by metric ``b`` values.
|
||||
assert result.loc["r1", ("a", "c1")] == pytest.approx(10 / 30)
|
||||
assert result.loc["r1", ("b", "c1")] == pytest.approx(1 / 4)
|
||||
|
||||
|
||||
def test_pivot_show_values_as_invalid_mode_raises() -> None:
|
||||
"""An unknown ``show_values_as`` value raises ``InvalidPostProcessingError``
|
||||
rather than silently falling through to a no-op."""
|
||||
with pytest.raises(InvalidPostProcessingError):
|
||||
pivot(
|
||||
df=_show_values_as_fixture(),
|
||||
index=["row"],
|
||||
columns=["col"],
|
||||
aggregates={"v": {"operator": "sum"}},
|
||||
show_values_as="percent_of_moon",
|
||||
)
|
||||
|
||||
|
||||
def test_pivot_show_values_as_empty_string_is_noop() -> None:
|
||||
"""Empty-string ``show_values_as`` is treated as a no-op alongside
|
||||
``None`` and ``"actual"`` — it must NOT reach the percent-mode
|
||||
validator (which would raise on it) or silently divide.
|
||||
"""
|
||||
df = _show_values_as_fixture()
|
||||
aggregates = {"v": {"operator": "sum"}}
|
||||
baseline = pivot(df=df, index=["row"], columns=["col"], aggregates=aggregates)
|
||||
|
||||
result = pivot(
|
||||
df=df,
|
||||
index=["row"],
|
||||
columns=["col"],
|
||||
aggregates=aggregates,
|
||||
show_values_as="",
|
||||
)
|
||||
pd.testing.assert_frame_equal(result, baseline)
|
||||
|
||||
|
||||
def test_pivot_show_values_as_percent_total_flat_multi_metric() -> None:
|
||||
"""A multi-metric pivot with **no** ``columns`` groupby produces a
|
||||
**flat** column index — each column IS its own metric. ``percent_total``
|
||||
must divide each metric column by its OWN grand total (never mixing
|
||||
metrics), otherwise one metric's magnitude changes another metric's
|
||||
percentages.
|
||||
"""
|
||||
df = DataFrame({"row": ["r1", "r2"], "a": [10, 30], "b": [1, 3]})
|
||||
result = pivot(
|
||||
df=df,
|
||||
index=["row"],
|
||||
aggregates={"a": {"operator": "sum"}, "b": {"operator": "sum"}},
|
||||
show_values_as="percent_total",
|
||||
)
|
||||
# Each metric column sums to 1.0 independently.
|
||||
assert result["a"].sum() == pytest.approx(1.0)
|
||||
assert result["b"].sum() == pytest.approx(1.0)
|
||||
# And metric a's magnitude (10, 30 → grand 40) doesn't leak into
|
||||
# metric b's percentages (which use grand 4).
|
||||
assert result.loc["r1", "a"] == pytest.approx(10 / 40)
|
||||
assert result.loc["r1", "b"] == pytest.approx(1 / 4)
|
||||
|
||||
|
||||
def test_pivot_show_values_as_percent_row_zero_row_total_yields_nan() -> None:
|
||||
"""A row whose values sum to zero yields NaN cells in that row rather
|
||||
than ``Infinity``/``NaN`` from division-by-zero. Other rows still
|
||||
divide correctly.
|
||||
"""
|
||||
df = DataFrame(
|
||||
{
|
||||
"row": ["r1", "r1", "r2", "r2"],
|
||||
"col": ["c1", "c2", "c1", "c2"],
|
||||
"v": [0, 0, 30, 40], # r1's row-total is 0
|
||||
}
|
||||
)
|
||||
result = pivot(
|
||||
df=df,
|
||||
index=["row"],
|
||||
columns=["col"],
|
||||
aggregates={"v": {"operator": "sum"}},
|
||||
show_values_as="percent_row",
|
||||
)
|
||||
assert pd.isna(result.loc["r1", ("v", "c1")])
|
||||
assert pd.isna(result.loc["r1", ("v", "c2")])
|
||||
# r2 still divides correctly against its own row-total (70).
|
||||
assert result.loc["r2", ("v", "c1")] == pytest.approx(30 / 70)
|
||||
|
||||
|
||||
def test_pivot_show_values_as_percent_col_zero_col_total_yields_nan() -> None:
|
||||
"""A column whose values sum to zero yields NaN cells in that column
|
||||
rather than ``Infinity``/``NaN`` from division-by-zero. Other columns
|
||||
still divide correctly.
|
||||
"""
|
||||
df = DataFrame(
|
||||
{
|
||||
"row": ["r1", "r1", "r2", "r2"],
|
||||
"col": ["c1", "c2", "c1", "c2"],
|
||||
"v": [0, 20, 0, 40], # c1's column-total is 0
|
||||
}
|
||||
)
|
||||
result = pivot(
|
||||
df=df,
|
||||
index=["row"],
|
||||
columns=["col"],
|
||||
aggregates={"v": {"operator": "sum"}},
|
||||
show_values_as="percent_col",
|
||||
)
|
||||
assert pd.isna(result.loc["r1", ("v", "c1")])
|
||||
assert pd.isna(result.loc["r2", ("v", "c1")])
|
||||
# c2 still divides correctly against its own column-total (60).
|
||||
assert result.loc["r1", ("v", "c2")] == pytest.approx(20 / 60)
|
||||
|
||||
|
||||
def test_pivot_show_values_as_with_marginal_distributions_raises() -> None:
|
||||
"""``show_values_as`` combined with ``marginal_distributions`` would
|
||||
include the ``All`` margin row/column in the row/column/grand-total
|
||||
denominators, producing wrong percentages. Combining the two needs a
|
||||
first-class design; for now the combination raises loudly rather than
|
||||
silently returning wrong numbers.
|
||||
"""
|
||||
with pytest.raises(InvalidPostProcessingError, match="marginal_distributions"):
|
||||
pivot(
|
||||
df=_show_values_as_fixture(),
|
||||
index=["row"],
|
||||
columns=["col"],
|
||||
aggregates={"v": {"operator": "sum"}},
|
||||
marginal_distributions=True,
|
||||
show_values_as="percent_row",
|
||||
)
|
||||
|
||||
|
||||
def test_pivot_show_values_as_with_combine_value_with_metric_preserves_per_metric() -> (
|
||||
None
|
||||
):
|
||||
"""Regression test for sadpandajoe's finding on #42976.
|
||||
|
||||
``combine_value_with_metric`` reshapes the column ``MultiIndex`` from
|
||||
``(metric, category)`` to ``(category, metric)``. Historically the
|
||||
``show_values_as`` transform ran *after* this reshape, so its per-metric
|
||||
iteration walked categories thinking they were metrics — mixing metric
|
||||
magnitudes and producing wrong percentages (e.g. metric ``a``'s row
|
||||
would sum to ~1.78 instead of 1.0 because metric ``b``'s values leaked
|
||||
into ``a``'s denominators).
|
||||
|
||||
The transform now runs *before* the reshape so per-metric isolation
|
||||
stays intact regardless of the final column layout.
|
||||
"""
|
||||
df = DataFrame(
|
||||
{
|
||||
"row": ["r1", "r1", "r2", "r2"],
|
||||
"col": ["c1", "c2", "c1", "c2"],
|
||||
"a": [10, 20, 30, 40],
|
||||
"b": [1, 3, 5, 7],
|
||||
}
|
||||
)
|
||||
result = pivot(
|
||||
df=df,
|
||||
index=["row"],
|
||||
columns=["col"],
|
||||
aggregates={"a": {"operator": "sum"}, "b": {"operator": "sum"}},
|
||||
combine_value_with_metric=True,
|
||||
show_values_as="percent_row",
|
||||
)
|
||||
|
||||
# After combine_value_with_metric, the column MultiIndex is
|
||||
# ``(category, metric)``. Per-metric row sums are pulled via cross-section
|
||||
# on level 1 (the metric axis).
|
||||
for metric, expected in (("a", [1.0, 1.0]), ("b", [1.0, 1.0])):
|
||||
per_metric = result.xs(metric, axis=1, level=1)
|
||||
assert per_metric.sum(axis=1).tolist() == pytest.approx(expected), (
|
||||
f"metric {metric!r} rows must each sum to 1.0 after "
|
||||
"percent_row on a combined pivot; got contamination from "
|
||||
"other metrics"
|
||||
)
|
||||
|
||||
# And the actual values match the natural per-metric percentages,
|
||||
# not the mixed-metric ones that the bug produced.
|
||||
assert result.loc["r1", ("c1", "a")] == pytest.approx(10 / 30)
|
||||
assert result.loc["r1", ("c1", "b")] == pytest.approx(1 / 4)
|
||||
|
||||
|
||||
def test_pivot_show_values_as_rejects_non_additive_aggregate() -> None:
|
||||
"""``show_values_as`` requires additive aggregates.
|
||||
|
||||
For a ``mean`` aggregate, the summed per-cell values are not the
|
||||
row/column/grand rollup the DB would compute over the underlying
|
||||
rows, so ``cell / sum(cells)`` disagrees with the "share of the
|
||||
real row total" the chart shows. Reject up front rather than emit
|
||||
numbers that mix with the DB rollup incorrectly.
|
||||
"""
|
||||
with pytest.raises(InvalidPostProcessingError, match="additive"):
|
||||
pivot(
|
||||
df=_show_values_as_fixture(),
|
||||
index=["row"],
|
||||
columns=["col"],
|
||||
aggregates={"v": {"operator": "mean"}},
|
||||
show_values_as="percent_row",
|
||||
)
|
||||
|
||||
|
||||
def test_pivot_show_values_as_on_empty_pivot_returns_empty_frame() -> None:
|
||||
"""Empty inputs must not crash the percent transform.
|
||||
|
||||
An empty pivot with a column grouping has a ``MultiIndex`` with zero
|
||||
level-0 groups; the metric-iteration loop then feeds ``pd.concat``
|
||||
an empty list and raises ``ValueError: No objects to concatenate``.
|
||||
The empty frame should pass through unchanged.
|
||||
"""
|
||||
empty = DataFrame({"row": [], "col": [], "v": []}).astype(
|
||||
{"row": str, "col": str, "v": float}
|
||||
)
|
||||
result = pivot(
|
||||
df=empty,
|
||||
index=["row"],
|
||||
columns=["col"],
|
||||
aggregates={"v": {"operator": "sum"}},
|
||||
show_values_as="percent_row",
|
||||
)
|
||||
assert result.empty
|
||||
|
||||
|
||||
def test_pivot_show_values_as_preserves_structural_nan() -> None:
|
||||
"""Structurally-missing cells (no input rows for that (row, col)) stay NaN.
|
||||
|
||||
NULL preservation is scoped to the structural case: cells that
|
||||
``pivot_table`` left as ``NaN`` because no input row exists for that
|
||||
(row, column) group must render as blank (``NaN``), not as ``0%``.
|
||||
Value-is-NULL cells are a separate case documented on
|
||||
``_apply_show_values_as``.
|
||||
"""
|
||||
df = DataFrame(
|
||||
{
|
||||
"row": ["r1", "r2", "r2"],
|
||||
"col": ["c1", "c1", "c2"],
|
||||
"v": [10.0, 30.0, 40.0],
|
||||
}
|
||||
)
|
||||
result = pivot(
|
||||
df=df,
|
||||
index=["row"],
|
||||
columns=["col"],
|
||||
aggregates={"v": {"operator": "sum"}},
|
||||
show_values_as="percent_row",
|
||||
)
|
||||
# r1 has no c2 row → cell is structurally missing → stays NaN.
|
||||
assert pd.isna(result.loc["r1", ("v", "c2")])
|
||||
# r1's row-total is just c1 (10.0), so c1 is 100%.
|
||||
assert result.loc["r1", ("v", "c1")] == pytest.approx(1.0)
|
||||
|
||||
@@ -392,3 +392,49 @@ def test_prophet_does_not_clamp_yhat_below_zero_for_negative_actuals():
|
||||
forecast_periods = 2
|
||||
forecast_yhat = result["balance__yhat"].iloc[-forecast_periods:]
|
||||
assert (forecast_yhat < 0).any()
|
||||
|
||||
|
||||
def test_prophet_every_time_grain_is_mapped():
|
||||
"""No `TimeGrain` may be missing from `PROPHET_TIME_GRAIN_MAP`.
|
||||
|
||||
Engine specs offer these grains in the Time Grain control, and the
|
||||
`ChartDataProphetOptionsSchema.time_grain` field advertises them in the
|
||||
OpenAPI spec, so an unmapped grain surfaces as "Unsupported time grain"
|
||||
on a value the UI and the API both present as valid.
|
||||
"""
|
||||
from superset.constants import TimeGrain
|
||||
from superset.utils.pandas_postprocessing.utils import PROPHET_TIME_GRAIN_MAP
|
||||
|
||||
unmapped = [
|
||||
grain.name for grain in TimeGrain if grain not in PROPHET_TIME_GRAIN_MAP
|
||||
]
|
||||
assert not unmapped, f"TimeGrain values missing a pandas frequency: {unmapped}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"time_grain",
|
||||
["PT5S", "PT30S", "PT0.5H", "PT6H", "P0.25Y"],
|
||||
)
|
||||
def test_prophet_previously_unmapped_time_grains(time_grain):
|
||||
"""These five grains raised "Unsupported time grain" despite being offered
|
||||
by engine specs such as Postgres, SQLite, Presto and Druid."""
|
||||
df = prophet(
|
||||
df=prophet_df, time_grain=time_grain, periods=3, confidence_interval=0.9
|
||||
)
|
||||
assert {"a__yhat", "a__yhat_upper", "a__yhat_lower"} <= set(df.columns)
|
||||
|
||||
|
||||
def test_prophet_alias_time_grains_match_their_canonical_form():
|
||||
"""`HALF_HOUR`/`QUARTER_YEAR` are alternate ISO-8601 spellings of
|
||||
`THIRTY_MINUTES`/`QUARTER` and must resolve to the same frequency."""
|
||||
from superset.constants import TimeGrain
|
||||
from superset.utils.pandas_postprocessing.utils import PROPHET_TIME_GRAIN_MAP
|
||||
|
||||
assert (
|
||||
PROPHET_TIME_GRAIN_MAP[TimeGrain.HALF_HOUR]
|
||||
== PROPHET_TIME_GRAIN_MAP[TimeGrain.THIRTY_MINUTES]
|
||||
)
|
||||
assert (
|
||||
PROPHET_TIME_GRAIN_MAP[TimeGrain.QUARTER_YEAR]
|
||||
== PROPHET_TIME_GRAIN_MAP[TimeGrain.QUARTER]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# 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 uuid import UUID
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
from superset.versioning.etag import raise_for_stale_write, StaleEntityError
|
||||
|
||||
LIVE = "9f1f4c1e-0000-4000-8000-000000000001"
|
||||
ENTITY = UUID("9f1f4c1e-0000-4000-8000-0000000000aa")
|
||||
|
||||
|
||||
def _put(app: Flask, if_match: str | None):
|
||||
headers = {"If-Match": if_match} if if_match is not None else {}
|
||||
return app.test_request_context("/api/v1/dataset/1", method="PUT", headers=headers)
|
||||
|
||||
|
||||
def test_no_if_match_header_passes(app: Flask) -> None:
|
||||
with _put(app, None):
|
||||
raise_for_stale_write(LIVE)
|
||||
|
||||
|
||||
def test_matching_if_match_passes(app: Flask) -> None:
|
||||
with _put(app, f'"{LIVE}"'):
|
||||
raise_for_stale_write(LIVE)
|
||||
|
||||
|
||||
def test_star_if_match_passes(app: Flask) -> None:
|
||||
with _put(app, "*"):
|
||||
raise_for_stale_write(LIVE)
|
||||
|
||||
|
||||
def test_compressed_if_match_passes(app: Flask) -> None:
|
||||
"""Flask-Compress rewrites the ETag of a compressed response to
|
||||
``"<uuid>:<algorithm>"``; a client replaying that must still match."""
|
||||
with _put(app, f'"{LIVE}:zstd"'):
|
||||
raise_for_stale_write(LIVE)
|
||||
|
||||
|
||||
def test_compressed_stale_if_match_still_raises(app: Flask) -> None:
|
||||
with _put(app, '"9f1f4c1e-0000-4000-8000-000000000002:gzip"'):
|
||||
with pytest.raises(StaleEntityError):
|
||||
raise_for_stale_write(LIVE)
|
||||
|
||||
|
||||
def test_stale_if_match_raises(app: Flask) -> None:
|
||||
with _put(app, '"9f1f4c1e-0000-4000-8000-000000000002"'):
|
||||
with pytest.raises(StaleEntityError):
|
||||
raise_for_stale_write(LIVE)
|
||||
|
||||
|
||||
def test_if_match_list_containing_live_passes(app: Flask) -> None:
|
||||
with _put(app, f'"9f1f4c1e-0000-4000-8000-000000000002", "{LIVE}"'):
|
||||
raise_for_stale_write(LIVE)
|
||||
|
||||
|
||||
def test_no_validator_available_passes(app: Flask) -> None:
|
||||
"""Version capture off (or no version rows yet) degrades to an
|
||||
unconditional write rather than blocking every save."""
|
||||
with _put(app, f'"{LIVE}"'):
|
||||
raise_for_stale_write(None)
|
||||
|
||||
|
||||
def test_unversioned_token_is_stable_and_entity_specific() -> None:
|
||||
"""A not-yet-versioned entity still gets a validator, derived from its own
|
||||
uuid so two such entities never share one."""
|
||||
from superset.versioning.api_helpers import unversioned_entity_token
|
||||
|
||||
other = UUID("9f1f4c1e-0000-4000-8000-0000000000ff")
|
||||
assert unversioned_entity_token(ENTITY) == unversioned_entity_token(ENTITY)
|
||||
assert unversioned_entity_token(ENTITY) != unversioned_entity_token(other)
|
||||
|
||||
|
||||
def test_unversioned_token_differs_from_first_real_version(app: Flask) -> None:
|
||||
"""The first version row must invalidate the unversioned token, or the
|
||||
first concurrent save on a pristine entity would go unguarded."""
|
||||
from superset.daos.version import derive_version_uuid
|
||||
from superset.versioning.api_helpers import unversioned_entity_token
|
||||
|
||||
stale = unversioned_entity_token(ENTITY)
|
||||
first_real = str(derive_version_uuid(ENTITY, 1))
|
||||
assert stale != first_real
|
||||
with _put(app, f'"{stale}"'):
|
||||
with pytest.raises(StaleEntityError):
|
||||
raise_for_stale_write(first_real)
|
||||
|
||||
|
||||
def test_unversioned_token_matches_while_still_unversioned(app: Flask) -> None:
|
||||
from superset.versioning.api_helpers import unversioned_entity_token
|
||||
|
||||
token = unversioned_entity_token(ENTITY)
|
||||
with _put(app, f'"{token}"'):
|
||||
raise_for_stale_write(token)
|
||||
@@ -16,11 +16,14 @@
|
||||
# under the License.
|
||||
"""Tests for superset.views.base module"""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.app import SupersetApp
|
||||
|
||||
|
||||
@patch("superset.views.base.utils.get_user_id", return_value=1)
|
||||
@patch(
|
||||
@@ -315,3 +318,34 @@ def test_deprecated_new_target_message_has_no_stray_space() -> None:
|
||||
formatted = template % tuple(args)
|
||||
assert "5.0.0. Use the following API endpoint instead" in formatted
|
||||
assert "5.0.0 . Use" not in formatted
|
||||
|
||||
|
||||
def test_language_pack_endpoint_is_public(app: "SupersetApp") -> None:
|
||||
"""The language pack endpoint must be accessible without authentication.
|
||||
|
||||
Translation data is non-sensitive and the embedded dashboard SPA needs to
|
||||
fetch it with a bare ``fetch()`` (no guest-token header). Previously the
|
||||
endpoint was protected by ``@has_access`` which caused a 302 redirect to
|
||||
``/login/`` for unauthenticated requests, silently breaking i18n in
|
||||
embedded dashboards (issue #42433).
|
||||
|
||||
When the compiled catalog exists the endpoint returns 200 with JSON.
|
||||
When it is missing the endpoint returns 404 (never a 302 to /login/).
|
||||
"""
|
||||
with app.test_client() as client:
|
||||
resp = client.get("/language_pack/en/")
|
||||
# The endpoint must be reachable without authentication.
|
||||
# In the test environment compiled catalogs may not exist, so
|
||||
# a 404 is acceptable; any 3xx redirect would indicate the old
|
||||
# @has_access guard is still in place.
|
||||
assert resp.status_code in (200, 404)
|
||||
assert not (300 <= resp.status_code < 400)
|
||||
if resp.status_code == 200:
|
||||
assert resp.is_json
|
||||
|
||||
|
||||
def test_language_pack_endpoint_rejects_invalid_lang(app: "SupersetApp") -> None:
|
||||
"""Invalid language codes are rejected with 400."""
|
||||
with app.test_client() as client:
|
||||
resp = client.get("/language_pack/zz/")
|
||||
assert resp.status_code in (400, 404)
|
||||
|
||||
Reference in New Issue
Block a user