Compare commits

..

9 Commits

Author SHA1 Message Date
Beto Dealmeida
992226527f feat: run pre-query cost estimation 2025-07-29 12:49:55 -04:00
dependabot[bot]
a9cd58508b chore(deps): bump cookie and @types/cookie in /superset-websocket (#34335)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: hainenber <dotronghai96@gmail.com>
2025-07-29 20:19:31 +07:00
Beto Dealmeida
122bb68e5a fix: subquery alias in RLS (#34374) 2025-07-28 22:58:15 -04:00
Beto Dealmeida
914ce9aa4f feat: read column metadata (#34359) 2025-07-28 22:57:57 -04:00
Gabriel Torres Ruiz
bb572983cd feat(theming): Align embedded sdk with theme configs (#34273) 2025-07-28 19:26:17 -07:00
Đỗ Trọng Hải
ff76ab647f build(deps): update ag-grid to non-breaking major v34 (#34326) 2025-07-29 07:46:55 +07:00
Mehmet Salih Yavuz
f554848c9f fix(PivotTable): Render html in cells if allowRenderHtml is true (#34351) 2025-07-29 01:12:37 +03:00
Hari Kiran
dc0c389488 docs(development): fix 2 typos in the dockerfile (#34341) 2025-07-28 15:06:21 -07:00
Beto Dealmeida
22b3cc0480 chore: bump BigQuery dialect to 1.15.0 (#34371) 2025-07-28 16:39:18 -04:00
43 changed files with 2203 additions and 295 deletions

View File

@@ -59,7 +59,7 @@ RUN mkdir -p /app/superset/static/assets \
# NOTE: we mount packages and plugins as they are referenced in package.json as workspaces
# ideally we'd COPY only their package.json. Here npm ci will be cached as long
# as the full content of these folders don't change, yielding a decent cache reuse rate.
# Note that's it's not possible selectively COPY of mount using blobs.
# Note that it's not possible to selectively COPY or mount using blobs.
RUN --mount=type=bind,source=./superset-frontend/package.json,target=./package.json \
--mount=type=bind,source=./superset-frontend/package-lock.json,target=./package-lock.json \
--mount=type=cache,target=/root/.cache \

View File

@@ -111,7 +111,7 @@ athena = ["pyathena[pandas]>=2, <3"]
aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
bigquery = [
"pandas-gbq>=0.19.1",
"sqlalchemy-bigquery>=1.6.1",
"sqlalchemy-bigquery>=1.15.0",
"google-cloud-bigquery>=3.10.0",
]
clickhouse = ["clickhouse-connect>=0.5.14, <1.0"]

View File

@@ -11,9 +11,7 @@ apispec==6.6.1
apsw==3.50.1.0
# via shillelagh
async-timeout==4.0.3
# via
# -r requirements/base.in
# redis
# via -r requirements/base.in
attrs==25.3.0
# via
# cattrs
@@ -99,11 +97,6 @@ email-validator==2.2.0
# via flask-appbuilder
et-xmlfile==2.0.0
# via openpyxl
exceptiongroup==1.3.0
# via
# cattrs
# trio
# trio-websocket
flask==2.3.3
# via
# apache-superset (pyproject.toml)
@@ -161,7 +154,6 @@ greenlet==3.1.1
# via
# apache-superset (pyproject.toml)
# shillelagh
# sqlalchemy
gunicorn==23.0.0
# via apache-superset (pyproject.toml)
h11==0.16.0
@@ -318,7 +310,7 @@ python-dateutil==2.9.0.post0
# holidays
# pandas
# shillelagh
python-dotenv==1.1.1
python-dotenv==1.1.0
# via apache-superset (pyproject.toml)
python-geohash==0.8.5
# via apache-superset (pyproject.toml)
@@ -403,11 +395,9 @@ typing-extensions==4.14.0
# apache-superset (pyproject.toml)
# alembic
# cattrs
# exceptiongroup
# limits
# pyopenssl
# referencing
# rich
# selenium
# shillelagh
tzdata==2025.2

View File

@@ -20,10 +20,6 @@ apsw==3.50.1.0
# shillelagh
astroid==3.3.10
# via pylint
async-timeout==4.0.3
# via
# -c requirements/base.txt
# redis
attrs==25.3.0
# via
# -c requirements/base.txt
@@ -180,13 +176,6 @@ et-xmlfile==2.0.0
# via
# -c requirements/base.txt
# openpyxl
exceptiongroup==1.3.0
# via
# -c requirements/base.txt
# cattrs
# pytest
# trio
# trio-websocket
filelock==3.12.2
# via virtualenv
flask==2.3.3
@@ -324,7 +313,6 @@ greenlet==3.1.1
# apache-superset
# gevent
# shillelagh
# sqlalchemy
grpcio==1.71.0
# via
# apache-superset
@@ -682,7 +670,7 @@ python-dateutil==2.9.0.post0
# pyhive
# shillelagh
# trino
python-dotenv==1.1.1
python-dotenv==1.1.0
# via
# -c requirements/base.txt
# apache-superset
@@ -807,7 +795,7 @@ sqlalchemy==1.4.54
# shillelagh
# sqlalchemy-bigquery
# sqlalchemy-utils
sqlalchemy-bigquery==1.12.0
sqlalchemy-bigquery==1.15.0
# via apache-superset
sqlalchemy-utils==0.38.3
# via
@@ -830,11 +818,6 @@ tabulate==0.9.0
# via
# -c requirements/base.txt
# apache-superset
tomli==2.2.1
# via
# coverage
# pylint
# pytest
tomlkit==0.13.3
# via pylint
tqdm==4.67.1
@@ -857,13 +840,10 @@ typing-extensions==4.14.0
# -c requirements/base.txt
# alembic
# apache-superset
# astroid
# cattrs
# exceptiongroup
# limits
# pyopenssl
# referencing
# rich
# selenium
# shillelagh
tzdata==2025.2

View File

@@ -53,8 +53,8 @@
"@visx/scale": "^3.5.0",
"@visx/tooltip": "^3.0.0",
"@visx/xychart": "^3.5.1",
"ag-grid-community": "33.1.1",
"ag-grid-react": "33.1.1",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "34.0.2",
"antd": "^5.24.6",
"chrono-node": "^2.7.8",
"classnames": "^2.2.5",
@@ -18747,27 +18747,27 @@
}
},
"node_modules/ag-charts-types": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-11.1.1.tgz",
"integrity": "sha512-bRmUcf5VVhEEekhX8Vk0NSwa8Te8YM/zchjyYKR2CX4vDYiwoohM1Jg9RFvbIhVbLC1S6QrPEbx5v2C6RDfpSA==",
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-12.0.2.tgz",
"integrity": "sha512-AWM1Y+XW+9VMmV3AbzdVEnreh/I2C9Pmqpc2iLmtId3Xbvmv7O56DqnuDb9EXjK5uPxmyUerTP+utL13UGcztw==",
"license": "MIT"
},
"node_modules/ag-grid-community": {
"version": "33.1.1",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-33.1.1.tgz",
"integrity": "sha512-CNubIro0ipj4nfQ5WJPG9Isp7UI6MMDvNzrPdHNf3W+IoM8Uv3RUhjEn7xQqpQHuu6o/tMjrqpacipMUkhzqnw==",
"version": "34.0.2",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.0.2.tgz",
"integrity": "sha512-hVJp5vrmwHRB10YjfSOVni5YJkO/v+asLjT72S4YnIFSx8lAgyPmByNJgtojk1aJ5h6Up93jTEmGDJeuKiWWLA==",
"license": "MIT",
"dependencies": {
"ag-charts-types": "11.1.1"
"ag-charts-types": "12.0.2"
}
},
"node_modules/ag-grid-react": {
"version": "33.1.1",
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-33.1.1.tgz",
"integrity": "sha512-xJ+t2gpqUUwpFqAeDvKz/GLVR4unkOghfQBr8iIY9RAdGFarYFClJavsOa8XPVVUqEB9OIuPVFnOdtocbX0jeA==",
"version": "34.0.2",
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-34.0.2.tgz",
"integrity": "sha512-1KBXkTvwtZiYVlSuDzBkiqfHjZgsATOmpLZdAtdmsCSOOOEWai0F9zHHgBuHfyciAE4nrbQWfojkx8IdnwsKFw==",
"license": "MIT",
"dependencies": {
"ag-grid-community": "33.1.1",
"ag-grid-community": "34.0.2",
"prop-types": "^15.8.1"
},
"peerDependencies": {
@@ -61168,8 +61168,8 @@
"@react-icons/all-files": "^4.1.0",
"@types/d3-array": "^2.9.0",
"@types/react-table": "^7.7.20",
"ag-grid-community": "^33.1.1",
"ag-grid-react": "^33.1.1",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "^34.0.2",
"classnames": "^2.5.1",
"d3-array": "^2.4.0",
"lodash": "^4.17.21",
@@ -61205,8 +61205,10 @@
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@reduxjs/toolkit": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@types/react-redux": "*",
"geostyler": "^14.1.3",
"geostyler-data": "^1.0.0",
"geostyler-openlayers-parser": "^4.0.0",

View File

@@ -121,8 +121,8 @@
"@visx/scale": "^3.5.0",
"@visx/tooltip": "^3.0.0",
"@visx/xychart": "^3.5.1",
"ag-grid-community": "33.1.1",
"ag-grid-react": "33.1.1",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "34.0.2",
"antd": "^5.24.6",
"chrono-node": "^2.7.8",
"classnames": "^2.2.5",

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { render } from '@superset-ui/core/spec';
import TelemetryPixel from '.';
import { TelemetryPixel } from '.';
const OLD_ENV = process.env;

View File

@@ -39,7 +39,7 @@ interface TelemetryPixelProps {
const PIXEL_ID = '0d3461e1-abb1-4691-a0aa-5ed50de66af0';
const TelemetryPixel = ({
export const TelemetryPixel = ({
version = 'unknownVersion',
sha = 'unknownSHA',
build = 'unknownBuild',
@@ -56,4 +56,3 @@ const TelemetryPixel = ({
/>
);
};
export default TelemetryPixel;

View File

@@ -1,116 +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 { Dropdown, Icons } from '@superset-ui/core/components';
import type { MenuItem } from '@superset-ui/core/components/Menu';
import { t, useTheme } from '@superset-ui/core';
import { ThemeAlgorithm, ThemeMode } from '../../theme/types';
export interface ThemeSelectProps {
setThemeMode: (newMode: ThemeMode) => void;
tooltipTitle?: string;
themeMode: ThemeMode;
hasLocalOverride?: boolean;
onClearLocalSettings?: () => void;
allowOSPreference?: boolean;
}
const ThemeSelect: React.FC<ThemeSelectProps> = ({
setThemeMode,
tooltipTitle = 'Select theme',
themeMode,
hasLocalOverride = false,
onClearLocalSettings,
allowOSPreference = true,
}) => {
const theme = useTheme();
const handleSelect = (mode: ThemeMode) => {
setThemeMode(mode);
};
const themeIconMap: Record<ThemeAlgorithm | ThemeMode, React.ReactNode> = {
[ThemeAlgorithm.DEFAULT]: <Icons.SunOutlined />,
[ThemeAlgorithm.DARK]: <Icons.MoonOutlined />,
[ThemeMode.SYSTEM]: <Icons.FormatPainterOutlined />,
[ThemeAlgorithm.COMPACT]: <Icons.CompressOutlined />,
};
// Use different icon when local theme is active
const triggerIcon = hasLocalOverride ? (
<Icons.FormatPainterOutlined style={{ color: theme.colorErrorText }} />
) : (
themeIconMap[themeMode] || <Icons.FormatPainterOutlined />
);
const menuItems: MenuItem[] = [
{
type: 'group',
label: t('Theme'),
},
{
key: ThemeMode.DEFAULT,
label: t('Light'),
icon: <Icons.SunOutlined />,
onClick: () => handleSelect(ThemeMode.DEFAULT),
},
{
key: ThemeMode.DARK,
label: t('Dark'),
icon: <Icons.MoonOutlined />,
onClick: () => handleSelect(ThemeMode.DARK),
},
...(allowOSPreference
? [
{
key: ThemeMode.SYSTEM,
label: t('Match system'),
icon: <Icons.FormatPainterOutlined />,
onClick: () => handleSelect(ThemeMode.SYSTEM),
},
]
: []),
];
// Add clear settings option only when there's a local theme active
if (onClearLocalSettings && hasLocalOverride) {
menuItems.push(
{ type: 'divider' } as MenuItem,
{
key: 'clear-local',
label: t('Clear local theme'),
icon: <Icons.ClearOutlined />,
onClick: onClearLocalSettings,
} as MenuItem,
);
}
return (
<Dropdown
menu={{
items: menuItems,
selectedKeys: [themeMode],
}}
trigger={['hover']}
>
{triggerIcon}
</Dropdown>
);
};
export default ThemeSelect;

View File

@@ -0,0 +1,273 @@
/**
* 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 {
render,
screen,
userEvent,
waitFor,
within,
} from '@superset-ui/core/spec';
import { ThemeMode } from '@superset-ui/core';
import { Menu } from '@superset-ui/core/components';
import { ThemeSubMenu } from '.';
// Mock the translation function
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
t: (key: string) => key,
}));
describe('ThemeSubMenu', () => {
const defaultProps = {
allowOSPreference: true,
setThemeMode: jest.fn(),
themeMode: ThemeMode.DEFAULT,
hasLocalOverride: false,
onClearLocalSettings: jest.fn(),
};
const renderThemeSubMenu = (props = defaultProps) =>
render(
<Menu>
<ThemeSubMenu {...props} />
</Menu>,
);
const findMenuWithText = async (text: string) => {
await waitFor(() => {
const found = screen
.getAllByRole('menu')
.some(m => within(m).queryByText(text));
if (!found) throw new Error(`Menu with text "${text}" not yet rendered`);
});
return screen.getAllByRole('menu').find(m => within(m).queryByText(text))!;
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders Light and Dark theme options by default', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Light');
expect(within(menu!).getByText('Light')).toBeInTheDocument();
expect(within(menu!).getByText('Dark')).toBeInTheDocument();
});
it('does not render Match system option when allowOSPreference is false', async () => {
renderThemeSubMenu({ ...defaultProps, allowOSPreference: false });
userEvent.hover(await screen.findByRole('menuitem'));
await waitFor(() => {
expect(screen.queryByText('Match system')).not.toBeInTheDocument();
});
});
it('renders with allowOSPreference as true by default', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Match system');
expect(within(menu).getByText('Match system')).toBeInTheDocument();
});
it('renders clear option when both hasLocalOverride and onClearLocalSettings are provided', async () => {
const mockClear = jest.fn();
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: true,
onClearLocalSettings: mockClear,
});
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Clear local theme');
expect(within(menu).getByText('Clear local theme')).toBeInTheDocument();
});
it('does not render clear option when hasLocalOverride is false', async () => {
const mockClear = jest.fn();
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: false,
onClearLocalSettings: mockClear,
});
userEvent.hover(await screen.findByRole('menuitem'));
await waitFor(() => {
expect(screen.queryByText('Clear local theme')).not.toBeInTheDocument();
});
});
it('calls setThemeMode with DEFAULT when Light is clicked', async () => {
const mockSet = jest.fn();
renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet });
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Light');
userEvent.click(within(menu).getByText('Light'));
expect(mockSet).toHaveBeenCalledWith(ThemeMode.DEFAULT);
});
it('calls setThemeMode with DARK when Dark is clicked', async () => {
const mockSet = jest.fn();
renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet });
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Dark');
userEvent.click(within(menu).getByText('Dark'));
expect(mockSet).toHaveBeenCalledWith(ThemeMode.DARK);
});
it('calls setThemeMode with SYSTEM when Match system is clicked', async () => {
const mockSet = jest.fn();
renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet });
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Match system');
userEvent.click(within(menu).getByText('Match system'));
expect(mockSet).toHaveBeenCalledWith(ThemeMode.SYSTEM);
});
it('calls onClearLocalSettings when Clear local theme is clicked', async () => {
const mockClear = jest.fn();
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: true,
onClearLocalSettings: mockClear,
});
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Clear local theme');
userEvent.click(within(menu).getByText('Clear local theme'));
expect(mockClear).toHaveBeenCalledTimes(1);
});
it('displays sun icon for DEFAULT theme', () => {
renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.DEFAULT });
expect(screen.getByTestId('sun')).toBeInTheDocument();
});
it('displays moon icon for DARK theme', () => {
renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.DARK });
expect(screen.getByTestId('moon')).toBeInTheDocument();
});
it('displays format-painter icon for SYSTEM theme', () => {
renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.SYSTEM });
expect(screen.getByTestId('format-painter')).toBeInTheDocument();
});
it('displays override icon when hasLocalOverride is true', () => {
renderThemeSubMenu({ ...defaultProps, hasLocalOverride: true });
expect(screen.getByTestId('format-painter')).toBeInTheDocument();
});
it('renders Theme group header', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Theme');
expect(within(menu).getByText('Theme')).toBeInTheDocument();
});
it('renders sun icon for Light theme option', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Light');
const lightOption = within(menu).getByText('Light').closest('li');
expect(within(lightOption!).getByTestId('sun')).toBeInTheDocument();
});
it('renders moon icon for Dark theme option', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Dark');
const darkOption = within(menu).getByText('Dark').closest('li');
expect(within(darkOption!).getByTestId('moon')).toBeInTheDocument();
});
it('renders format-painter icon for Match system option', async () => {
renderThemeSubMenu({ ...defaultProps, allowOSPreference: true });
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Match system');
const matchOption = within(menu).getByText('Match system').closest('li');
expect(
within(matchOption!).getByTestId('format-painter'),
).toBeInTheDocument();
});
it('renders clear icon for Clear local theme option', async () => {
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: true,
onClearLocalSettings: jest.fn(),
});
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Clear local theme');
const clearOption = within(menu)
.getByText('Clear local theme')
.closest('li');
expect(within(clearOption!).getByTestId('clear')).toBeInTheDocument();
});
it('renders divider before clear option when clear option is present', async () => {
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: true,
onClearLocalSettings: jest.fn(),
});
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Clear local theme');
const divider = within(menu).queryByRole('separator');
expect(divider).toBeInTheDocument();
});
it('does not render divider when clear option is not present', async () => {
renderThemeSubMenu({ ...defaultProps });
userEvent.hover(await screen.findByRole('menuitem'));
const divider = document.querySelector('.ant-menu-item-divider');
expect(divider).toBeNull();
});
});

View File

@@ -0,0 +1,170 @@
/**
* 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 { useMemo } from 'react';
import { Icons, Menu } from '@superset-ui/core/components';
import {
css,
styled,
t,
ThemeMode,
useTheme,
ThemeAlgorithm,
} from '@superset-ui/core';
const StyledThemeSubMenu = styled(Menu.SubMenu)`
${({ theme }) => css`
[data-icon='caret-down'] {
color: ${theme.colorIcon};
font-size: ${theme.fontSizeXS}px;
margin-left: ${theme.sizeUnit}px;
}
&.ant-menu-submenu-active {
.ant-menu-title-content {
color: ${theme.colorPrimary};
}
}
`}
`;
const StyledThemeSubMenuItem = styled(Menu.Item)<{ selected: boolean }>`
${({ theme, selected }) => css`
&:hover {
color: ${theme.colorPrimary} !important;
cursor: pointer !important;
}
${selected &&
css`
background-color: ${theme.colors.primary.light4} !important;
color: ${theme.colors.primary.dark1} !important;
`}
`}
`;
export interface ThemeSubMenuOption {
key: ThemeMode;
label: string;
icon: React.ReactNode;
onClick: () => void;
}
export interface ThemeSubMenuProps {
setThemeMode: (newMode: ThemeMode) => void;
themeMode: ThemeMode;
hasLocalOverride?: boolean;
onClearLocalSettings?: () => void;
allowOSPreference?: boolean;
}
export const ThemeSubMenu: React.FC<ThemeSubMenuProps> = ({
setThemeMode,
themeMode,
hasLocalOverride = false,
onClearLocalSettings,
allowOSPreference = true,
}: ThemeSubMenuProps) => {
const theme = useTheme();
const handleSelect = (mode: ThemeMode) => {
setThemeMode(mode);
};
const themeIconMap: Record<ThemeAlgorithm | ThemeMode, React.ReactNode> =
useMemo(
() => ({
[ThemeAlgorithm.DEFAULT]: <Icons.SunOutlined />,
[ThemeAlgorithm.DARK]: <Icons.MoonOutlined />,
[ThemeMode.SYSTEM]: <Icons.FormatPainterOutlined />,
[ThemeAlgorithm.COMPACT]: <Icons.CompressOutlined />,
}),
[],
);
const selectedThemeModeIcon = useMemo(
() =>
hasLocalOverride ? (
<Icons.FormatPainterOutlined
style={{ color: theme.colors.error.base }}
/>
) : (
themeIconMap[themeMode]
),
[hasLocalOverride, theme.colors.error.base, themeIconMap, themeMode],
);
const themeOptions: ThemeSubMenuOption[] = [
{
key: ThemeMode.DEFAULT,
label: t('Light'),
icon: <Icons.SunOutlined />,
onClick: () => handleSelect(ThemeMode.DEFAULT),
},
{
key: ThemeMode.DARK,
label: t('Dark'),
icon: <Icons.MoonOutlined />,
onClick: () => handleSelect(ThemeMode.DARK),
},
...(allowOSPreference
? [
{
key: ThemeMode.SYSTEM,
label: t('Match system'),
icon: <Icons.FormatPainterOutlined />,
onClick: () => handleSelect(ThemeMode.SYSTEM),
},
]
: []),
];
// Add clear settings option only when there's a local theme active
const clearOption =
onClearLocalSettings && hasLocalOverride
? {
key: 'clear-local',
label: t('Clear local theme'),
icon: <Icons.ClearOutlined />,
onClick: onClearLocalSettings,
}
: null;
return (
<StyledThemeSubMenu
key="theme-sub-menu"
title={selectedThemeModeIcon}
icon={<Icons.CaretDownOutlined iconSize="xs" />}
>
<Menu.ItemGroup title={t('Theme')} />
{themeOptions.map(option => (
<StyledThemeSubMenuItem
key={option.key}
onClick={option.onClick}
selected={option.key === themeMode}
>
{option.icon} {option.label}
</StyledThemeSubMenuItem>
))}
{clearOption && [
<Menu.Divider key="theme-divider" />,
<Menu.Item key={clearOption.key} onClick={clearOption.onClick}>
{clearOption.icon} {clearOption.label}
</Menu.Item>,
]}
</StyledThemeSubMenu>
);
};

View File

@@ -164,6 +164,8 @@ export * from './Steps';
export * from './Table';
export * from './TableView';
export * from './Tag';
export * from './TelemetryPixel';
export * from './ThemeSubMenu';
export * from './UnsavedChangesModal';
export * from './constants';
export * from './Result';

View File

@@ -26,7 +26,9 @@ import {
type ThemeStorage,
type ThemeControllerOptions,
type ThemeContextType,
type SupersetThemeConfig,
ThemeAlgorithm,
ThemeMode,
} from './types';
export {
@@ -66,7 +68,16 @@ const themeObject: Theme = Theme.fromConfig({
const { theme } = themeObject;
const supersetTheme = theme;
export { Theme, themeObject, styled, theme, supersetTheme };
export {
Theme,
ThemeAlgorithm,
ThemeMode,
themeObject,
styled,
theme,
supersetTheme,
};
export type {
SupersetTheme,
SerializableThemeConfig,
@@ -74,6 +85,7 @@ export type {
ThemeStorage,
ThemeControllerOptions,
ThemeContextType,
SupersetThemeConfig,
};
// Export theme utility functions

View File

@@ -429,3 +429,16 @@ export interface ThemeContextType {
canDetectOSPreference: () => boolean;
createDashboardThemeProvider: (themeId: string) => Promise<Theme | null>;
}
/**
* Configuration object for complete theme setup including default, dark themes and settings
*/
export interface SupersetThemeConfig {
theme_default: AnyThemeConfig;
theme_dark?: AnyThemeConfig;
theme_settings?: {
enforced?: boolean;
allowSwitching?: boolean;
allowOSPreference?: boolean;
};
}

View File

@@ -27,8 +27,8 @@
"@react-icons/all-files": "^4.1.0",
"@types/d3-array": "^2.9.0",
"@types/react-table": "^7.7.20",
"ag-grid-community": "^33.1.1",
"ag-grid-react": "^33.1.1",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "^34.0.2",
"classnames": "^2.5.1",
"d3-array": "^2.4.0",
"lodash": "^4.17.21",

View File

@@ -34,6 +34,12 @@ const parseLabel = value => {
return String(value);
};
function displayCell(value, allowRenderHtml) {
if (allowRenderHtml && typeof value === 'string') {
return safeHtmlSpan(value);
}
return parseLabel(value);
}
function displayHeaderCell(
needToggle,
ArrowIcon,
@@ -742,7 +748,7 @@ export class TableRenderer extends Component {
onContextMenu={e => this.props.onContextMenu(e, colKey, rowKey)}
style={style}
>
{agg.format(aggValue)}
{displayCell(agg.format(aggValue), allowRenderHtml)}
</td>
);
});
@@ -759,7 +765,7 @@ export class TableRenderer extends Component {
onClick={rowTotalCallbacks[flatRowKey]}
onContextMenu={e => this.props.onContextMenu(e, undefined, rowKey)}
>
{agg.format(aggValue)}
{displayCell(agg.format(aggValue), allowRenderHtml)}
</td>
);
}
@@ -823,7 +829,7 @@ export class TableRenderer extends Component {
onContextMenu={e => this.props.onContextMenu(e, colKey, undefined)}
style={{ padding: '5px' }}
>
{agg.format(aggValue)}
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
</td>
);
});
@@ -840,7 +846,7 @@ export class TableRenderer extends Component {
onClick={grandTotalCallback}
onContextMenu={e => this.props.onContextMenu(e, undefined, undefined)}
>
{agg.format(aggValue)}
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
</td>
);
}

View File

@@ -97,6 +97,10 @@ export const COST_ESTIMATE_STARTED = 'COST_ESTIMATE_STARTED';
export const COST_ESTIMATE_RETURNED = 'COST_ESTIMATE_RETURNED';
export const COST_ESTIMATE_FAILED = 'COST_ESTIMATE_FAILED';
export const COST_THRESHOLD_CHECK_STARTED = 'COST_THRESHOLD_CHECK_STARTED';
export const COST_THRESHOLD_CHECK_RETURNED = 'COST_THRESHOLD_CHECK_RETURNED';
export const COST_THRESHOLD_CHECK_FAILED = 'COST_THRESHOLD_CHECK_FAILED';
export const CREATE_DATASOURCE_STARTED = 'CREATE_DATASOURCE_STARTED';
export const CREATE_DATASOURCE_SUCCESS = 'CREATE_DATASOURCE_SUCCESS';
export const CREATE_DATASOURCE_FAILED = 'CREATE_DATASOURCE_FAILED';
@@ -233,6 +237,45 @@ export function estimateQueryCost(queryEditor) {
};
}
export function checkCostThreshold(queryEditor) {
return (dispatch, getState) => {
const { dbId, catalog, schema, sql, selectedText, templateParams } =
getUpToDateQuery(getState(), queryEditor);
const requestSql = selectedText || sql;
const postPayload = {
database_id: dbId,
catalog,
schema,
sql: requestSql,
template_params: JSON.parse(templateParams || '{}'),
};
return Promise.all([
dispatch({ type: COST_THRESHOLD_CHECK_STARTED, query: queryEditor }),
SupersetClient.post({
endpoint: '/api/v1/sqllab/check_cost_threshold/',
body: JSON.stringify(postPayload),
headers: { 'Content-Type': 'application/json' },
})
.then(({ json }) =>
dispatch({ type: COST_THRESHOLD_CHECK_RETURNED, query: queryEditor, json }),
)
.catch(response =>
getClientErrorObject(response).then(error => {
const message =
error.error ||
error.statusText ||
t('Failed at checking cost threshold');
return dispatch({
type: COST_THRESHOLD_CHECK_FAILED,
query: queryEditor,
error: message,
});
}),
),
]);
};
}
export function clearInactiveQueries(interval) {
return { type: CLEAR_INACTIVE_QUERIES, interval };
}

View File

@@ -0,0 +1,131 @@
/**
* 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 { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '@superset-ui/core';
import { theme } from 'src/preamble';
import CostWarningModal from './index';
const mockProps = {
visible: true,
onHide: jest.fn(),
onProceed: jest.fn(),
warningMessage: 'This query will scan 10 GB of data, which exceeds the threshold of 5 GB.',
thresholdInfo: {
bytes_threshold: 5 * 1024 ** 3, // 5 GB
estimated_bytes: 10 * 1024 ** 3, // 10 GB
},
};
const renderWithTheme = (ui: React.ReactElement) =>
render(<ThemeProvider theme={theme}>{ui}</ThemeProvider>);
describe('CostWarningModal', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders with warning message', () => {
renderWithTheme(<CostWarningModal {...mockProps} />);
expect(screen.getByText('Query Cost Warning')).toBeInTheDocument();
expect(screen.getByText(mockProps.warningMessage)).toBeInTheDocument();
});
it('shows threshold details when provided', () => {
renderWithTheme(<CostWarningModal {...mockProps} />);
expect(screen.getByText('Threshold Details:')).toBeInTheDocument();
expect(screen.getByText('Data to scan:')).toBeInTheDocument();
expect(screen.getByText('10.0 GB')).toBeInTheDocument();
expect(screen.getByText('5.0 GB')).toBeInTheDocument();
});
it('disables proceed button until checkbox is checked', () => {
renderWithTheme(<CostWarningModal {...mockProps} />);
const proceedButton = screen.getByText('Run Query Anyway');
const checkbox = screen.getByRole('checkbox');
expect(proceedButton).toBeDisabled();
fireEvent.click(checkbox);
expect(proceedButton).not.toBeDisabled();
});
it('calls onProceed when proceed button is clicked with checkbox checked', () => {
renderWithTheme(<CostWarningModal {...mockProps} />);
const checkbox = screen.getByRole('checkbox');
const proceedButton = screen.getByText('Run Query Anyway');
fireEvent.click(checkbox);
fireEvent.click(proceedButton);
expect(mockProps.onProceed).toHaveBeenCalledTimes(1);
});
it('calls onHide when cancel button is clicked', () => {
renderWithTheme(<CostWarningModal {...mockProps} />);
const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton);
expect(mockProps.onHide).toHaveBeenCalledTimes(1);
});
it('renders without threshold details when not provided', () => {
const propsWithoutThreshold = {
...mockProps,
thresholdInfo: undefined,
};
renderWithTheme(<CostWarningModal {...propsWithoutThreshold} />);
expect(screen.queryByText('Threshold Details:')).not.toBeInTheDocument();
});
it('shows default message when warningMessage is null', () => {
const propsWithNoMessage = {
...mockProps,
warningMessage: null,
};
renderWithTheme(<CostWarningModal {...propsWithNoMessage} />);
expect(screen.getByText('This query may be expensive to run.')).toBeInTheDocument();
});
it('handles cost threshold details', () => {
const propsWithCostThreshold = {
...mockProps,
thresholdInfo: {
cost_threshold: 100,
estimated_cost: 250,
},
};
renderWithTheme(<CostWarningModal {...propsWithCostThreshold} />);
expect(screen.getByText('Estimated cost:')).toBeInTheDocument();
expect(screen.getByText('250')).toBeInTheDocument();
expect(screen.getByText('Cost threshold:')).toBeInTheDocument();
expect(screen.getByText('100')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,166 @@
/**
* 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 { styled, t } from '@superset-ui/core';
import { Button, Modal, Checkbox } from '@superset-ui/core/components';
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
const StyledModal = styled(Modal)`
.ant-modal-body {
padding: 24px;
}
`;
const WarningContent = styled.div`
margin: 16px 0;
font-size: 14px;
line-height: 1.5;
`;
const DetailsSection = styled.div`
margin: 16px 0;
padding: 12px;
background-color: ${({ theme }) => theme.colors.grayscale.light4};
border-radius: 4px;
font-size: 12px;
`;
const CheckboxWrapper = styled.div`
margin: 16px 0;
`;
interface CostWarningModalProps {
visible: boolean;
onHide: () => void;
onProceed: () => void;
warningMessage: string | null;
thresholdInfo?: {
bytes_threshold?: number;
estimated_bytes?: number;
cost_threshold?: number;
estimated_cost?: number;
};
}
export default function CostWarningModal({
visible,
onHide,
onProceed,
warningMessage,
thresholdInfo,
}: CostWarningModalProps) {
const [proceedAnyway, setProceedAnyway] = useState(false);
const handleProceed = () => {
if (proceedAnyway) {
onProceed();
}
};
const formatBytes = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
if (bytes < 1024 ** 5) return `${(bytes / 1024 ** 4).toFixed(1)} TB`;
return `${(bytes / 1024 ** 5).toFixed(1)} PB`;
};
const renderThresholdDetails = () => {
if (!thresholdInfo) return null;
const details = [];
if (thresholdInfo.bytes_threshold && thresholdInfo.estimated_bytes) {
details.push(
<div key="bytes">
<strong>{t('Data to scan:')}</strong> {formatBytes(thresholdInfo.estimated_bytes)}
<br />
<strong>{t('Threshold:')}</strong> {formatBytes(thresholdInfo.bytes_threshold)}
</div>
);
}
if (thresholdInfo.cost_threshold && thresholdInfo.estimated_cost) {
details.push(
<div key="cost">
<strong>{t('Estimated cost:')}</strong> {thresholdInfo.estimated_cost}
<br />
<strong>{t('Cost threshold:')}</strong> {thresholdInfo.cost_threshold}
</div>
);
}
return details.length > 0 ? (
<DetailsSection>
<div style={{ marginBottom: '8px' }}>
<strong>{t('Threshold Details:')}</strong>
</div>
{details.map((detail, index) => (
<div key={index} style={{ marginBottom: index < details.length - 1 ? '8px' : '0' }}>
{detail}
</div>
))}
</DetailsSection>
) : null;
};
return (
<StyledModal
show={visible}
onHide={onHide}
title={
<ModalTitleWithIcon
icon="exclamation-triangle"
title={t('Query Cost Warning')}
/>
}
footer={
<>
<Button onClick={onHide}>
{t('Cancel')}
</Button>
<Button
buttonStyle="primary"
onClick={handleProceed}
disabled={!proceedAnyway}
>
{t('Run Query Anyway')}
</Button>
</>
}
>
<WarningContent>
{warningMessage || t('This query may be expensive to run.')}
</WarningContent>
{renderThresholdDetails()}
<CheckboxWrapper>
<Checkbox
checked={proceedAnyway}
onChange={(e) => setProceedAnyway(e.target.checked)}
>
{t('I understand the cost implications and want to proceed anyway')}
</Checkbox>
</CheckboxWrapper>
</StyledModal>
);
}

View File

@@ -71,6 +71,7 @@ import {
addNewQueryEditor,
CtasEnum,
estimateQueryCost,
checkCostThreshold,
persistEditorHeight,
postStopQuery,
queryEditorSetAutorun,
@@ -123,6 +124,7 @@ import SouthPane from '../SouthPane';
import SaveQuery, { QueryPayload } from '../SaveQuery';
import ScheduleQueryButton from '../ScheduleQueryButton';
import EstimateQueryCostButton from '../EstimateQueryCostButton';
import CostWarningModal from '../CostWarningModal';
import ShareSqlLabQuery from '../ShareSqlLabQuery';
import SqlEditorLeftBar from '../SqlEditorLeftBar';
import AceEditorWrapper from '../AceEditorWrapper';
@@ -270,6 +272,7 @@ const SqlEditor: FC<Props> = ({
hideLeftBar,
currentQueryEditorId,
hasSqlStatement,
costThresholdData,
} = useSelector<
SqlLabRootState,
{
@@ -278,8 +281,9 @@ const SqlEditor: FC<Props> = ({
hideLeftBar?: boolean;
currentQueryEditorId: QueryEditor['id'];
hasSqlStatement: boolean;
costThresholdData?: any;
}
>(({ sqlLab: { unsavedQueryEditor, databases, queries, tabHistory } }) => {
>(({ sqlLab: { unsavedQueryEditor, databases, queries, tabHistory, queryCostThresholds } }) => {
let { dbId, latestQueryId, hideLeftBar } = queryEditor;
if (unsavedQueryEditor?.id === queryEditor.id) {
dbId = unsavedQueryEditor.dbId || dbId;
@@ -295,6 +299,7 @@ const SqlEditor: FC<Props> = ({
latestQuery: queries[latestQueryId || ''],
hideLeftBar,
currentQueryEditorId: tabHistory.slice(-1)[0],
costThresholdData: queryCostThresholds[queryEditor.id],
};
}, shallowEqual);
@@ -317,6 +322,11 @@ const SqlEditor: FC<Props> = ({
);
const [showCreateAsModal, setShowCreateAsModal] = useState(false);
const [createAs, setCreateAs] = useState('');
const [showCostWarningModal, setShowCostWarningModal] = useState(false);
const [costWarningData, setCostWarningData] = useState<{
warningMessage: string | null;
thresholdInfo?: any;
} | null>(null);
const currentSQL = useRef<string>(queryEditor.sql);
const showEmptyState = useMemo(
() => !database || isEmpty(database),
@@ -330,7 +340,69 @@ const SqlEditor: FC<Props> = ({
const isTempId = (value: unknown): boolean => Number.isNaN(Number(value));
const checkCostThresholdAndRun = useCallback(
(ctasArg = false, ctas_method = CtasEnum.Table) => {
if (!database) {
return;
}
// Check if cost threshold checking is enabled via feature flag or configuration
// For now, we'll implement the logic directly
dispatch(checkCostThreshold(queryEditor)).then(([_, response]) => {
if (response && response.json) {
const { exceeds_threshold, formatted_warning, threshold_info } = response.json;
if (exceeds_threshold && formatted_warning) {
// Show warning modal
setCostWarningData({
warningMessage: formatted_warning,
thresholdInfo: threshold_info,
});
setShowCostWarningModal(true);
return;
}
}
// If no threshold exceeded or checking failed, proceed with query
dispatch(
runQueryFromSqlEditor(
database,
queryEditor,
defaultQueryLimit,
ctasArg ? ctas : '',
ctasArg,
ctas_method,
),
);
dispatch(setActiveSouthPaneTab('Results'));
}).catch(() => {
// If cost checking fails, proceed with query anyway
dispatch(
runQueryFromSqlEditor(
database,
queryEditor,
defaultQueryLimit,
ctasArg ? ctas : '',
ctasArg,
ctas_method,
),
);
dispatch(setActiveSouthPaneTab('Results'));
});
},
[ctas, database, defaultQueryLimit, dispatch, queryEditor],
);
const startQuery = useCallback(
(ctasArg = false, ctas_method = CtasEnum.Table) => {
// Use cost threshold checking for regular queries
checkCostThresholdAndRun(ctasArg, ctas_method);
},
[checkCostThresholdAndRun],
);
// Direct query execution without cost checking (for modal "proceed anyway")
const executeQueryDirectly = useCallback(
(ctasArg = false, ctas_method = CtasEnum.Table) => {
if (!database) {
return;
@@ -1121,6 +1193,20 @@ const SqlEditor: FC<Props> = ({
<span>{t('Name')}</span>
<Input placeholder={createModalPlaceHolder} onChange={ctasChanged} />
</Modal>
<CostWarningModal
visible={showCostWarningModal}
onHide={() => {
setShowCostWarningModal(false);
setCostWarningData(null);
}}
onProceed={() => {
setShowCostWarningModal(false);
setCostWarningData(null);
executeQueryDirectly();
}}
warningMessage={costWarningData?.warningMessage || null}
thresholdInfo={costWarningData?.thresholdInfo}
/>
</StyledSqlEditor>
);
};

View File

@@ -264,6 +264,7 @@ export default function getInitialState({
queriesLastUpdate: Date.now(),
editorTabLastUpdatedAt,
queryCostEstimates: {},
queryCostThresholds: {},
unsavedQueryEditor,
lastUpdatedActiveTab,
destroyedQueryEditors,

View File

@@ -315,6 +315,51 @@ export default function sqlLabReducer(state = {}, action) {
},
};
},
[actions.COST_THRESHOLD_CHECK_STARTED]() {
return {
...state,
queryCostThresholds: {
...state.queryCostThresholds,
[action.query.id]: {
completed: false,
exceedsThreshold: false,
thresholdInfo: null,
formattedWarning: null,
error: null,
},
},
};
},
[actions.COST_THRESHOLD_CHECK_RETURNED]() {
return {
...state,
queryCostThresholds: {
...state.queryCostThresholds,
[action.query.id]: {
completed: true,
exceedsThreshold: action.json.exceeds_threshold,
thresholdInfo: action.json.threshold_info,
formattedWarning: action.json.formatted_warning,
error: null,
},
},
};
},
[actions.COST_THRESHOLD_CHECK_FAILED]() {
return {
...state,
queryCostThresholds: {
...state.queryCostThresholds,
[action.query.id]: {
completed: false,
exceedsThreshold: false,
thresholdInfo: null,
formattedWarning: null,
error: action.error,
},
},
};
},
[actions.START_QUERY]() {
let newState = { ...state };
if (action.query.sqlEditorId) {

View File

@@ -0,0 +1,93 @@
/**
* 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 { Route } from 'react-router-dom';
import { getExtensionsRegistry } from '@superset-ui/core';
import { Provider as ReduxProvider } from 'react-redux';
import { QueryParamProvider } from 'use-query-params';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { FlashProvider, DynamicPluginProvider } from 'src/components';
import { EmbeddedUiConfigProvider } from 'src/components/UiConfigContext';
import { SupersetThemeProvider } from 'src/theme/ThemeProvider';
import { ThemeController } from 'src/theme/ThemeController';
import type { ThemeStorage } from '@superset-ui/core';
import { store } from 'src/views/store';
import getBootstrapData from 'src/utils/getBootstrapData';
/**
* In-memory implementation of ThemeStorage interface for embedded contexts.
* Persistent storage is not required for embedded dashboards.
*/
class ThemeMemoryStorageAdapter implements ThemeStorage {
private storage = new Map<string, string>();
getItem(key: string): string | null {
return this.storage.get(key) || null;
}
setItem(key: string, value: string): void {
this.storage.set(key, value);
}
removeItem(key: string): void {
this.storage.delete(key);
}
}
const themeController = new ThemeController({
storage: new ThemeMemoryStorageAdapter(),
});
export const getThemeController = (): ThemeController => themeController;
const { common } = getBootstrapData();
const extensionsRegistry = getExtensionsRegistry();
export const EmbeddedContextProviders: React.FC = ({ children }) => {
const RootContextProviderExtension = extensionsRegistry.get(
'root.context.provider',
);
return (
<SupersetThemeProvider themeController={themeController}>
<ReduxProvider store={store}>
<DndProvider backend={HTML5Backend}>
<FlashProvider messages={common.flash_messages}>
<EmbeddedUiConfigProvider>
<DynamicPluginProvider>
<QueryParamProvider
ReactRouterRoute={Route}
stringifyOptions={{ encode: false }}
>
{RootContextProviderExtension ? (
<RootContextProviderExtension>
{children}
</RootContextProviderExtension>
) : (
children
)}
</QueryParamProvider>
</DynamicPluginProvider>
</EmbeddedUiConfigProvider>
</FlashProvider>
</DndProvider>
</ReduxProvider>
</SupersetThemeProvider>
);
};

View File

@@ -21,20 +21,27 @@ import 'src/public-path';
import { lazy, Suspense } from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import { makeApi, t, logging, themeObject } from '@superset-ui/core';
import {
type SupersetThemeConfig,
makeApi,
t,
logging,
} from '@superset-ui/core';
import Switchboard from '@superset-ui/switchboard';
import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData';
import setupClient from 'src/setup/setupClient';
import setupPlugins from 'src/setup/setupPlugins';
import { useUiConfig } from 'src/components/UiConfigContext';
import { RootContextProviders } from 'src/views/RootContextProviders';
import { store, USER_LOADED } from 'src/views/store';
import { Loading } from '@superset-ui/core/components';
import { ErrorBoundary } from 'src/components';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import { AnyThemeConfig } from 'packages/superset-ui-core/src/theme/types';
import {
EmbeddedContextProviders,
getThemeController,
} from './EmbeddedContextProviders';
import { embeddedApi } from './api';
import { getDataMaskChangeTrigger } from './utils';
@@ -44,9 +51,7 @@ const debugMode = process.env.WEBPACK_MODE === 'development';
const bootstrapData = getBootstrapData();
function log(...info: unknown[]) {
if (debugMode) {
logging.debug(`[superset]`, ...info);
}
if (debugMode) logging.debug(`[superset]`, ...info);
}
const LazyDashboardPage = lazy(
@@ -85,12 +90,12 @@ const EmbededLazyDashboardPage = () => {
const EmbeddedRoute = () => (
<Suspense fallback={<Loading />}>
<RootContextProviders>
<EmbeddedContextProviders>
<ErrorBoundary>
<EmbededLazyDashboardPage />
</ErrorBoundary>
<ToastContainer position="top" />
</RootContextProviders>
</EmbeddedContextProviders>
</Suspense>
);
@@ -245,12 +250,13 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
Switchboard.defineMethod('getDataMask', embeddedApi.getDataMask);
Switchboard.defineMethod(
'setThemeConfig',
(payload: { themeConfig: AnyThemeConfig }) => {
(payload: { themeConfig: SupersetThemeConfig }) => {
const { themeConfig } = payload;
log('Received setThemeConfig request:', themeConfig);
try {
themeObject.setConfig(themeConfig);
const themeController = getThemeController();
themeController.setThemeConfig(themeConfig);
return { success: true, message: 'Theme applied' };
} catch (error) {
logging.error('Failed to apply theme config:', error);
@@ -258,8 +264,22 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
}
},
);
Switchboard.start();
}
});
// Clean up theme controller on page unload
window.addEventListener('beforeunload', () => {
try {
const controller = getThemeController();
if (controller) {
log('Destroying theme controller');
controller.destroy();
}
} catch (error) {
logging.warn('Failed to destroy theme controller:', error);
}
});
log('embed page is ready to receive messages');

View File

@@ -17,13 +17,11 @@
* under the License.
*/
import { Fragment, useState, useEffect, FC, PureComponent } from 'react';
import rison from 'rison';
import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { useQueryParams, BooleanParam } from 'use-query-params';
import { get, isEmpty } from 'lodash';
import {
t,
styled,
@@ -33,10 +31,15 @@ import {
getExtensionsRegistry,
useTheme,
} from '@superset-ui/core';
import { Menu } from '@superset-ui/core/components/Menu';
import { Label, Tooltip } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { Typography } from '@superset-ui/core/components/Typography';
import {
Label,
Tooltip,
ThemeSubMenu,
Menu,
Icons,
Typography,
TelemetryPixel,
} from '@superset-ui/core/components';
import { ensureAppRoot } from 'src/utils/pathUtils';
import { findPermission } from 'src/utils/findPermission';
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
@@ -49,9 +52,7 @@ import { RootState } from 'src/dashboard/types';
import DatabaseModal from 'src/features/databases/DatabaseModal';
import UploadDataModal from 'src/features/databases/UploadDataModel';
import { uploadUserPerms } from 'src/views/CRUD/utils';
import TelemetryPixel from '@superset-ui/core/components/TelemetryPixel';
import { useThemeContext } from 'src/theme/ThemeProvider';
import ThemeSelect from '@superset-ui/core/components/ThemeSelect';
import LanguagePicker from './LanguagePicker';
import {
ExtensionConfigs,
@@ -138,6 +139,7 @@ const RightMenu = ({
datasetAdded?: boolean;
}) => void;
}) => {
const theme = useTheme();
const user = useSelector<any, UserWithPermissionsAndRoles>(
state => state.user,
);
@@ -371,7 +373,6 @@ const RightMenu = ({
localStorage.removeItem('redux');
};
const theme = useTheme();
return (
<StyledDiv align={align}>
{canDatabase && (
@@ -493,16 +494,15 @@ const RightMenu = ({
})}
</StyledSubMenu>
)}
{canSetMode() && (
<span>
<ThemeSelect
setThemeMode={setThemeMode}
themeMode={themeMode}
hasLocalOverride={hasDevOverride()}
onClearLocalSettings={clearLocalOverrides}
allowOSPreference={canDetectOSPreference()}
/>
</span>
<ThemeSubMenu
setThemeMode={setThemeMode}
themeMode={themeMode}
hasLocalOverride={hasDevOverride()}
onClearLocalSettings={clearLocalOverrides}
allowOSPreference={canDetectOSPreference()}
/>
)}
<StyledSubMenu

View File

@@ -17,13 +17,15 @@
* under the License.
*/
import {
type AnyThemeConfig,
type SupersetTheme,
type SupersetThemeConfig,
type ThemeControllerOptions,
type ThemeStorage,
Theme,
AnyThemeConfig,
ThemeStorage,
ThemeControllerOptions,
ThemeMode,
themeObject as supersetThemeObject,
} from '@superset-ui/core';
import { SupersetTheme, ThemeMode } from '@superset-ui/core/theme/types';
import {
getAntdConfig,
normalizeThemeConfig,
@@ -94,7 +96,7 @@ export class ThemeController {
private currentMode: ThemeMode;
private readonly hasBootstrapThemes: boolean;
private hasCustomThemes: boolean;
private onChangeCallbacks: Set<(theme: Theme) => void> = new Set();
@@ -109,15 +111,13 @@ export class ThemeController {
private dashboardCrudTheme: AnyThemeConfig | null = null;
constructor(options: ThemeControllerOptions = {}) {
const {
storage = new LocalStorageAdapter(),
modeStorageKey = STORAGE_KEYS.THEME_MODE,
themeObject = supersetThemeObject,
defaultTheme = (supersetThemeObject.theme as AnyThemeConfig) ?? {},
onChange = null,
} = options;
constructor({
storage = new LocalStorageAdapter(),
modeStorageKey = STORAGE_KEYS.THEME_MODE,
themeObject = supersetThemeObject,
defaultTheme = (supersetThemeObject.theme as AnyThemeConfig) ?? {},
onChange = undefined,
}: ThemeControllerOptions = {}) {
this.storage = storage;
this.modeStorageKey = modeStorageKey;
@@ -129,14 +129,14 @@ export class ThemeController {
bootstrapDefaultTheme,
bootstrapDarkTheme,
bootstrapThemeSettings,
hasBootstrapThemes,
hasCustomThemes,
}: BootstrapThemeData = this.loadBootstrapData();
this.hasBootstrapThemes = hasBootstrapThemes;
this.hasCustomThemes = hasCustomThemes;
this.themeSettings = bootstrapThemeSettings || {};
// Set themes based on bootstrap data availability
if (this.hasBootstrapThemes) {
if (this.hasCustomThemes) {
this.darkTheme = bootstrapDarkTheme || bootstrapDefaultTheme || null;
this.defaultTheme =
bootstrapDefaultTheme || bootstrapDarkTheme || defaultTheme;
@@ -424,6 +424,42 @@ export class ThemeController {
return allowOSPreference === true;
}
/**
* Sets an entire new theme configuration, replacing all existing theme data and settings.
* This method is designed for use cases like embedded dashboards where themes are provided
* dynamically from external sources.
* @param config - The complete theme configuration object
*/
public setThemeConfig(config: SupersetThemeConfig): void {
this.defaultTheme = config.theme_default;
this.darkTheme = config.theme_dark || null;
this.hasCustomThemes = true;
this.themeSettings = {
enforced: config.theme_settings?.enforced ?? false,
allowSwitching: config.theme_settings?.allowSwitching ?? true,
allowOSPreference: config.theme_settings?.allowOSPreference ?? true,
};
let newMode: ThemeMode;
try {
this.validateModeUpdatePermission(this.currentMode);
const hasRequiredTheme = this.isValidThemeMode(this.currentMode);
newMode = hasRequiredTheme
? this.currentMode
: this.determineInitialMode();
} catch {
newMode = this.determineInitialMode();
}
this.currentMode = newMode;
const themeToApply =
this.getThemeForMode(this.currentMode) || this.defaultTheme;
this.updateTheme(themeToApply);
}
/**
* Handles system theme changes with error recovery.
*/
@@ -547,7 +583,7 @@ export class ThemeController {
bootstrapDefaultTheme: hasValidDefault ? defaultTheme : null,
bootstrapDarkTheme: hasValidDark ? darkTheme : null,
bootstrapThemeSettings: hasValidSettings ? themeSettings : null,
hasBootstrapThemes: hasValidDefault || hasValidDark,
hasCustomThemes: hasValidDefault || hasValidDark,
};
}
@@ -607,7 +643,7 @@ export class ThemeController {
resolvedMode = ThemeController.getSystemPreferredMode();
}
if (!this.hasBootstrapThemes) {
if (!this.hasCustomThemes) {
const baseTheme = this.defaultTheme.token as Partial<SupersetTheme>;
return getAntdConfig(baseTheme, resolvedMode === ThemeMode.DARK);
}

View File

@@ -24,8 +24,12 @@ import {
useMemo,
useState,
} from 'react';
import { Theme, AnyThemeConfig, ThemeContextType } from '@superset-ui/core';
import { ThemeMode } from '@superset-ui/core/theme/types';
import {
type AnyThemeConfig,
type ThemeContextType,
Theme,
ThemeMode,
} from '@superset-ui/core';
import { ThemeController } from './ThemeController';
const ThemeContext = createContext<ThemeContextType | null>(null);

View File

@@ -17,12 +17,17 @@
* under the License.
*/
import { theme as antdThemeImport } from 'antd';
import { Theme } from '@superset-ui/core';
import {
type AnyThemeConfig,
type SupersetThemeConfig,
Theme,
ThemeAlgorithm,
ThemeMode,
} from '@superset-ui/core';
import type {
BootstrapThemeDataConfig,
CommonBootstrapData,
} from 'src/types/bootstrapTypes';
import { ThemeAlgorithm, ThemeMode } from '@superset-ui/core/theme/types';
import getBootstrapData from 'src/utils/getBootstrapData';
import { LocalStorageAdapter, ThemeController } from '../ThemeController';
@@ -43,7 +48,7 @@ const mockThemeFromConfig = jest.fn();
const mockSetConfig = jest.fn();
// Mock data constants
const DEFAULT_THEME = {
const DEFAULT_THEME: AnyThemeConfig = {
token: {
colorBgBase: '#ededed',
colorTextBase: '#120f0f',
@@ -55,7 +60,7 @@ const DEFAULT_THEME = {
},
};
const DARK_THEME = {
const DARK_THEME: AnyThemeConfig = {
token: {
colorBgBase: '#141118',
colorTextBase: '#fdc7c7',
@@ -65,7 +70,7 @@ const DARK_THEME = {
colorSuccess: '#3c7c1b',
colorWarning: '#dc9811',
},
algorithm: ThemeMode.DARK,
algorithm: ThemeAlgorithm.DARK,
};
const THEME_SETTINGS = {
@@ -1049,4 +1054,298 @@ describe('ThemeController', () => {
);
});
});
describe('setThemeConfig', () => {
beforeEach(() => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: {},
dark: {},
settings: {},
}),
);
controller = new ThemeController({
themeObject: mockThemeObject,
defaultTheme: { token: {} },
});
jest.clearAllMocks();
});
it('should set complete theme configuration', () => {
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
theme_settings: {
enforced: false,
allowSwitching: true,
allowOSPreference: true,
},
};
controller.setThemeConfig(themeConfig);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining(DEFAULT_THEME.token),
algorithm: antdThemeImport.defaultAlgorithm,
}),
);
expect(controller.getCurrentMode()).toBe(ThemeMode.SYSTEM);
expect(controller.canSetTheme()).toBe(true);
expect(controller.canSetMode()).toBe(true);
});
it('should handle theme_default only', () => {
const themeConfig = {
theme_default: DEFAULT_THEME,
};
controller.setThemeConfig(themeConfig);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining(DEFAULT_THEME.token),
algorithm: antdThemeImport.defaultAlgorithm,
}),
);
expect(controller.canSetTheme()).toBe(true);
expect(controller.canSetMode()).toBe(true);
});
it('should handle theme_default and theme_dark without settings', () => {
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
};
controller.setThemeConfig(themeConfig);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining(DEFAULT_THEME.token),
}),
);
jest.clearAllMocks();
controller.setThemeMode(ThemeMode.DARK);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining(DARK_THEME.token),
algorithm: antdThemeImport.darkAlgorithm,
}),
);
});
it('should handle enforced theme settings', () => {
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
theme_settings: {
enforced: true,
allowSwitching: false,
allowOSPreference: false,
},
};
controller.setThemeConfig(themeConfig);
expect(controller.canSetTheme()).toBe(false);
expect(controller.canSetMode()).toBe(false);
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
expect(() => {
controller.setThemeMode(ThemeMode.DARK);
}).toThrow('User does not have permission to update the theme mode');
});
it('should handle allowOSPreference: false setting', () => {
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
theme_settings: {
enforced: false,
allowSwitching: true,
allowOSPreference: false,
},
};
controller.setThemeConfig(themeConfig);
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
expect(controller.canSetMode()).toBe(true);
expect(() => {
controller.setThemeMode(ThemeMode.SYSTEM);
}).toThrow('System theme mode is not allowed');
});
it('should re-determine initial mode based on new settings', () => {
mockMatchMedia.mockReturnValue({
matches: true,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
});
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
theme_settings: {
enforced: false,
allowSwitching: false,
allowOSPreference: true,
},
};
controller.setThemeConfig(themeConfig);
expect(controller.getCurrentMode()).toBe(ThemeMode.SYSTEM);
expect(controller.canSetMode()).toBe(false);
});
it('should apply appropriate theme after configuration', () => {
controller.setThemeMode(ThemeMode.DARK);
jest.clearAllMocks();
const themeConfig = {
theme_default: {
token: {
colorPrimary: '#00ff00',
},
},
theme_dark: {
token: {
colorPrimary: '#ff0000',
colorBgBase: '#000000',
},
algorithm: 'dark',
},
};
controller.setThemeConfig(themeConfig as SupersetThemeConfig);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining({
colorPrimary: '#ff0000',
colorBgBase: '#000000',
}),
algorithm: antdThemeImport.darkAlgorithm,
}),
);
});
it('should handle missing theme_dark gracefully', () => {
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_settings: {
allowSwitching: true,
},
};
controller.setThemeConfig(themeConfig);
jest.clearAllMocks();
controller.setThemeMode(ThemeMode.DARK);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining(DEFAULT_THEME.token),
algorithm: antdThemeImport.defaultAlgorithm,
}),
);
});
it('should preserve existing theme mode when possible', () => {
controller.setThemeMode(ThemeMode.DARK);
const initialMode = controller.getCurrentMode();
jest.clearAllMocks();
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
theme_settings: {
allowSwitching: true,
allowOSPreference: false,
},
};
controller.setThemeConfig(themeConfig);
expect(controller.getCurrentMode()).toBe(initialMode);
});
it('should trigger onChange callbacks', () => {
const changeCallback = jest.fn();
controller.onChange(changeCallback);
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
};
controller.setThemeConfig(themeConfig);
expect(changeCallback).toHaveBeenCalledTimes(1);
expect(changeCallback).toHaveBeenCalledWith(mockThemeObject);
});
it('should handle partial theme_settings', () => {
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_settings: {
enforced: true,
},
};
controller.setThemeConfig(themeConfig);
expect(controller.canSetTheme()).toBe(false);
expect(controller.canSetMode()).toBe(false);
});
it('should handle error in theme application', () => {
mockSetConfig.mockImplementationOnce(() => {
throw new Error('Theme application error');
});
const themeConfig = {
theme_default: DEFAULT_THEME,
};
expect(() => {
controller.setThemeConfig(themeConfig);
}).not.toThrow();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to apply theme:',
expect.any(Error),
);
});
it('should update stored theme mode', () => {
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
};
controller.setThemeConfig(themeConfig);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'superset-theme-mode',
expect.any(String),
);
});
});
});

