Compare commits

..
20 changed files with 1005 additions and 548 deletions
@@ -26,8 +26,7 @@ page and its menu entry are hidden, and deletes are permanent as before.
## Finding archived objects
Open **Recently Archived** and pick a type — **Chart**, **Dashboard**, or
**Dataset** (shown as **Datasource** when semantic layers are enabled) — from
the Type selector. The view shows one type at a time; each
**Dataset** — from the Type selector. The view shows one type at a time; each
type is read from its own list endpoint, so the same row-level access rules that
govern the normal lists apply here.
+64 -82
View File
@@ -185,9 +185,9 @@
"@storybook/react-webpack5": "10.5.8",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.16.0",
"@swc/plugin-emotion": "^15.0.0",
"@swc/plugin-transform-imports": "^13.0.0",
"@swc/core": "^1.15.47",
"@swc/plugin-emotion": "^14.19.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^15.0.0",
@@ -11550,15 +11550,15 @@
}
},
"node_modules/@swc/core": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.0.tgz",
"integrity": "sha512-zSdvEHxBg00WhUNtW/u58hhcdR33gjtMQvOBo8F7POWJDyjRCt/miKfhidT3hCc/118RUwNnlEAmxiihFMbK4Q==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.47.tgz",
"integrity": "sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.28"
"@swc/types": "^0.1.27"
},
"engines": {
"node": ">=10"
@@ -11568,18 +11568,18 @@
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.16.0",
"@swc/core-darwin-x64": "1.16.0",
"@swc/core-linux-arm-gnueabihf": "1.16.0",
"@swc/core-linux-arm64-gnu": "1.16.0",
"@swc/core-linux-arm64-musl": "1.16.0",
"@swc/core-linux-ppc64-gnu": "1.16.0",
"@swc/core-linux-s390x-gnu": "1.16.0",
"@swc/core-linux-x64-gnu": "1.16.0",
"@swc/core-linux-x64-musl": "1.16.0",
"@swc/core-win32-arm64-msvc": "1.16.0",
"@swc/core-win32-ia32-msvc": "1.16.0",
"@swc/core-win32-x64-msvc": "1.16.0"
"@swc/core-darwin-arm64": "1.15.47",
"@swc/core-darwin-x64": "1.15.47",
"@swc/core-linux-arm-gnueabihf": "1.15.47",
"@swc/core-linux-arm64-gnu": "1.15.47",
"@swc/core-linux-arm64-musl": "1.15.47",
"@swc/core-linux-ppc64-gnu": "1.15.47",
"@swc/core-linux-s390x-gnu": "1.15.47",
"@swc/core-linux-x64-gnu": "1.15.47",
"@swc/core-linux-x64-musl": "1.15.47",
"@swc/core-win32-arm64-msvc": "1.15.47",
"@swc/core-win32-ia32-msvc": "1.15.47",
"@swc/core-win32-x64-msvc": "1.15.47"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
@@ -11591,9 +11591,9 @@
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.0.tgz",
"integrity": "sha512-SJQPl+xG/zB8bNjC/gTg3WOmOvz7EzlQD+VShfCKFYPNr2qvb+vATUY11vYEjnMWCn6wV8H8eAtjQrVflYyX5A==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.47.tgz",
"integrity": "sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==",
"cpu": [
"arm64"
],
@@ -11607,9 +11607,9 @@
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.0.tgz",
"integrity": "sha512-ql2JVch8V5t1i+HxiiuD4oVDI1dOku4/e3QiCkplONrm3SLitqNAP+nztHN51fSG2IgGuOwpAi3hgA+ukT5yQg==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.47.tgz",
"integrity": "sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==",
"cpu": [
"x64"
],
@@ -11623,9 +11623,9 @@
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.0.tgz",
"integrity": "sha512-PcdDBaRbe39y37h1rXVkhNy7mEU7f8b34KD761C68R23EsfMsj5oDPVddRzGdSRAvwwSfH0WSNEHgYmc/AJipg==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.47.tgz",
"integrity": "sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==",
"cpu": [
"arm"
],
@@ -11639,15 +11639,12 @@
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.0.tgz",
"integrity": "sha512-t21IUztHQ/COucy7Kk9eIlehmq08H/hYq7aRA6fZox3S5ddi6TxWPK6e5S/+aTCf6+Od9qQ+LIpjHMiTy737vA==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.47.tgz",
"integrity": "sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -11658,15 +11655,12 @@
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.0.tgz",
"integrity": "sha512-d9+iajbMB87b0umgbP+Gy3yBDSDgty4Q6H5pZ8fgTb/dOoKIwwynP4L4kvWCOFg2i49kxmAAUs1uJZh9s0E+RQ==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.47.tgz",
"integrity": "sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -11677,15 +11671,12 @@
}
},
"node_modules/@swc/core-linux-ppc64-gnu": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.0.tgz",
"integrity": "sha512-QRpeKGOg+B0qmo3BFU+6rL/gpoKYYJ7OFSMf5DNMafohYZ/iq2qvAH9Gcrf8NxROj3iooKOVewJ+YgahH1nSLw==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.47.tgz",
"integrity": "sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -11696,15 +11687,12 @@
}
},
"node_modules/@swc/core-linux-s390x-gnu": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.0.tgz",
"integrity": "sha512-q+Vr/hmHCcRXT/WFzOJC+T6GGEEtq2iaTtmyLfxO7yzu4ckgcqSNkg9m181wfNhuMwfNBoBhOfwQCnLsGZ5F4g==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.47.tgz",
"integrity": "sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -11715,15 +11703,12 @@
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.0.tgz",
"integrity": "sha512-DWVBc3QnpsSgKoq8N4rmZeZa5r/XrHdLkITsExN/tvTdqPtAPDPt+Ysy33OfgBlyN8lNe4xwsXWe6DXlRkJeRQ==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.47.tgz",
"integrity": "sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -11734,15 +11719,12 @@
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.0.tgz",
"integrity": "sha512-6XCgDSc1HPf/5dpjvABhKHICiBcsuZyW3hQMkn8sxel0TqprkJGp+H4iaBYIUTPixhrBub2hBPtfjcZLE6yL3w==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.47.tgz",
"integrity": "sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -11753,9 +11735,9 @@
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.0.tgz",
"integrity": "sha512-T/+9VVCZJ3AKEth9IP3U9AJ2YscQq+7LUqRTvfR4a2q36+Ri22oOwUizpAKOqQ42vb2Y/kOa4TOcJOfHoDIT/w==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.47.tgz",
"integrity": "sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==",
"cpu": [
"arm64"
],
@@ -11769,9 +11751,9 @@
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.0.tgz",
"integrity": "sha512-Pr1lsR/PMs8ndL0UWMrW8nLZ7H7sspIxBRDdjL8f+YJ/FJNASgzfunbVVXAqj0csgIJYHPZy+OW9smjFmk1Rcg==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.47.tgz",
"integrity": "sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==",
"cpu": [
"ia32"
],
@@ -11785,9 +11767,9 @@
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.0.tgz",
"integrity": "sha512-ktdeYLgOQdaonvsj5tJijqgpb0wk7gfF80wCFVA0kucI1hhSUIyfcGbjo5+9sdqv38OhMnTdLoA6xbqgOgPQjw==",
"version": "1.15.47",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.47.tgz",
"integrity": "sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==",
"cpu": [
"x64"
],
@@ -11826,9 +11808,9 @@
}
},
"node_modules/@swc/plugin-emotion": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-15.0.0.tgz",
"integrity": "sha512-B0L0KuItii5XatOskjeFW4kNPXYEDo5JYm+k5Lze3LEY46q4L7foVkXiUFbNn0GjbKJCOv+nU2nM57k4LYLbHw==",
"version": "14.19.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.19.0.tgz",
"integrity": "sha512-0/q84ro0a7kdjpYpn9Wmi5/RLHYuSwYjO638lE5ZBQfIvYpSLJxbEgLsObCmdH4KPe2stoN8plVKUpCsKPggaw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -11836,9 +11818,9 @@
}
},
"node_modules/@swc/plugin-transform-imports": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-transform-imports/-/plugin-transform-imports-13.0.0.tgz",
"integrity": "sha512-G8Wp8zX92O5F2YQ8OSqoAbNqPiU7VTLKFBtmN4W0y29SaNUDi8rLwvos5P5J1qdrQP3BmnQnS1wdZioMZXlJmw==",
"version": "12.5.0",
"resolved": "https://registry.npmjs.org/@swc/plugin-transform-imports/-/plugin-transform-imports-12.5.0.tgz",
"integrity": "sha512-b9ReG4NY9OwIIqXLlTuOb7k4N2yRBl501iNiBEKaiTazpxXxg6nR2XKOPojlyu1yb5YnK3s3EjTZX3DGSIDKNg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -11846,9 +11828,9 @@
}
},
"node_modules/@swc/types": {
"version": "0.1.28",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz",
"integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==",
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.27.tgz",
"integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
+3 -3
View File
@@ -262,9 +262,9 @@
"@storybook/react-webpack5": "10.5.8",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.16.0",
"@swc/plugin-emotion": "^15.0.0",
"@swc/plugin-transform-imports": "^13.0.0",
"@swc/core": "^1.15.47",
"@swc/plugin-emotion": "^14.19.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^15.0.0",
@@ -1,117 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useState } from 'react';
import { fireEvent, render, screen } from '@superset-ui/core/spec';
import { Input } from '../Input';
import { Modal } from './Modal';
const drag = (
target: Element,
from: [number, number],
to: [number, number],
) => {
fireEvent.mouseDown(target, { clientX: from[0], clientY: from[1] });
fireEvent.mouseMove(document, { clientX: to[0], clientY: to[1] });
fireEvent.mouseUp(document);
};
const isDragged = () => !!document.querySelector('.react-draggable-dragged');
describe('Modal draggable', () => {
test('dragging from the title bar moves the modal', () => {
render(
<Modal show onHide={() => {}} title="Edit Dataset" draggable name="test">
<Input data-test="field" defaultValue="value" />
</Modal>,
);
const trigger = document.querySelector('.draggable-trigger') as HTMLElement;
drag(trigger, [100, 50], [150, 90]);
expect(isDragged()).toBe(true);
});
test('dragging inside modal content does not move the modal', () => {
render(
<Modal show onHide={() => {}} title="Edit Dataset" draggable name="test">
<Input data-test="field" defaultValue="first_view_event" />
</Modal>,
);
const input = screen.getByTestId('field');
drag(input, [200, 400], [260, 430]);
expect(isDragged()).toBe(false);
});
test('dragging inside modal content does not move the modal, even after an unrelated re-render while the title was hovered', () => {
// Regression test: the title bar used to gate dragging with a
// hover-tracked boolean (mouseover/mouseout on `.draggable-trigger`)
// instead of react-draggable's own `handle` prop. Because the title
// element was defined as an inline component recreated on every
// render, any unrelated state change while the cursor was over the
// title (e.g. typing in any field) force-remounted it without a real
// mouseout ever firing, leaving dragging permanently enabled -- so
// selecting text anywhere in the modal dragged the whole modal
// instead.
function Harness() {
const [tick, setTick] = useState(0);
return (
<Modal
show
onHide={() => {}}
title="Edit Dataset"
draggable
name="test"
>
<button
type="button"
data-test="rerender"
onClick={() => setTick(tick + 1)}
>
rerender
</button>
<Input data-test="field" defaultValue="first_view_event" />
</Modal>
);
}
render(<Harness />);
const trigger = document.querySelector('.draggable-trigger') as HTMLElement;
fireEvent.mouseOver(trigger);
fireEvent.click(screen.getByTestId('rerender'));
const input = screen.getByTestId('field');
drag(input, [200, 400], [260, 430]);
expect(isDragged()).toBe(false);
});
test('dragging is disabled entirely when draggable is not set', () => {
render(
<Modal show onHide={() => {}} title="Edit Dataset" name="test">
<Input data-test="field" defaultValue="value" />
</Modal>,
);
expect(document.querySelector('.draggable-trigger')).toBeNull();
});
});
@@ -269,6 +269,7 @@ const CustomModal = ({
);
const draggableRef = useRef<HTMLDivElement>(null);
const [bounds, setBounds] = useState<DraggableBounds>({});
const [dragDisabled, setDragDisabled] = useState<boolean>(true);
const theme = useTheme();
const handleOnHide = () => {
@@ -338,7 +339,19 @@ const CustomModal = ({
}, [hideFooter, resizableConfig]);
const ModalTitle = () =>
draggable ? <div className="draggable-trigger">{title}</div> : <>{title}</>;
draggable ? (
<div
className="draggable-trigger"
onMouseOver={() => dragDisabled && setDragDisabled(false)}
onMouseOut={() => !dragDisabled && setDragDisabled(true)}
onFocus={() => dragDisabled && setDragDisabled(false)}
onBlur={() => !dragDisabled && setDragDisabled(true)}
>
{title}
</div>
) : (
<>{title}</>
);
return (
<StyledModal
@@ -365,17 +378,13 @@ const CustomModal = ({
modalRender={modal =>
resizable || draggable ? (
<Draggable
disabled={!draggable || dragDisabled}
bounds={bounds ?? false}
onStart={(event, uiData) => onDragStart(event, uiData)}
{...draggableConfig}
// These two props are derived from `draggable` and must stay
// authoritative, so they're applied after the spread to
// prevent callers from overriding them via `draggableConfig`.
disabled={!draggable}
handle={draggable ? '.draggable-trigger' : undefined}
// Pass nodeRef so react-draggable does not fall back to
// ReactDOM.findDOMNode (deprecated in React 18+ Strict Mode).
nodeRef={draggableRef}
{...draggableConfig}
>
{resizable ? (
<Resizable className="resizable" {...getResizableConfig}>
@@ -18,15 +18,9 @@
*/
import { createMemoryHistory, type Update } from 'history';
import { Router } from 'react-router-dom';
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import {
render,
screen,
fireEvent,
within,
} from 'spec/helpers/testing-library';
import { isFeatureEnabled } from '@superset-ui/core';
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
import type Chart from 'src/types/Chart';
import type { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import ChartCard from './ChartCard';
jest.mock('@superset-ui/core', () => ({
@@ -43,18 +37,7 @@ const mockChart = {
thumbnail_url: '/thumbnail.png',
} as Chart;
// Admin qualifies as editor, so the card's delete entry is enabled.
const adminUser = {
userId: 1,
username: 'admin',
roles: { Admin: [] },
permissions: {},
} as unknown as UserWithPermissionsAndRoles;
const renderCard = (
history: ReturnType<typeof createMemoryHistory>,
props: Partial<React.ComponentProps<typeof ChartCard>> = {},
) =>
const renderCard = (history: ReturnType<typeof createMemoryHistory>) =>
render(
<Router history={history}>
<ChartCard
@@ -69,7 +52,6 @@ const renderCard = (
favoriteStatus={false}
showThumbnails
handleBulkChartExport={jest.fn()}
{...props}
/>
</Router>,
);
@@ -124,44 +106,3 @@ test('clicking the card outside the thumbnail navigates to the chart', () => {
expect(navigations).toEqual(['PUSH /explore/?slice_id=1']);
});
test('with soft delete on, the card delete flow shows the archive dialog', async () => {
(isFeatureEnabled as jest.Mock).mockImplementation(
flag => flag === FeatureFlag.SoftDelete,
);
renderCard(createMemoryHistory(), { user: adminUser });
fireEvent.click(screen.getByTestId('chart-card-menu'));
fireEvent.click(await screen.findByText('Archive'));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Archive Sample Chart?')).toBeInTheDocument();
// The body comes from the shared soft-delete copy module; its exact
// wording evolves there (location hint, retention clause), so pin the
// stable prefix rather than a full sentence.
expect(
within(dialog).getByText(/This chart will be moved to Recently Archived/),
).toBeInTheDocument();
expect(
within(dialog).getByRole('button', { name: 'Archive' }),
).toBeInTheDocument();
// Recoverable deletes drop the type-DELETE friction.
expect(
within(dialog).queryByTestId('delete-modal-input'),
).not.toBeInTheDocument();
});
test('with soft delete off, the card delete dialog is the permanent-delete one', async () => {
(isFeatureEnabled as jest.Mock).mockReturnValue(false);
renderCard(createMemoryHistory(), { user: adminUser });
fireEvent.click(screen.getByTestId('chart-card-menu'));
fireEvent.click(await screen.findByText('Delete'));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Please confirm')).toBeInTheDocument();
expect(
within(dialog).getByText(/Are you sure you want to delete/),
).toBeInTheDocument();
expect(within(dialog).getByTestId('delete-modal-input')).toBeInTheDocument();
});
@@ -38,10 +38,6 @@ import {
isNavigationHandledByLink,
} from 'src/views/CRUD/utils';
import { assetUrl } from 'src/utils/assetUrl';
import {
archiveConfirmDescription,
deleteActionLabel,
} from 'src/utils/softDeleteCopy';
import type { ListViewFetchDataConfig as FetchDataConfig } from 'src/components';
import { TableTab } from 'src/views/CRUD/types';
import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils';
@@ -163,29 +159,15 @@ export default function ChartCard({
}
if (canDelete) {
// With soft delete on, deleting archives the chart (recoverable), so the
// confirmation drops the type-DELETE friction and uses the shared archive
// copy -- matching the list view's dialog for the same action.
const softDelete = isFeatureEnabled(FeatureFlag.SoftDelete);
menuItems.push({
key: 'delete',
label: (
<ConfirmStatusChange
recoverable={softDelete}
title={
softDelete
? t('Archive %(name)s?', { name: chart.slice_name })
: t('Please confirm')
}
title={t('Please confirm')}
description={
softDelete ? (
<p>{archiveConfirmDescription(t('chart'))}</p>
) : (
<>
{t('Are you sure you want to delete')} <b>{chart.slice_name}</b>
?
</>
)
<>
{t('Are you sure you want to delete')} <b>{chart.slice_name}</b>?
</>
}
onConfirm={() =>
handleChartDelete(
@@ -222,7 +204,7 @@ export default function ChartCard({
vertical-align: text-top;
`}
/>{' '}
{deleteActionLabel()}
{t('Delete')}
</button>
</Tooltip>
)}
@@ -25,10 +25,8 @@ import {
fireEvent,
userEvent,
waitFor,
within,
selectOption,
} from 'spec/helpers/testing-library';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { MemoryRouter } from 'react-router-dom';
import { QueryParamProvider } from 'use-query-params';
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
@@ -90,15 +88,6 @@ const mockCharts = [
// list so `_info` requests resolve to it rather than the broader list glob.
// withToasts injects the toast callbacks as props; the harness renders no
// toast container, so the spy is the only way to pin what the user is told.
// The type label for the dataset concept is flag-aware (SEMANTIC_LAYERS →
// "Datasource"); mock the flag reader so tests can exercise both states. The
// default (false for every flag) matches the real test environment, where no
// bootstrap flags are set.
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(() => false),
}));
const mockAddDangerToast = jest.fn();
jest.mock('src/components/MessageToasts/withToasts', () => ({
__esModule: true,
@@ -155,13 +144,6 @@ beforeEach(() => {
mockAddDangerToast.mockClear();
});
afterEach(() => {
// The flag mock is shared module state; restore the environment default so a
// flag-flipping test that dies mid-body (e.g. by Jest timeout) cannot leak
// SEMANTIC_LAYERS into whichever test runs next.
(isFeatureEnabled as jest.Mock).mockImplementation(() => false);
});
test('renders archived rows with Name and Type columns', async () => {
mockRoutes();
renderArchivedList();
@@ -591,57 +573,3 @@ test('a viewer who can read none of the types gets an empty state, not three 403
// No list fetch was ever issued.
expect(fetchMock.callHistory.calls(/chart\/\?q/)).toHaveLength(0);
});
test('labels the dataset type "Datasource" when semantic layers is enabled', async () => {
(isFeatureEnabled as jest.Mock).mockImplementation(
(flag: FeatureFlag) => flag === FeatureFlag.SemanticLayers,
);
mockRoutes();
renderArchivedList();
await screen.findByText('Deleted Chart One');
userEvent.click(screen.getByRole('combobox', { name: 'Type' }));
expect(
await screen.findByRole('option', { name: 'Datasource' }),
).toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Dataset' }),
).not.toBeInTheDocument();
// Selecting the renamed option still drives the dataset resource —
// the underlying type value is flag-independent.
await selectOption('Datasource', 'Type');
await screen.findByText('deleted_table_one');
expect(
fetchMock.callHistory.calls(datasetListEndpoint).length,
).toBeGreaterThan(0);
// Pin the Type COLUMN cell, not just the Select's own rendered value.
const datasetRow = screen.getByText('deleted_table_one').closest('tr');
expect(
within(datasetRow as HTMLElement).getByText('Datasource'),
).toBeInTheDocument();
});
test('labels the dataset type "Dataset" when semantic layers is disabled', async () => {
mockRoutes();
renderArchivedList();
await screen.findByText('Deleted Chart One');
userEvent.click(screen.getByRole('combobox', { name: 'Type' }));
expect(
await screen.findByRole('option', { name: 'Dataset' }),
).toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Datasource' }),
).not.toBeInTheDocument();
await selectOption('Dataset', 'Type');
await screen.findByText('deleted_table_one');
expect(
fetchMock.callHistory.calls(datasetListEndpoint).length,
).toBeGreaterThan(0);
const datasetRow = screen.getByText('deleted_table_one').closest('tr');
expect(
within(datasetRow as HTMLElement).getByText('Dataset'),
).toBeInTheDocument();
});
@@ -37,7 +37,6 @@ import {
type ListViewFilters,
} from 'src/components';
import SubMenu from 'src/features/home/SubMenu';
import { datasetLabel } from 'src/features/semanticLayers/label';
import withToasts from 'src/components/MessageToasts/withToasts';
import { recoveredToast } from 'src/utils/softDeleteCopy';
import { findPermission } from 'src/utils/findPermission';
@@ -83,12 +82,10 @@ const EmptyStateRow = styled.div`
`}
`;
// Getters, not strings: the dataset label follows the SEMANTIC_LAYERS flag
// ("Dataset" / "Datasource"), read at render time via the shared naming module.
const TYPE_LABELS: Record<ArchivedType, () => string> = {
chart: () => t('Chart'),
dashboard: () => t('Dashboard'),
dataset: datasetLabel,
const TYPE_LABELS: Record<ArchivedType, string> = {
chart: t('Chart'),
dashboard: t('Dashboard'),
dataset: t('Dataset'),
};
interface ToastProps {
@@ -169,7 +166,7 @@ function ArchivedListBody({
refreshData,
} = useListViewResource<ArchivedItem>(
config.resource,
TYPE_LABELS[type](),
TYPE_LABELS[type],
addDangerToast,
true,
[],
@@ -250,7 +247,7 @@ function ArchivedListBody({
name => {
const { text, options } = recoveredToast(
name,
TYPE_LABELS[type](),
TYPE_LABELS[type],
item.url ?? item.explore_url,
);
addSuccessToast(text, options);
@@ -309,7 +306,7 @@ function ArchivedListBody({
id: config.nameField,
},
{
Cell: () => TYPE_LABELS[type](),
Cell: () => TYPE_LABELS[type],
Header: t('Type'),
id: 'type',
disableSortBy: true,
@@ -542,7 +539,7 @@ function ArchivedList({ addDangerToast, addSuccessToast }: ToastProps) {
onChange={handleTypeChange}
options={availableTypes.map(option => ({
value: option,
label: TYPE_LABELS[option](),
label: TYPE_LABELS[option],
}))}
/>
</TypeSelectRow>
@@ -96,29 +96,19 @@ test('a malformed window does not leak into the copy', () => {
test('the confirm copy quotes the window when there is one', () => {
withConf({ SOFT_DELETE_RETENTION_DAYS: 30 });
expect(archiveConfirmDescription('chart')).toBe(
'This chart will be moved to Recently Archived in the Settings menu. You can recover it there within 30 days.',
'This chart will be moved to Recently Archived. You can recover it there within 30 days.',
);
expect(archiveConfirmDescription('charts', true)).toBe(
'These charts will be moved to Recently Archived in the Settings menu. You can recover them there within 30 days.',
);
});
test('a one-day window is quoted in the singular', () => {
withConf({ SOFT_DELETE_RETENTION_DAYS: 1 });
expect(archiveConfirmDescription('chart')).toBe(
'This chart will be moved to Recently Archived in the Settings menu. You can recover it there within 1 day.',
);
expect(archiveConfirmDescription('charts', true)).toBe(
'These charts will be moved to Recently Archived in the Settings menu. You can recover them there within 1 day.',
'These charts will be moved to Recently Archived. You can recover them there within 30 days.',
);
});
test('the confirm copy omits the clause when there is no window', () => {
withConf({});
expect(archiveConfirmDescription('dashboard')).toBe(
'This dashboard will be moved to Recently Archived in the Settings menu. You can recover it there.',
'This dashboard will be moved to Recently Archived. You can recover it there.',
);
expect(archiveConfirmDescription('dashboards', true)).toBe(
'These dashboards will be moved to Recently Archived in the Settings menu. You can recover them there.',
'These dashboards will be moved to Recently Archived. You can recover them there.',
);
});
+7 -14
View File
@@ -17,7 +17,7 @@
* under the License.
*/
import { escape } from 'lodash-es';
import { t, tn } from '@apache-superset/core/translation';
import { t } from '@apache-superset/core/translation';
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import getBootstrapData from 'src/utils/getBootstrapData';
@@ -62,32 +62,25 @@ export function archiveConfirmDescription(
// Each case is a single, complete translation unit (rather than two joined
// fragments) so translators control the whole sentence; only the noun and the
// day count are interpolated, matching Superset's existing `%(...)s` usage.
// The timed variants pluralize on the day count (`tn`) because the retention
// window accepts 1: "within 1 days" is exactly the copy defect this module
// exists to prevent.
const days = getSoftDeleteRetentionDays();
if (days) {
return plural
? tn(
'These %(type)s will be moved to Recently Archived in the Settings menu. You can recover them there within %(days)s day.',
'These %(type)s will be moved to Recently Archived in the Settings menu. You can recover them there within %(days)s days.',
days,
? t(
'These %(type)s will be moved to Recently Archived. You can recover them there within %(days)s days.',
{ type: typeLabel, days },
)
: tn(
'This %(type)s will be moved to Recently Archived in the Settings menu. You can recover it there within %(days)s day.',
'This %(type)s will be moved to Recently Archived in the Settings menu. You can recover it there within %(days)s days.',
days,
: t(
'This %(type)s will be moved to Recently Archived. You can recover it there within %(days)s days.',
{ type: typeLabel, days },
);
}
return plural
? t(
'These %(type)s will be moved to Recently Archived in the Settings menu. You can recover them there.',
'These %(type)s will be moved to Recently Archived. You can recover them there.',
{ type: typeLabel },
)
: t(
'This %(type)s will be moved to Recently Archived in the Settings menu. You can recover it there.',
'This %(type)s will be moved to Recently Archived. You can recover it there.',
{ type: typeLabel },
);
}
+10
View File
@@ -28,6 +28,16 @@ from werkzeug.local import LocalProxy
# form.
flask_appbuilder.Model.__allow_unmapped__ = True
# pandas >= 2.2 advertises a minimum SQLAlchemy of 2.0 and silently ignores
# older installations, breaking DataFrame.to_sql / read_sql with SQLAlchemy
# 1.4 engines. Its SQL layer still works with 1.4, so restore support until
# Superset itself requires SQLAlchemy >= 2. Must run before any pandas SQL IO.
from superset.utils.pandas_sqlalchemy_compat import ( # noqa: E402
restore_pandas_sqlalchemy_support,
)
restore_pandas_sqlalchemy_support()
from superset.app import create_app # noqa: E402, F401
from superset.extensions import ( # noqa: E402
appbuilder, # noqa: F401
+4
View File
@@ -1316,6 +1316,10 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
try:
TestConnectionDatabaseCommand(item).run()
return self.response(200, message="OK")
except OAuth2RedirectError:
# OAuth2 connections pass, so they can be saved. A user later
# can then store an OAuth2 token.
return self.response(200, message="OK")
except (
SSHTunnelingNotEnabledError,
SSHTunnelDatabasePortError,
-12
View File
@@ -5618,18 +5618,6 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
editor_subject_ids = set(get_extra_editor_subject_ids(resource))
if hasattr(resource, "editors"):
editor_subject_ids.update(s.id for s in resource.editors)
# Fallback ONLY for Query and SavedQuery models that use 'user_id'
from superset.models.sql_lab import Query, SavedQuery
from superset.subjects.utils import get_user_subject
if (
isinstance(resource, (Query, SavedQuery))
and getattr(resource, "user_id", None) is not None
):
if subject := get_user_subject(resource.user_id):
editor_subject_ids.add(subject.id)
return bool(subject_ids & editor_subject_ids)
def is_viewer(self, resource: Model) -> bool:
+10 -18
View File
@@ -13831,18 +13831,14 @@ msgstr ""
#, python-format
msgid ""
"These %(type)s will be moved to Recently Archived in the Settings menu. "
"You can recover them there within %(days)s day."
msgid_plural ""
"These %(type)s will be moved to Recently Archived in the Settings menu. "
"You can recover them there within %(days)s days."
msgstr[0] ""
msgstr[1] ""
"These %(type)s will be moved to Recently Archived. You can recover them "
"there within %(days)s days."
msgstr ""
#, python-format
msgid ""
"These %(type)s will be moved to Recently Archived in the Settings menu. "
"You can recover them there."
"These %(type)s will be moved to Recently Archived. You can recover them "
"there."
msgstr ""
msgid "These are the datasets this filter will be applied to."
@@ -13850,18 +13846,14 @@ msgstr ""
#, python-format
msgid ""
"This %(type)s will be moved to Recently Archived in the Settings menu. "
"You can recover it there within %(days)s day."
msgid_plural ""
"This %(type)s will be moved to Recently Archived in the Settings menu. "
"You can recover it there within %(days)s days."
msgstr[0] ""
msgstr[1] ""
"This %(type)s will be moved to Recently Archived. You can recover it "
"there within %(days)s days."
msgstr ""
#, python-format
msgid ""
"This %(type)s will be moved to Recently Archived in the Settings menu. "
"You can recover it there."
"This %(type)s will be moved to Recently Archived. You can recover it "
"there."
msgstr ""
msgid ""
@@ -0,0 +1,80 @@
# 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.
"""Compatibility shim letting pandas >= 2.2 use SQLAlchemy 1.4 engines.
pandas 2.2 raised its advertised minimum SQLAlchemy version to 2.0 as a
support-policy change. When an older SQLAlchemy is installed, pandas does not
fail loudly: ``pandas.io.sql`` silently pretends SQLAlchemy is absent, treats
Engine/Connection arguments as raw DBAPI connections, and falls back to its
sqlite-only code path, breaking every ``DataFrame.to_sql`` / ``read_sql``
call site (dataset uploads, example data loading, annotation queries, filter
values).
The pandas SQL layer itself still works with SQLAlchemy 1.4 because it only
uses the API subset common to SQLAlchemy 1.4 and 2.x. Lowering the advertised
minimum back to the pandas 2.1 value restores the working behavior.
This module is obsolete once Superset requires SQLAlchemy >= 2; at that point
the patch becomes a no-op and the module (and its call site in
``superset/__init__.py``) can be deleted.
"""
import logging
import sqlalchemy
from packaging.version import Version
logger = logging.getLogger(__name__)
# The last pandas release line to support SQLAlchemy 1.4 (pandas 2.1)
# required at least this version.
_SQLALCHEMY_MINIMUM = "1.4.16"
def restore_pandas_sqlalchemy_support() -> None:
"""Lower pandas' advertised SQLAlchemy minimum so 1.4 engines work.
Only applies when the installed SQLAlchemy predates 2.0 and pandas
advertises a 2.x minimum; in every other combination this is a no-op.
Safe to call multiple times.
"""
if Version(sqlalchemy.__version__) >= Version("2.0.0"):
# pandas supports SQLAlchemy 2.x natively; nothing to patch.
return
try:
from pandas.compat import _optional
except ImportError:
# The private module moved in a newer pandas; SQL IO with a pre-2.0
# SQLAlchemy will misbehave, so make the situation diagnosable.
logger.warning(
"Could not adjust pandas' minimum SQLAlchemy version; "
"DataFrame.to_sql/read_sql may not accept SQLAlchemy %s engines",
sqlalchemy.__version__,
)
return
advertised = _optional.VERSIONS.get("sqlalchemy")
if advertised and Version(advertised) > Version(_SQLALCHEMY_MINIMUM):
_optional.VERSIONS["sqlalchemy"] = _SQLALCHEMY_MINIMUM
logger.debug(
"Lowered pandas' minimum SQLAlchemy version from %s to %s so "
"pandas SQL IO keeps working with the installed SQLAlchemy %s",
advertised,
_SQLALCHEMY_MINIMUM,
sqlalchemy.__version__,
)
@@ -2403,6 +2403,54 @@ class TestDatabaseApi(SupersetTestCase):
assert rv.status_code == 200
assert rv.headers["Content-Type"] == "application/json; charset=utf-8"
@with_config({"PREVENT_UNSAFE_DB_CONNECTIONS": False})
def test_test_connection_oauth2(self):
"""
Database API: Test test connection flow with a connection authenticated via
OAuth2.
The test would always raise ``OAuth2RedirectError``, and we can't start the
OAuth2 dance before the connection is saved, so it should return a 200 status.
"""
self.login(ADMIN_USERNAME)
example_db = get_example_database()
masked_encrypted_extra = json.dumps(
{
"oauth2_client_info": {
"id": "client_id",
"secret": "client_secret",
"scope": "some-scope",
"authorization_request_uri": "https://example.org/authorize",
"token_request_uri": "https://example.org/token",
}
}
)
data = {
"database_name": "examples",
"masked_encrypted_extra": masked_encrypted_extra,
"impersonate_user": True,
"sqlalchemy_uri": example_db.safe_sqlalchemy_uri(),
"server_cert": None,
}
url = "api/v1/database/test_connection/"
with (
mock.patch(
"superset.commands.database.test_connection.ping",
side_effect=Exception("Unauthorized"),
),
mock.patch.object(
example_db.db_engine_spec,
"needs_oauth2",
return_value=True,
),
):
rv = self.post_assert_metric(url, data, "test_connection")
assert rv.status_code == 200
assert rv.headers["Content-Type"] == "application/json; charset=utf-8"
assert json.loads(rv.data.decode("utf-8")) == {"message": "OK"}
def test_test_connection_failed(self):
"""
Database API: Test test connection failed
@@ -605,6 +605,9 @@ class TestSavedQueryApi(SupersetTestCase):
db.session.query(SavedQuery).filter(SavedQuery.label == "label1").all()[0]
)
self.login(ADMIN_USERNAME)
# Freeze relative to the persisted timestamp so database-specific
# timestamp precision cannot make the humanized value age into the
# next bucket while the request is being handled.
with freeze_time(saved_query.changed_on):
uri = f"api/v1/saved_query/{saved_query.id}"
rv = self.get_assert_metric(uri, "get")
+672 -111
View File
@@ -36,6 +36,7 @@ from superset.extensions import appbuilder
from superset.models.slice import Slice
from superset.security.manager import (
_collect_sortable_identifiers,
_sql_filters_modified,
freeze_value,
query_context_modified,
SupersetSecurityManager,
@@ -3793,121 +3794,681 @@ def test_validate_guest_token_resources_accepts_embedded_int_id(
)
def test_is_editor_query_owner(mocker: MockerFixture, app_context: None) -> None:
"""
Test that a Query owner is considered an editor via Subject resolution.
"""
from superset.models.sql_lab import Query
sm = SupersetSecurityManager(appbuilder)
mocker.patch.object(sm, "is_admin", return_value=False)
mocker.patch(
"superset.security.manager.get_user_id",
return_value=100,
)
mocker.patch(
"superset.subjects.utils.get_user_subject_ids",
return_value={1000},
)
mocker.patch(
"superset.security.manager.get_extra_editor_subject_ids",
return_value=set(),
)
subject_user_100 = mocker.MagicMock(id=1000)
subject_user_200 = mocker.MagicMock(id=2000)
def mock_get_user_subject(uid: int):
if uid == 100:
return subject_user_100
if uid == 200:
return subject_user_200
return None
mocker.patch(
"superset.subjects.utils.get_user_subject",
side_effect=mock_get_user_subject,
)
query = Query(user_id=100)
assert sm.is_editor(query) is True
other_query = Query(user_id=200)
assert sm.is_editor(other_query) is False
# ---------------------------------------------------------------------------
# _sql_filters_modified block custom SQL injection by guest users
# ---------------------------------------------------------------------------
def test_is_editor_saved_query_owner(mocker: MockerFixture, app_context: None) -> None:
"""
Test that a SavedQuery owner is considered an editor via Subject resolution.
"""
from superset.models.sql_lab import SavedQuery
sm = SupersetSecurityManager(appbuilder)
mocker.patch.object(sm, "is_admin", return_value=False)
mocker.patch(
"superset.security.manager.get_user_id",
return_value=100,
)
mocker.patch(
"superset.subjects.utils.get_user_subject_ids",
return_value={1000},
)
mocker.patch(
"superset.security.manager.get_extra_editor_subject_ids",
return_value=set(),
)
subject_user_100 = mocker.MagicMock(id=1000)
subject_user_200 = mocker.MagicMock(id=2000)
def mock_get_user_subject(uid: int):
if uid == 100:
return subject_user_100
if uid == 200:
return subject_user_200
return None
mocker.patch(
"superset.subjects.utils.get_user_subject",
side_effect=mock_get_user_subject,
)
saved_query = SavedQuery(user_id=100)
assert sm.is_editor(saved_query) is True
other_saved_query = SavedQuery(user_id=200)
assert sm.is_editor(other_saved_query) is False
def test_is_editor_other_model_with_user_id_not_editor(
mocker: MockerFixture, app_context: None
def test_sql_filters_extras_where_injected_blocked(
mocker: MockerFixture,
) -> None:
"""
Test that a model with user_id that is NOT Query or SavedQuery
does NOT receive the fallback and is not considered an editor.
"""
from superset.models.sql_lab import TabState
"""Injecting extras.where when the chart has no SQL filters is blocked."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {"metrics": ["count"]}
query = QueryObject(extras={"where": "1=1"})
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_extras_having_injected_blocked(
mocker: MockerFixture,
) -> None:
"""Injecting extras.having when the chart has no SQL filters is blocked."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
query = QueryObject(extras={"having": "COUNT(*) > 0"})
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_extras_where_replay_allowed(
mocker: MockerFixture,
) -> None:
"""Replaying the chart's own SQL WHERE filter is allowed."""
sql_filter = {
"expressionType": "SQL",
"sqlExpression": "region = 'EMEA'",
"clause": "WHERE",
}
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {"adhoc_filters": [sql_filter]}
# freeform_where_having wraps each clause in parens
query = QueryObject(extras={"where": "(region = 'EMEA')"})
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_extras_having_replay_allowed(
mocker: MockerFixture,
) -> None:
"""Replaying the chart's own SQL HAVING filter is allowed."""
sql_filter = {
"expressionType": "SQL",
"sqlExpression": "SUM(sales) > 100",
"clause": "HAVING",
}
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {"adhoc_filters": [sql_filter]}
query = QueryObject(extras={"having": "(SUM(sales) > 100)"})
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_adhoc_sql_filter_injected_blocked(
mocker: MockerFixture,
) -> None:
"""Injecting a new SQL adhoc filter not on the stored chart is blocked."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
query = QueryObject()
query_context.queries = [query]
injected_filter = {
"expressionType": "SQL",
"sqlExpression": "1=1",
"clause": "WHERE",
}
form_data: dict[str, Any] = {"slice_id": 1, "adhoc_filters": [injected_filter]}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_adhoc_sql_filter_replay_allowed(
mocker: MockerFixture,
) -> None:
"""Replaying the exact stored SQL adhoc filter is allowed."""
sql_filter = {
"expressionType": "SQL",
"sqlExpression": "region = 'EMEA'",
"clause": "WHERE",
}
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {"adhoc_filters": [sql_filter]}
query = QueryObject()
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1, "adhoc_filters": [sql_filter]}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_empty_extras_always_allowed(
mocker: MockerFixture,
) -> None:
"""No SQL in extras is always allowed, even when the chart has SQL filters."""
sql_filter = {
"expressionType": "SQL",
"sqlExpression": "region = 'EMEA'",
"clause": "WHERE",
}
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {"adhoc_filters": [sql_filter]}
query = QueryObject()
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_from_stored_qc_allowed(
mocker: MockerFixture,
) -> None:
"""extras.where from stored query_context is allowed."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
stored_qc = {
"queries": [{"extras": {"where": "(col > 5)"}}],
}
query = QueryObject(extras={"where": "(col > 5)"})
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, stored_qc)
def test_sql_filters_multi_query_stored_predicate_allowed(
mocker: MockerFixture,
) -> None:
"""Multiple queries replaying predicates from the stored chart are allowed.
The allowed set is global across all stored queries per-query pinning is
intentionally not applied because there is no stable identity linking a
request query to a stored query, and all queries share the same
chart/datasource so predicates only restrict rows, never expand access.
"""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
stored_qc = {
"queries": [
{"extras": {"where": "(region = 'EMEA')"}},
{"extras": {"where": "(status = 'active')"}},
],
}
# Both request queries use predicates from the stored chart.
query_context.queries = [
QueryObject(extras={"where": "(region = 'EMEA')"}),
QueryObject(extras={"where": "(status = 'active')"}),
]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, stored_qc)
def test_sql_filters_multi_query_novel_predicate_blocked(
mocker: MockerFixture,
) -> None:
"""A novel predicate on any query is blocked even when others are valid."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
stored_qc = {
"queries": [{"extras": {"where": "(region = 'EMEA')"}}],
}
query_context.queries = [
QueryObject(extras={"where": "(region = 'EMEA')"}),
QueryObject(extras={"where": "(1=1)"}), # not stored
]
form_data: dict[str, Any] = {"slice_id": 1}
assert _sql_filters_modified(query_context, form_data, stored_chart, stored_qc)
def test_sql_filters_different_sql_blocked(
mocker: MockerFixture,
) -> None:
"""Modified SQL (appending extra predicates) is blocked."""
sql_filter = {
"expressionType": "SQL",
"sqlExpression": "col > 5",
"clause": "WHERE",
}
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {"adhoc_filters": [sql_filter]}
# Attacker appends extra predicate
query = QueryObject(
extras={"where": "(col > 5) AND (1=1)"},
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_simple_filters_not_blocked(
mocker: MockerFixture,
) -> None:
"""SIMPLE structured filters (from dashboard native filters) are not blocked."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
query = QueryObject(
filters=[{"col": "country", "op": "==", "val": "US"}],
)
query_context.queries = [query]
simple_adhoc_filter = {
"expressionType": "SIMPLE",
"subject": "country",
"operator": "==",
"comparator": "US",
"clause": "WHERE",
}
form_data: dict[str, Any] = {
"slice_id": 1,
"adhoc_filters": [simple_adhoc_filter],
}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_structured_filter_adhoc_col_blocked(
mocker: MockerFixture,
) -> None:
"""Structured filter with an adhoc SQL column in ``col`` is blocked.
``ChartDataFilterSchema.col`` is ``fields.Raw``, so an attacker can pass
an adhoc column dict that reaches ``adhoc_column_to_sqla`` and executes
arbitrary SQL in the WHERE clause.
"""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
adhoc_col: Any = {
"expressionType": "SQL",
"sqlExpression": "1; DROP TABLE users--",
"label": "x",
}
query = QueryObject(
filters=[{"col": adhoc_col, "op": "!=", "val": "z"}],
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_structured_filter_stored_adhoc_col_allowed(
mocker: MockerFixture,
) -> None:
"""Cross-filter with an adhoc SQL column matching a stored chart dimension
is allowed."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {
"columns": [
{"sqlExpression": "YEAR(order_date)", "label": "order_year"},
],
}
adhoc_col: Any = {
"sqlExpression": "YEAR(order_date)",
"label": "order_year",
}
query = QueryObject(
filters=[{"col": adhoc_col, "op": "==", "val": "2024"}],
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_cross_filter_adhoc_col_from_sibling_chart_allowed(
mocker: MockerFixture,
) -> None:
"""Cross-filter with an adhoc SQL column from a sibling chart on the same
dashboard is allowed."""
from superset.models.dashboard import Dashboard
# Target chart (chart B) has no custom SQL columns.
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.id = 2
stored_chart.params_dict = {"metrics": ["count"]}
# Source chart (chart A) has the custom SQL dimension.
sibling_chart = mocker.MagicMock()
sibling_chart.id = 1
sibling_chart.params_dict = {
"columns": [
{"sqlExpression": "YEAR(order_date)", "label": "order_year"},
],
}
# Dashboard contains both charts.
dashboard = mocker.MagicMock(spec=Dashboard)
dashboard.slices = [sibling_chart, stored_chart]
mocker.patch("superset.db.session.query")
db_query = mocker.patch("superset.db.session.query").return_value
db_query.filter.return_value.one_or_none.return_value = dashboard
mocker.patch(
"superset.security_manager.has_guest_access",
return_value=True,
)
adhoc_col: Any = {
"sqlExpression": "YEAR(order_date)",
"label": "order_year",
}
query = QueryObject(
filters=[{"col": adhoc_col, "op": "==", "val": "2024"}],
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 2, "dashboardId": 10}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_cross_filter_rejected_for_unauthorized_dashboard(
mocker: MockerFixture,
) -> None:
"""Cross-filter lookup must not use a dashboard the guest has no access to."""
from superset.models.dashboard import Dashboard
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.id = 2
stored_chart.params_dict = {}
sibling_chart = mocker.MagicMock()
sibling_chart.id = 1
sibling_chart.params_dict = {
"columns": [{"sqlExpression": "YEAR(order_date)", "label": "order_year"}],
}
dashboard = mocker.MagicMock(spec=Dashboard)
dashboard.slices = [sibling_chart, stored_chart]
mocker.patch("superset.db.session.query")
db_query = mocker.patch("superset.db.session.query").return_value
db_query.filter.return_value.one_or_none.return_value = dashboard
mocker.patch(
"superset.security_manager.has_guest_access",
return_value=False,
)
adhoc_col: Any = {"sqlExpression": "YEAR(order_date)", "label": "order_year"}
query = QueryObject(
filters=[{"col": adhoc_col, "op": "==", "val": "2024"}],
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 2, "dashboardId": 999}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_cross_filter_rejected_when_chart_not_on_dashboard(
mocker: MockerFixture,
) -> None:
"""Cross-filter lookup must verify the target chart belongs to the dashboard."""
from superset.models.dashboard import Dashboard
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.id = 99 # not on the dashboard
stored_chart.params_dict = {}
sibling_chart = mocker.MagicMock()
sibling_chart.id = 1
sibling_chart.params_dict = {
"columns": [{"sqlExpression": "YEAR(order_date)", "label": "order_year"}],
}
dashboard = mocker.MagicMock(spec=Dashboard)
dashboard.slices = [sibling_chart] # stored_chart not here
mocker.patch("superset.db.session.query")
db_query = mocker.patch("superset.db.session.query").return_value
db_query.filter.return_value.one_or_none.return_value = dashboard
mocker.patch(
"superset.security_manager.has_guest_access",
return_value=True,
)
adhoc_col: Any = {"sqlExpression": "YEAR(order_date)", "label": "order_year"}
query = QueryObject(
filters=[{"col": adhoc_col, "op": "==", "val": "2024"}],
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 99, "dashboardId": 10}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_sibling_expressions_cannot_inject_where_having(
mocker: MockerFixture,
) -> None:
"""Sibling chart column expressions must not legitimize novel WHERE/HAVING."""
from superset.models.dashboard import Dashboard
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.id = 2
stored_chart.params_dict = {}
# Sibling has a column expression that an attacker tries to use as WHERE.
sibling_chart = mocker.MagicMock()
sibling_chart.id = 1
sibling_chart.params_dict = {
"columns": [
{"sqlExpression": "(SELECT secret FROM users LIMIT 1)", "label": "x"},
],
}
dashboard = mocker.MagicMock(spec=Dashboard)
dashboard.slices = [sibling_chart, stored_chart]
mocker.patch("superset.db.session.query")
db_query = mocker.patch("superset.db.session.query").return_value
db_query.filter.return_value.one_or_none.return_value = dashboard
# Attacker injects the sibling expression into extras.where.
query = QueryObject(
extras={"where": "(SELECT secret FROM users LIMIT 1)"},
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 2, "dashboardId": 10}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_collect_allowed_sql_includes_scalar_column_params(
mocker: MockerFixture,
) -> None:
"""Scalar column params like x_axis contribute their sqlExpression."""
from superset.security.manager import _collect_allowed_sql
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {
"x_axis": {"sqlExpression": "DATE_TRUNC('month', ts)", "label": "m"},
"groupby": [{"sqlExpression": "UPPER(country)", "label": "c"}],
}
_, col_allowed = _collect_allowed_sql(stored_chart, None)
assert "DATE_TRUNC('month', ts)" in col_allowed
assert "UPPER(country)" in col_allowed
def test_sql_filters_structured_filter_string_col_allowed(
mocker: MockerFixture,
) -> None:
"""Structured filter with a plain string column is allowed."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
query = QueryObject(
filters=[{"col": "status", "op": "==", "val": "active"}],
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_empty_filter_sentinel_allowed(
mocker: MockerFixture,
) -> None:
"""The ``(1 = 0)`` sentinel from a required-but-empty native filter is allowed."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
query = QueryObject(extras={"where": "(1 = 0)"})
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_double_sentinel_allowed(
mocker: MockerFixture,
) -> None:
"""Two required-but-empty filters compose ``(1 = 0) AND (1 = 0)``."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
query = QueryObject(extras={"where": "(1 = 0) AND (1 = 0)"})
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_stored_clause_plus_sentinel_allowed(
mocker: MockerFixture,
) -> None:
"""A stored SQL filter composed with the empty-filter sentinel is allowed."""
sql_filter = {
"expressionType": "SQL",
"sqlExpression": "region = 'EMEA'",
"clause": "WHERE",
}
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {"adhoc_filters": [sql_filter]}
query = QueryObject(
extras={"where": "(region = 'EMEA') AND (1 = 0)"},
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_non_dict_adhoc_filter_skipped(
mocker: MockerFixture,
) -> None:
"""Non-dict items in adhoc_filters are skipped, not 500."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
query = QueryObject()
query_context.queries = [query]
form_data: dict[str, Any] = {
"slice_id": 1,
"adhoc_filters": ["not_a_dict", 42, None],
}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_raise_for_access_guest_user_sql_filter_injection_blocked(
mocker: MockerFixture,
app_context: None,
stored_metrics: list[AdhocMetric],
) -> None:
"""Guest user injecting SQL via extras.where is rejected by raise_for_access."""
sm = SupersetSecurityManager(appbuilder)
mocker.patch.object(sm, "is_admin", return_value=False)
mocker.patch(
"superset.security.manager.get_user_id",
return_value=100,
)
mocker.patch(
"superset.subjects.utils.get_user_subject_ids",
return_value={1000},
)
mocker.patch(
"superset.security.manager.get_extra_editor_subject_ids",
return_value=set(),
)
mocker.patch.object(sm, "is_guest_user", return_value=True)
mocker.patch.object(sm, "can_access", return_value=True)
subject_user_100 = mocker.MagicMock(id=1000)
mocker.patch(
"superset.subjects.utils.get_user_subject",
return_value=subject_user_100,
)
query_context = mocker.MagicMock()
query_context.slice_.id = 42
query_context.slice_.query_context = None
query_context.slice_.params_dict = {"metrics": stored_metrics}
tab_state = TabState(user_id=100)
assert sm.is_editor(tab_state) is False
query_context.form_data = {"slice_id": 42, "metrics": stored_metrics}
query_context.queries = [
QueryObject(
metrics=stored_metrics, # type: ignore
extras={"where": "1=1 UNION SELECT password FROM users"},
)
]
with pytest.raises(SupersetSecurityException):
sm.raise_for_access(query_context=query_context)
def test_sql_filters_cache_replay_skips_check(
mocker: MockerFixture,
) -> None:
"""Cache-replay requests skip the SQL filter check."""
query_context = mocker.MagicMock()
query_context._from_cache_replay = True
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
query = QueryObject(extras={"where": "(injected SQL)"})
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert not _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_column_expression_cannot_become_where(
mocker: MockerFixture,
) -> None:
"""A chart's column sqlExpression must not be injectable as extras.where."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {
"columns": [
{
"sqlExpression": "(SELECT secret FROM users LIMIT 1)",
"label": "x",
},
],
}
query = QueryObject(
extras={"where": "((SELECT secret FROM users LIMIT 1))"},
)
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
def test_sql_filters_unbalanced_parens_rejected(
mocker: MockerFixture,
) -> None:
"""Unbalanced parens in extras.where are rejected (403, not 500)."""
query_context = mocker.MagicMock()
stored_chart = mocker.MagicMock()
stored_chart.params_dict = {}
query = QueryObject(extras={"where": "(a) AND (b"})
query_context.queries = [query]
form_data: dict[str, Any] = {"slice_id": 1}
assert _sql_filters_modified(query_context, form_data, stored_chart, None)
@@ -0,0 +1,67 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import pandas as pd
from sqlalchemy import create_engine, types
from superset.utils.pandas_sqlalchemy_compat import (
restore_pandas_sqlalchemy_support,
)
def test_to_sql_accepts_sqlalchemy_engine_and_dtypes() -> None:
"""
``DataFrame.to_sql`` must accept a SQLAlchemy engine plus SQLAlchemy
``dtype`` objects regardless of the installed pandas/SQLAlchemy combo.
This is the exact call shape used by dataset uploads
(``BaseEngineSpec.df_to_sql``), example data loading, and the test data
loaders; it breaks when pandas silently rejects the installed SQLAlchemy
as too old (pandas >= 2.2 with SQLAlchemy 1.x) and no compat shim is
applied.
"""
restore_pandas_sqlalchemy_support()
engine = create_engine("sqlite://")
df = pd.DataFrame(
{
"name": ["a", "b"],
"num": [1, 2],
"ds": pd.to_datetime(["2021-01-01", "2021-01-02"]),
}
)
df.to_sql(
"birth_names",
engine,
index=False,
dtype={"ds": types.DateTime(), "name": types.String(255)},
method="multi",
chunksize=100,
)
df.to_sql("birth_names", engine, index=False, if_exists="replace")
result = pd.read_sql_query("SELECT name, num FROM birth_names", engine)
assert result["name"].tolist() == ["a", "b"]
assert result["num"].tolist() == [1, 2]
def test_restore_pandas_sqlalchemy_support_is_idempotent() -> None:
from pandas.compat import _optional
restore_pandas_sqlalchemy_support()
first = _optional.VERSIONS.get("sqlalchemy")
restore_pandas_sqlalchemy_support()
assert _optional.VERSIONS.get("sqlalchemy") == first