View File

@@ -17,8 +17,7 @@
* under the License.
*/
import { ReactNode } from 'react';
import { Theme } from '@superset-ui/core';
import { ThemeContextType, ThemeMode } from '@superset-ui/core/theme/types';
import { type ThemeContextType, Theme, ThemeMode } from '@superset-ui/core';
import { act, render, screen } from '@superset-ui/core/spec';
import { renderHook } from '@testing-library/react-hooks';
import { SupersetThemeProvider, useThemeContext } from '../ThemeProvider';

View File

@@ -16,14 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
ColorSchemeConfig,
FeatureFlagMap,
JsonObject,
LanguagePack,
Locale,
SequentialSchemeConfig,
} from '@superset-ui/core';
import { FormatLocaleDefinition } from 'd3-format';
import { TimeLocaleDefinition } from 'd3-time-format';
import { isPlainObject } from 'lodash';
@@ -31,8 +23,14 @@ import { Languages } from 'src/features/home/LanguagePicker';
import type { FlashMessage } from 'src/components';
import type {
AnyThemeConfig,
ColorSchemeConfig,
FeatureFlagMap,
JsonObject,
LanguagePack,
Locale,
SequentialSchemeConfig,
SerializableThemeConfig,
} from '@superset-ui/core/theme/types';
} from '@superset-ui/core';
export type User = {
createdOn?: string;
@@ -189,7 +187,7 @@ export interface BootstrapThemeData {
bootstrapDefaultTheme: AnyThemeConfig | null;
bootstrapDarkTheme: AnyThemeConfig | null;
bootstrapThemeSettings: SerializableThemeSettings | null;
hasBootstrapThemes: boolean;
hasCustomThemes: boolean;
}
export function isUser(user: any): user is User {

View File

@@ -9,7 +9,7 @@
"version": "0.0.1",
"license": "Apache-2.0",
"dependencies": {
"cookie": "^0.7.0",
"cookie": "^1.0.2",
"hot-shots": "^11.1.0",
"ioredis": "^5.6.1",
"jsonwebtoken": "^9.0.2",
@@ -20,7 +20,6 @@
},
"devDependencies": {
"@eslint/js": "^9.25.1",
"@types/cookie": "^0.6.0",
"@types/eslint__js": "^8.42.3",
"@types/ioredis": "^4.27.8",
"@types/jest": "^29.5.14",
@@ -1721,12 +1720,6 @@
"@babel/types": "^7.3.0"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true
},
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -3045,11 +3038,11 @@
"dev": true
},
"node_modules/cookie": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.0.tgz",
"integrity": "sha512-qCf+V4dtlNhSRXGAZatc1TasyFO6GjohcOul807YOb5ik3+kQSnb4d7iajeCL8QHaJ4uZEjCgiCJerKXwdRVlQ==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"engines": {
"node": ">= 0.6"
"node": ">=18"
}
},
"node_modules/create-jest": {
@@ -8402,12 +8395,6 @@
"@babel/types": "^7.3.0"
}
},
"@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true
},
"@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -9317,9 +9304,9 @@
"dev": true
},
"cookie": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.0.tgz",
"integrity": "sha512-qCf+V4dtlNhSRXGAZatc1TasyFO6GjohcOul807YOb5ik3+kQSnb4d7iajeCL8QHaJ4uZEjCgiCJerKXwdRVlQ=="
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="
},
"create-jest": {
"version": "29.7.0",

View File

@@ -17,7 +17,7 @@
},
"license": "Apache-2.0",
"dependencies": {
"cookie": "^0.7.0",
"cookie": "^1.0.2",
"hot-shots": "^11.1.0",
"ioredis": "^5.6.1",
"jsonwebtoken": "^9.0.2",
@@ -28,7 +28,6 @@
},
"devDependencies": {
"@eslint/js": "^9.25.1",
"@types/cookie": "^0.6.0",
"@types/eslint__js": "^8.42.3",
"@types/ioredis": "^4.27.8",
"@types/jest": "^29.5.14",
@@ -52,7 +51,7 @@
"typescript-eslint": "^8.19.0"
},
"engines": {
"node": "^16.9.1",
"npm": "^7.5.4 || ^8.1.2"
"node": "^20.19.4",
"npm": "^10.8.2"
}
}

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import { buildConfig } from '../src/config';
import { expect, test } from '@jest/globals';
test('buildConfig() builds configuration and applies env var overrides', () => {
let config = buildConfig();

View File

@@ -19,15 +19,26 @@
const jwt = require('jsonwebtoken');
const config = require('../config.test.json');
import { describe, expect, test, beforeEach, afterEach } from '@jest/globals';
import {
describe,
expect,
test,
beforeEach,
afterEach,
jest,
} from '@jest/globals';
import * as http from 'http';
import * as net from 'net';
import { WebSocket } from 'ws';
interface MockedRedisXrange {
(): Promise<server.StreamResult[]>;
}
// NOTE: these mock variables needs to start with "mock" due to
// calls to `jest.mock` being hoisted to the top of the file.
// https://jestjs.io/docs/es6-class-mocks#calling-jestmock-with-the-module-factory-parameter
const mockRedisXrange = jest.fn();
const mockRedisXrange = jest.fn() as jest.MockedFunction<MockedRedisXrange>;
jest.mock('ws');
jest.mock('ioredis', () => {
@@ -59,7 +70,7 @@ import * as server from '../src/index';
import { statsd } from '../src/index';
describe('server', () => {
let statsdIncrementMock: jest.SpyInstance;
let statsdIncrementMock: jest.SpiedFunction<typeof statsd.increment>;
beforeEach(() => {
mockRedisXrange.mockClear();
@@ -319,10 +330,12 @@ describe('server', () => {
describe('wsConnection', () => {
let ws: WebSocket;
let wsEventMock: jest.SpyInstance;
let trackClientSpy: jest.SpyInstance;
let fetchRangeFromStreamSpy: jest.SpyInstance;
let dateNowSpy: jest.SpyInstance;
let wsEventMock: jest.SpiedFunction<typeof ws.on>;
let trackClientSpy: jest.SpiedFunction<typeof server.trackClient>;
let fetchRangeFromStreamSpy: jest.SpiedFunction<
typeof server.fetchRangeFromStream
>;
let dateNowSpy: jest.SpiedFunction<typeof Date.now>;
let socketInstanceExpected: server.SocketInstance;
const getRequest = (token: string, url: string): http.IncomingMessage => {
@@ -431,8 +444,8 @@ describe('server', () => {
describe('httpUpgrade', () => {
let socket: net.Socket;
let socketDestroySpy: jest.SpyInstance;
let wssUpgradeSpy: jest.SpyInstance;
let socketDestroySpy: jest.SpiedFunction<typeof socket.destroy>;
let wssUpgradeSpy: jest.SpiedFunction<typeof server.wss.handleUpgrade>;
const getRequest = (token: string, url: string): http.IncomingMessage => {
const request = new http.IncomingMessage(new net.Socket());
@@ -496,8 +509,8 @@ describe('server', () => {
describe('checkSockets', () => {
let ws: WebSocket;
let pingSpy: jest.SpyInstance;
let terminateSpy: jest.SpyInstance;
let pingSpy: jest.SpiedFunction<typeof ws.ping>;
let terminateSpy: jest.SpiedFunction<typeof ws.terminate>;
let socketInstance: server.SocketInstance;
beforeEach(() => {

View File

@@ -21,7 +21,7 @@ import * as net from 'net';
import WebSocket from 'ws';
import { v4 as uuidv4 } from 'uuid';
import jwt, { Algorithm } from 'jsonwebtoken';
import cookie from 'cookie';
import { parse } from 'cookie';
import Redis, { RedisOptions } from 'ioredis';
import StatsD from 'hot-shots';
@@ -285,7 +285,7 @@ export const processStreamResults = (results: StreamResult[]): void => {
* configured via 'jwtCookieName' in the config.
*/
const readChannelId = (request: http.IncomingMessage): string => {
const cookies = cookie.parse(request.headers.cookie || '');
const cookies = parse(request.headers.cookie || '');
const token = cookies[opts.jwtCookieName];
if (!token) throw new Error('JWT not present');

View File

@@ -0,0 +1,230 @@
# 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 __future__ import annotations
import logging
from typing import Any, TypedDict
from superset import app
from superset.commands.base import BaseCommand
from superset.commands.sql_lab.estimate import QueryEstimationCommand, EstimateQueryCostType
config = app.config
logger = logging.getLogger(__name__)
class CostThresholdResult(TypedDict):
exceeds_threshold: bool
estimated_cost: list[dict[str, Any]]
threshold_info: dict[str, Any]
formatted_warning: str | None
class QueryCostThresholdCheckCommand(BaseCommand):
"""
Command to check if a query's estimated cost exceeds configured thresholds.
"""
_estimation_command: QueryEstimationCommand
def __init__(self, estimation_params: EstimateQueryCostType) -> None:
self._estimation_command = QueryEstimationCommand(estimation_params)
def validate(self) -> None:
# Use the estimation command's validation
self._estimation_command.validate()
def run(self) -> CostThresholdResult:
"""
Check if query cost exceeds thresholds.
Returns a result indicating whether the query exceeds cost thresholds
and provides information for user warnings.
"""
self.validate()
# Check if cost checking is enabled
if not config.get("SQLLAB_QUERY_COST_CHECKING_ENABLED", False):
return self._create_empty_result()
estimated_cost = self._get_estimated_cost()
if not estimated_cost:
return self._create_empty_result()
thresholds = self._get_engine_thresholds()
if not thresholds:
return CostThresholdResult(
exceeds_threshold=False,
estimated_cost=estimated_cost,
threshold_info={},
formatted_warning=None,
)
return self._check_thresholds(estimated_cost, thresholds)
def _create_empty_result(self) -> CostThresholdResult:
"""Create an empty result when cost checking is disabled or fails."""
return CostThresholdResult(
exceeds_threshold=False,
estimated_cost=[],
threshold_info={},
formatted_warning=None,
)
def _get_estimated_cost(self) -> list[dict[str, Any]] | None:
"""Get cost estimation, returning None if it fails."""
try:
return self._estimation_command.run()
except Exception as ex:
logger.warning("Cost estimation failed: %s", str(ex))
return None
def _get_engine_thresholds(self) -> dict[str, Any]:
"""Get thresholds for the current database engine."""
database = self._estimation_command._database
engine_name = database.db_engine_spec.engine_name
if engine_name is None:
return {}
engine_name = engine_name.lower()
return config.get("SQLLAB_QUERY_COST_THRESHOLDS", {}).get(engine_name, {})
def _check_thresholds(
self, estimated_cost: list[dict[str, Any]], thresholds: dict[str, Any]
) -> CostThresholdResult:
"""Check if estimated cost exceeds configured thresholds."""
exceeds_threshold = False
warning_messages = []
threshold_info = {}
for cost_item in estimated_cost:
if self._check_bytes_threshold(cost_item, thresholds, threshold_info, warning_messages):
exceeds_threshold = True
if self._check_cost_threshold(cost_item, thresholds, threshold_info, warning_messages):
exceeds_threshold = True
formatted_warning = None
if warning_messages:
formatted_warning = (
" ".join(warning_messages) + " Are you sure you want to continue?"
)
return CostThresholdResult(
exceeds_threshold=exceeds_threshold,
estimated_cost=estimated_cost,
threshold_info=threshold_info,
formatted_warning=formatted_warning,
)
def _check_bytes_threshold(
self,
cost_item: dict[str, Any],
thresholds: dict[str, Any],
threshold_info: dict[str, Any],
warning_messages: list[str]
) -> bool:
"""Check bytes scanned threshold. Returns True if threshold exceeded."""
if "bytes_scanned" not in thresholds or "Bytes Scanned" not in cost_item:
return False
try:
bytes_scanned = self._parse_bytes_from_cost_item(cost_item["Bytes Scanned"])
threshold_bytes = thresholds["bytes_scanned"]
threshold_info["bytes_threshold"] = threshold_bytes
threshold_info["estimated_bytes"] = bytes_scanned
if bytes_scanned > threshold_bytes:
warning_messages.append(
f"This query will scan approximately {self._format_bytes(bytes_scanned)} "
f"of data, which exceeds the threshold of {self._format_bytes(threshold_bytes)}."
)
return True
except (ValueError, KeyError) as ex:
logger.warning("Failed to parse bytes from cost estimation: %s", str(ex))
return False
def _check_cost_threshold(
self,
cost_item: dict[str, Any],
thresholds: dict[str, Any],
threshold_info: dict[str, Any],
warning_messages: list[str]
) -> bool:
"""Check cost threshold. Returns True if threshold exceeded."""
if "cost_threshold" not in thresholds or "Cost" not in cost_item:
return False
try:
cost_value = float(cost_item["Cost"])
threshold_cost = thresholds["cost_threshold"]
threshold_info["cost_threshold"] = threshold_cost
threshold_info["estimated_cost"] = cost_value
if cost_value > threshold_cost:
warning_messages.append(
f"This query has an estimated cost of {cost_value}, "
f"which exceeds the threshold of {threshold_cost}."
)
return True
except (ValueError, KeyError) as ex:
logger.warning("Failed to parse cost from cost estimation: %s", str(ex))
return False
def _parse_bytes_from_cost_item(self, bytes_str: str) -> int:
"""Parse bytes from formatted string like '5.2 GB' or '1024 MB'."""
if not isinstance(bytes_str, str):
return int(bytes_str)
# Remove commas and split
parts = bytes_str.replace(",", "").strip().split()
if len(parts) != 2:
raise ValueError(f"Cannot parse bytes from: {bytes_str}")
value_str, unit = parts
value = float(value_str)
unit = unit.upper()
multipliers = {
"B": 1,
"KB": 1024,
"MB": 1024**2,
"GB": 1024**3,
"TB": 1024**4,
"PB": 1024**5,
}
if unit not in multipliers:
raise ValueError(f"Unknown unit: {unit}")
return int(value * multipliers[unit])
def _format_bytes(self, bytes_count: int) -> str:
"""Format bytes into human-readable string."""
if bytes_count < 1024:
return f"{bytes_count} B"
elif bytes_count < 1024**2:
return f"{bytes_count / 1024:.1f} KB"
elif bytes_count < 1024**3:
return f"{bytes_count / (1024**2):.1f} MB"
elif bytes_count < 1024**4:
return f"{bytes_count / (1024**3):.1f} GB"
elif bytes_count < 1024**5:
return f"{bytes_count / (1024**4):.1f} TB"
else:
return f"{bytes_count / (1024**5):.1f} PB"

View File

@@ -1191,6 +1191,18 @@ SQLLAB_ASYNC_TIME_LIMIT_SEC = int(timedelta(hours=6).total_seconds())
# timeout.
SQLLAB_QUERY_COST_ESTIMATE_TIMEOUT = int(timedelta(seconds=10).total_seconds())
# Query cost governance configuration
# Enable automatic cost checking before query execution
SQLLAB_QUERY_COST_CHECKING_ENABLED = False
# Cost thresholds that trigger warnings before query execution
# This is a dictionary where keys are database engine names and values are threshold configs
# Each threshold config can contain:
# - 'bytes_scanned': maximum bytes that can be scanned without warning
# - 'cost_threshold': monetary cost threshold (engine-specific units)
# Example: {'bigquery': {'bytes_scanned': 5 * 1024**4}, 'presto': {'cost_threshold': 1000}}
SQLLAB_QUERY_COST_THRESHOLDS = {}
# Timeout duration for SQL Lab fetching query results by the resultsKey.
# 0 means no timeout.
SQLLAB_QUERY_RESULT_TIMEOUT = 0

View File

@@ -1680,6 +1680,9 @@ class SqlaTable(
table=self,
)
new_column.is_dttm = new_column.is_temporal
# Set description from comment field if available
if col.get("comment"):
new_column.description = col["comment"]
db_engine_spec.alter_new_orm_column(new_column)
else:
new_column = old_column
@@ -1687,6 +1690,9 @@ class SqlaTable(
results.modified.append(col["column_name"])
new_column.type = col["type"]
new_column.expression = ""
# Set description from comment field if available
if col.get("comment"):
new_column.description = col["comment"]
new_column.groupby = True
new_column.filterable = True
columns.append(new_column)

View File

@@ -262,8 +262,16 @@ class RLSAsSubqueryTransformer(RLSTransformer):
return node
if predicate := self.get_predicate(node):
# use alias or name
alias = node.alias or node.sql()
if node.alias:
alias = node.alias
else:
name = ".".join(
part
for part in (node.catalog or "", node.db or "", node.name)
if part
)
alias = exp.TableAlias(this=exp.Identifier(this=name, quoted=True))
node.set("alias", None)
node = exp.Subquery(
this=exp.Select(
@@ -683,7 +691,10 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
"""
return {
eq.this.sql(comments=False): eq.expression.sql(comments=False)
eq.this.sql(
dialect=self._dialect,
comments=False,
): eq.expression.sql(comments=False)
for set_item in self._parsed.find_all(exp.SetItem)
for eq in set_item.find_all(exp.EQ)
}

View File

@@ -25,6 +25,9 @@ from flask_appbuilder.models.sqla.interface import SQLAInterface
from marshmallow import ValidationError
from superset import app, is_feature_enabled
from superset.commands.sql_lab.check_cost_threshold import (
QueryCostThresholdCheckCommand,
)
from superset.commands.sql_lab.estimate import QueryEstimationCommand
from superset.commands.sql_lab.execute import CommandResult, ExecuteSqlCommand
from superset.commands.sql_lab.export import SqlResultExportCommand
@@ -188,6 +191,66 @@ class SqlLabRestApi(BaseSupersetApi):
result = command.run()
return self.response(200, result=result)
@expose("/check_cost_threshold/", methods=("POST",))
@protect()
@statsd_metrics
@requires_json
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
f".check_cost_threshold",
log_to_statsd=False,
)
def check_cost_threshold(self) -> Response:
"""Check if query cost exceeds configured thresholds.
---
post:
summary: Check if query cost exceeds thresholds
requestBody:
description: SQL query and params
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/EstimateQueryCostSchema'
responses:
200:
description: Cost threshold check result
content:
application/json:
schema:
type: object
properties:
exceeds_threshold:
type: boolean
description: Whether query exceeds cost thresholds
estimated_cost:
type: array
description: Detailed cost estimation
threshold_info:
type: object
description: Information about thresholds and estimates
formatted_warning:
type: string
nullable: true
description: Human-readable warning message
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
500:
$ref: '#/components/responses/500'
"""
try:
model = self.estimate_model_schema.load(request.json)
except ValidationError as error:
return self.response_400(message=error.messages)
command = QueryCostThresholdCheckCommand(model)
result = command.run()
return self.response(200, **result)
@expose("/format_sql/", methods=("POST",))
@statsd_metrics
@protect()

View File

@@ -287,3 +287,321 @@ def test_normalize_prequery_result_type_custom_sql() -> None:
sqla_table._normalize_prequery_result_type(row, dimension, columns_by_name)
== "Car"
)
def test_fetch_metadata_with_comment_field_new_columns(mocker: MockerFixture) -> None:
"""Test that fetch_metadata correctly assigns comment field to description
for new columns
"""
# Mock database
database = mocker.MagicMock()
database.get_metrics.return_value = []
# Mock db_engine_spec
mock_db_engine_spec = mocker.MagicMock()
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
database.db_engine_spec = mock_db_engine_spec
# Create table
table = SqlaTable(
table_name="test_table",
database=database,
)
# Mock external_metadata to return columns with comment fields
mock_columns = [
{
"column_name": "id",
"type": "INTEGER",
"comment": "Primary key identifier",
},
{
"column_name": "name",
"type": "VARCHAR",
"comment": "Full name of the user",
},
{
"column_name": "status",
"type": "VARCHAR",
# No comment field for this column
},
]
# Mock dependencies
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
mocker.patch("superset.connectors.sqla.models.db.session")
mocker.patch(
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
)
# Execute fetch_metadata
result = table.fetch_metadata()
# Verify results
assert len(result.added) == 3
assert set(result.added) == {"id", "name", "status"}
# Check that descriptions were set correctly from comments
columns_by_name = {col.column_name: col for col in table.columns}
assert columns_by_name["id"].description == "Primary key identifier"
assert columns_by_name["name"].description == "Full name of the user"
# Column without comment should have None description
assert columns_by_name["status"].description is None
def test_fetch_metadata_with_comment_field_existing_columns(
mocker: MockerFixture,
) -> None:
"""Test that fetch_metadata correctly updates description for existing columns"""
# Mock database
database = mocker.MagicMock()
database.get_metrics.return_value = []
# Mock db_engine_spec
mock_db_engine_spec = mocker.MagicMock()
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
database.db_engine_spec = mock_db_engine_spec
# Create table with existing columns
table = SqlaTable(
table_name="test_table_existing",
database=database,
)
table.id = 1 # Set ID so it's treated as existing table
# Create existing columns
existing_col1 = TableColumn(
column_name="id",
type="INTEGER",
table=table,
description="Old description",
)
existing_col2 = TableColumn(
column_name="name",
type="VARCHAR",
table=table,
)
table.columns = [existing_col1, existing_col2]
# Mock external_metadata to return updated columns with comments
mock_columns = [
{
"column_name": "id",
"type": "INTEGER",
"comment": "Updated primary key description",
},
{
"column_name": "name",
"type": "VARCHAR",
"comment": "Updated name description",
},
]
# Mock dependencies
mock_session = mocker.patch("superset.connectors.sqla.models.db.session")
mock_session.query.return_value.filter.return_value.all.return_value = [
existing_col1,
existing_col2,
]
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
mocker.patch(
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
)
# Execute fetch_metadata
result = table.fetch_metadata()
# Verify no new columns were added
assert len(result.added) == 0
# Check that descriptions were updated from comments
columns_by_name = {col.column_name: col for col in table.columns}
assert columns_by_name["id"].description == "Updated primary key description"
assert columns_by_name["name"].description == "Updated name description"
def test_fetch_metadata_mixed_comment_scenarios(mocker: MockerFixture) -> None:
"""Test fetch_metadata with mix of new/existing columns and with/without
comments
"""
# Mock database
database = mocker.MagicMock()
database.get_metrics.return_value = []
# Mock db_engine_spec
mock_db_engine_spec = mocker.MagicMock()
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
database.db_engine_spec = mock_db_engine_spec
# Create table with one existing column
table = SqlaTable(
table_name="test_table_mixed",
database=database,
)
table.id = 1
existing_col = TableColumn(
column_name="existing_col",
type="INTEGER",
table=table,
description="Existing description",
)
table.columns = [existing_col]
# Mock external_metadata with mixed scenarios
mock_columns = [
{
"column_name": "existing_col",
"type": "INTEGER",
"comment": "Updated existing column comment",
},
{
"column_name": "new_with_comment",
"type": "VARCHAR",
"comment": "New column with comment",
},
{
"column_name": "new_without_comment",
"type": "VARCHAR",
# No comment field
},
]
# Mock dependencies
mock_session = mocker.patch("superset.connectors.sqla.models.db.session")
mock_session.query.return_value.filter.return_value.all.return_value = [
existing_col
]
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
mocker.patch(
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
)
# Execute fetch_metadata
result = table.fetch_metadata()
# Check added columns
assert len(result.added) == 2
assert set(result.added) == {"new_with_comment", "new_without_comment"}
# Check all column descriptions
columns_by_name = {col.column_name: col for col in table.columns}
# Existing column should have updated description
assert (
columns_by_name["existing_col"].description == "Updated existing column comment"
)
# New column with comment should have description set
assert columns_by_name["new_with_comment"].description == "New column with comment"
# New column without comment should have None description
assert columns_by_name["new_without_comment"].description is None
def test_fetch_metadata_no_comment_field_safe_handling(
mocker: MockerFixture,
) -> None:
"""Test that fetch_metadata safely handles columns with no comment field"""
# Mock database
database = mocker.MagicMock()
database.get_metrics.return_value = []
# Mock db_engine_spec
mock_db_engine_spec = mocker.MagicMock()
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
database.db_engine_spec = mock_db_engine_spec
# Create table
table = SqlaTable(
table_name="test_table_no_comments",
database=database,
)
# Mock external_metadata with columns that have no comment fields
mock_columns = [
{"column_name": "col1", "type": "INTEGER"},
{"column_name": "col2", "type": "VARCHAR"},
]
# Mock dependencies
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
mocker.patch("superset.connectors.sqla.models.db.session")
mocker.patch(
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
)
# Execute fetch_metadata - should not raise any exceptions
result = table.fetch_metadata()
# Check that columns were added successfully
assert len(result.added) == 2
assert set(result.added) == {"col1", "col2"}
# Check that descriptions are None (not set)
columns_by_name = {col.column_name: col for col in table.columns}
assert columns_by_name["col1"].description is None
assert columns_by_name["col2"].description is None
def test_fetch_metadata_empty_comment_field_handling(mocker: MockerFixture) -> None:
"""Test that fetch_metadata handles empty comment fields correctly"""
# Mock database
database = mocker.MagicMock()
database.get_metrics.return_value = []
# Mock db_engine_spec
mock_db_engine_spec = mocker.MagicMock()
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
database.db_engine_spec = mock_db_engine_spec
# Create table
table = SqlaTable(
table_name="test_table_empty_comments",
database=database,
)
# Mock external_metadata with empty comment fields
mock_columns = [
{
"column_name": "col_with_empty_comment",
"type": "INTEGER",
"comment": "", # Empty string comment
},
{
"column_name": "col_with_none_comment",
"type": "VARCHAR",
"comment": None, # None comment
},
{
"column_name": "col_with_valid_comment",
"type": "VARCHAR",
"comment": "Valid comment",
},
]
# Mock dependencies
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
mocker.patch("superset.connectors.sqla.models.db.session")
mocker.patch(
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
)
# Execute fetch_metadata
result = table.fetch_metadata()
# Check that all columns were added
assert len(result.added) == 3
columns_by_name = {col.column_name: col for col in table.columns}
# Empty string comment should not be set (falsy)
assert columns_by_name["col_with_empty_comment"].description is None
# None comment should not be set
assert columns_by_name["col_with_none_comment"].description is None
# Valid comment should be set
assert columns_by_name["col_with_valid_comment"].description == "Valid comment"

View File

@@ -1851,7 +1851,7 @@ FROM (
FROM some_table
WHERE
id = 42
) AS some_table
) AS "some_table"
WHERE
1 = 1
""".strip(),
@@ -1868,7 +1868,7 @@ FROM (
FROM table
WHERE
id = 42
) AS table
) AS "table"
WHERE
1 = 1
""".strip(),
@@ -1925,7 +1925,7 @@ JOIN (
FROM other_table
WHERE
id = 42
) AS other_table
) AS "other_table"
ON table.id = other_table.id
""".strip(),
),
@@ -1961,7 +1961,7 @@ FROM (
FROM some_table
WHERE
id = 42
) AS some_table
) AS "some_table"
)
""".strip(),
),
@@ -1977,7 +1977,7 @@ FROM (
FROM table
WHERE
id = 42
) AS table
) AS "table"
UNION ALL
SELECT
*
@@ -2000,7 +2000,7 @@ FROM (
FROM other_table
WHERE
id = 42
) AS other_table
) AS "other_table"
""".strip(),
),
(
@@ -2039,6 +2039,22 @@ INNER JOIN tbl_b AS b
ON a.col = b.col
""".strip(),
),
(
"SELECT * FROM public.flights LIMIT 100",
{Table("flights", "public", "catalog1"): "\"AIRLINE\" like 'A%'"},
"""
SELECT
*
FROM (
SELECT
*
FROM public.flights
WHERE
"AIRLINE" LIKE 'A%'
) AS "public.flights"
LIMIT 100
""".strip(),
),
],
)
def test_rls_subquery_transformer(

View File

@@ -259,13 +259,13 @@ FROM (
FROM t1
WHERE
c1 = 1
) AS t1, (
) AS "t1", (
SELECT
*
FROM t2
WHERE
c2 = 2
) AS t2
) AS "t2"
""".strip()
)