Compare commits

..

1 Commits

Author SHA1 Message Date
Beto Dealmeida
5b51ae8bdc feat: improve file upload checkbox during DB creation 2025-07-28 18:37:23 -04:00
70 changed files with 361 additions and 1888 deletions

View File

@@ -1,5 +0,0 @@
# Superset Development with GitHub Codespaces
For complete documentation on using GitHub Codespaces with Apache Superset, please see:
**[Setting up a Development Environment - GitHub Codespaces](https://superset.apache.org/docs/contributing/development#github-codespaces-cloud-development)**

View File

@@ -1,52 +0,0 @@
{
"name": "Apache Superset Development",
// Keep this in sync with the base image in Dockerfile (ARG PY_VER)
// Using the same base as Dockerfile, but non-slim for dev tools
"image": "python:3.11.13-bookworm",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": true,
"dockerDashComposeVersion": "v2"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
},
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/common-utils:2": {
"configureZshAsDefaultShell": true
},
"ghcr.io/devcontainers/features/sshd:1": {
"version": "latest"
}
},
// Forward ports for development
"forwardPorts": [9001],
"portsAttributes": {
"9001": {
"label": "Superset (via Webpack Dev Server)",
"onAutoForward": "notify",
"visibility": "public"
}
},
// Run commands after container is created
"postCreateCommand": "chmod +x .devcontainer/setup-dev.sh && .devcontainer/setup-dev.sh",
// Auto-start Superset on Codespace resume
"postStartCommand": ".devcontainer/start-superset.sh",
// VS Code customizations
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance",
"charliermarsh.ruff",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}
}
}

View File

@@ -1,32 +0,0 @@
#!/bin/bash
# Setup script for Superset Codespaces development environment
echo "🔧 Setting up Superset development environment..."
# The universal image has most tools, just need Superset-specific libs
echo "📦 Installing Superset-specific dependencies..."
sudo apt-get update
sudo apt-get install -y \
libsasl2-dev \
libldap2-dev \
libpq-dev \
tmux \
gh
# Install uv for fast Python package management
echo "📦 Installing uv..."
curl -LsSf https://astral.sh/uv/install.sh | sh
# Add cargo/bin to PATH for uv
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.bashrc
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.zshrc
# Install Claude Code CLI via npm
echo "🤖 Installing Claude Code..."
npm install -g @anthropic-ai/claude-code
# Make the start script executable
chmod +x .devcontainer/start-superset.sh
echo "✅ Development environment setup complete!"
echo "🚀 Run '.devcontainer/start-superset.sh' to start Superset"

View File

@@ -1,59 +0,0 @@
#!/bin/bash
# Startup script for Superset in Codespaces
echo "🚀 Starting Superset in Codespaces..."
echo "🌐 Frontend will be available at port 9001"
# Find the workspace directory (Codespaces clones as 'superset', not 'superset-2')
WORKSPACE_DIR=$(find /workspaces -maxdepth 1 -name "superset*" -type d | head -1)
if [ -n "$WORKSPACE_DIR" ]; then
cd "$WORKSPACE_DIR"
echo "📁 Working in: $WORKSPACE_DIR"
else
echo "📁 Using current directory: $(pwd)"
fi
# Check if docker is running
if ! docker info > /dev/null 2>&1; then
echo "⏳ Waiting for Docker to start..."
sleep 5
fi
# Clean up any existing containers
echo "🧹 Cleaning up existing containers..."
docker-compose -f docker-compose-light.yml down
# Start services
echo "🏗️ Building and starting services..."
echo ""
echo "📝 Once started, login with:"
echo " Username: admin"
echo " Password: admin"
echo ""
echo "📋 Running in foreground with live logs (Ctrl+C to stop)..."
# Run docker-compose and capture exit code
docker-compose -f docker-compose-light.yml up
EXIT_CODE=$?
# If it failed, provide helpful instructions
if [ $EXIT_CODE -ne 0 ] && [ $EXIT_CODE -ne 130 ]; then # 130 is Ctrl+C
echo ""
echo "❌ Superset startup failed (exit code: $EXIT_CODE)"
echo ""
echo "🔄 To restart Superset, run:"
echo " .devcontainer/start-superset.sh"
echo ""
echo "🔧 For troubleshooting:"
echo " # View logs:"
echo " docker-compose -f docker-compose-light.yml logs"
echo ""
echo " # Clean restart (removes volumes):"
echo " docker-compose -f docker-compose-light.yml down -v"
echo " .devcontainer/start-superset.sh"
echo ""
echo " # Common issues:"
echo " - Network timeouts: Just retry, often transient"
echo " - Port conflicts: Check 'docker ps'"
echo " - Database issues: Try clean restart with -v"
fi

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 # 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 # 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. # as the full content of these folders don't change, yielding a decent cache reuse rate.
# Note that it's not possible to selectively COPY or mount using blobs. # Note that's it's not possible selectively COPY of mount using blobs.
RUN --mount=type=bind,source=./superset-frontend/package.json,target=./package.json \ 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=bind,source=./superset-frontend/package-lock.json,target=./package-lock.json \
--mount=type=cache,target=/root/.cache \ --mount=type=cache,target=/root/.cache \

View File

@@ -120,78 +120,6 @@ docker volume rm superset_db_home
docker-compose up docker-compose up
``` ```
## GitHub Codespaces (Cloud Development)
GitHub Codespaces provides a complete, pre-configured development environment in the cloud. This is ideal for:
- Quick contributions without local setup
- Consistent development environments across team members
- Working from devices that can't run Docker locally
- Safe experimentation in isolated environments
:::info
We're grateful to GitHub for providing this excellent cloud development service that makes
contributing to Apache Superset more accessible to developers worldwide.
:::
### Getting Started with Codespaces
1. **Create a Codespace**: Use this pre-configured link that sets up everything you need:
[**Launch Superset Codespace →**](https://github.com/codespaces/new?skip_quickstart=true&machine=standardLinux32gb&repo=39464018&ref=master&geo=UsWest&devcontainer_path=.devcontainer%2Fdevcontainer.json)
:::caution
**Important**: You must select at least the **4 CPU / 16GB RAM** machine type (pre-selected in the link above).
Smaller instances will not have sufficient resources to run Superset effectively.
:::
2. **Wait for Setup**: The initial setup takes several minutes. The Codespace will:
- Build the development container
- Install all dependencies
- Start all required services (PostgreSQL, Redis, etc.)
- Initialize the database with example data
3. **Access Superset**: Once ready, check the **PORTS** tab in VS Code for port `9001`.
Click the globe icon to open Superset in your browser.
- Default credentials: `admin` / `admin`
### Key Features
- **Auto-reload**: Both Python and TypeScript files auto-refresh on save
- **Pre-installed Extensions**: VS Code extensions for Python, TypeScript, and database tools
- **Multiple Instances**: Run multiple Codespaces for different branches/features
- **SSH Access**: Connect via terminal using `gh cs ssh` or through the GitHub web UI
- **VS Code Integration**: Works seamlessly with VS Code desktop app
### Managing Codespaces
- **List active Codespaces**: `gh cs list`
- **SSH into a Codespace**: `gh cs ssh`
- **Stop a Codespace**: Via GitHub UI or `gh cs stop`
- **Delete a Codespace**: Via GitHub UI or `gh cs delete`
### Debugging and Logs
Since Codespaces uses `docker-compose-light.yml`, you can monitor all services:
```bash
# Stream logs from all services
docker compose -f docker-compose-light.yml logs -f
# Stream logs from a specific service
docker compose -f docker-compose-light.yml logs -f superset
# View last 100 lines and follow
docker compose -f docker-compose-light.yml logs --tail=100 -f
# List all running services
docker compose -f docker-compose-light.yml ps
```
:::tip
Codespaces automatically stop after 30 minutes of inactivity to save resources.
Your work is preserved and you can restart anytime.
:::
## Installing Development Tools ## Installing Development Tools
:::note :::note

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,116 @@
/**
* 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

@@ -1,273 +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 {
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

@@ -1,170 +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 { 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,8 +164,6 @@ export * from './Steps';
export * from './Table'; export * from './Table';
export * from './TableView'; export * from './TableView';
export * from './Tag'; export * from './Tag';
export * from './TelemetryPixel';
export * from './ThemeSubMenu';
export * from './UnsavedChangesModal'; export * from './UnsavedChangesModal';
export * from './constants'; export * from './constants';
export * from './Result'; export * from './Result';

View File

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

View File

@@ -429,16 +429,3 @@ export interface ThemeContextType {
canDetectOSPreference: () => boolean; canDetectOSPreference: () => boolean;
createDashboardThemeProvider: (themeId: string) => Promise<Theme | null>; 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", "@react-icons/all-files": "^4.1.0",
"@types/d3-array": "^2.9.0", "@types/d3-array": "^2.9.0",
"@types/react-table": "^7.7.20", "@types/react-table": "^7.7.20",
"ag-grid-community": "^34.0.2", "ag-grid-community": "^33.1.1",
"ag-grid-react": "^34.0.2", "ag-grid-react": "^33.1.1",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"d3-array": "^2.4.0", "d3-array": "^2.4.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",

View File

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

View File

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

View File

@@ -41,9 +41,6 @@ import {
import TableChartPlugin from '../../../../../plugins/plugin-chart-table/src'; import TableChartPlugin from '../../../../../plugins/plugin-chart-table/src';
import VizTypeControl, { VIZ_TYPE_CONTROL_TEST_ID } from './index'; import VizTypeControl, { VIZ_TYPE_CONTROL_TEST_ID } from './index';
// Mock scrollIntoView to avoid errors in test environment
jest.mock('scroll-into-view-if-needed', () => jest.fn());
jest.useFakeTimers(); jest.useFakeTimers();
class MainPreset extends Preset { class MainPreset extends Preset {
@@ -259,22 +256,4 @@ describe('VizTypeControl', () => {
expect(defaultProps.onChange).toHaveBeenCalledWith(VizType.Line); expect(defaultProps.onChange).toHaveBeenCalledWith(VizType.Line);
}); });
it('Search input is focused when modal opens', async () => {
// Mock the focus method to track if it was called
const focusSpy = jest.fn();
const originalFocus = HTMLInputElement.prototype.focus;
HTMLInputElement.prototype.focus = focusSpy;
await waitForRenderWrapper();
const searchInput = screen.getByTestId(getTestId('search-input'));
// Verify that focus() was called on the search input
expect(focusSpy).toHaveBeenCalled();
expect(searchInput).toBeInTheDocument();
// Restore the original focus method
HTMLInputElement.prototype.focus = originalFocus;
});
}); });

View File

@@ -575,13 +575,6 @@ export default function VizTypeGallery(props: VizTypeGalleryProps) {
setIsSearchFocused(true); setIsSearchFocused(true);
}, []); }, []);
// Auto-focus the search input when the modal opens
useEffect(() => {
if (searchInputRef.current) {
searchInputRef.current.focus();
}
}, []);
const changeSearch: ChangeEventHandler<HTMLInputElement> = useCallback( const changeSearch: ChangeEventHandler<HTMLInputElement> = useCallback(
event => setSearchInputValue(event.target.value), event => setSearchInputValue(event.target.value),
[], [],

View File

@@ -50,6 +50,7 @@ const ExtraOptions = ({
onExtraInputChange, onExtraInputChange,
onExtraEditorChange, onExtraEditorChange,
extraExtension, extraExtension,
testedEngineInfo,
}: { }: {
db: DatabaseObject | null; db: DatabaseObject | null;
onInputChange: ( onInputChange: (
@@ -62,13 +63,14 @@ const ExtraOptions = ({
) => void; ) => void;
onExtraEditorChange: Function; onExtraEditorChange: Function;
extraExtension: DatabaseConnectionExtension | undefined; extraExtension: DatabaseConnectionExtension | undefined;
testedEngineInfo?: any;
}) => { }) => {
const expandableModalIsOpen = !!db?.expose_in_sqllab; const expandableModalIsOpen = !!db?.expose_in_sqllab;
const createAsOpen = !!(db?.allow_ctas || db?.allow_cvas); const createAsOpen = !!(db?.allow_ctas || db?.allow_cvas);
const isFileUploadSupportedByEngine = // Use tested engine info if available, otherwise fall back to initial engine info
db?.engine_information?.supports_file_upload; const engineInfo = testedEngineInfo || db?.engine_information;
const supportsDynamicCatalog = const isFileUploadSupportedByEngine = engineInfo?.supports_file_upload;
db?.engine_information?.supports_dynamic_catalog; const supportsDynamicCatalog = engineInfo?.supports_dynamic_catalog;
// JSON.parse will deep parse engine_params // JSON.parse will deep parse engine_params
// if it's an object, and we want to keep it a string // if it's an object, and we want to keep it a string
@@ -528,23 +530,29 @@ const ExtraOptions = ({
/> />
</div> </div>
</StyledInputContainer> </StyledInputContainer>
{isFileUploadSupportedByEngine && ( <StyledInputContainer
<StyledInputContainer css={!db?.allow_file_upload ? no_margin_bottom : {}}
css={!db?.allow_file_upload ? no_margin_bottom : {}} >
> <div className="input-container">
<div className="input-container"> <Checkbox
<Checkbox id="allow_file_upload"
id="allow_file_upload" name="allow_file_upload"
name="allow_file_upload" indeterminate={false}
indeterminate={false} checked={!!db?.allow_file_upload}
checked={!!db?.allow_file_upload} disabled={!isFileUploadSupportedByEngine}
onChange={onInputChange} onChange={onInputChange}
> >
{t('Allow file uploads to database')} {t('Allow file uploads to database')}
</Checkbox> </Checkbox>
</div> {!isFileUploadSupportedByEngine && (
</StyledInputContainer> <InfoTooltip
)} tooltip={t(
'File upload is not supported for this database engine'
)}
/>
)}
</div>
</StyledInputContainer>
{isFileUploadSupportedByEngine && !!db?.allow_file_upload && ( {isFileUploadSupportedByEngine && !!db?.allow_file_upload && (
<StyledInputContainer css={no_margin_bottom}> <StyledInputContainer css={no_margin_bottom}>
<div className="control-label"> <div className="control-label">

View File

@@ -607,6 +607,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
const [editNewDb, setEditNewDb] = useState<boolean>(false); const [editNewDb, setEditNewDb] = useState<boolean>(false);
const [isLoading, setLoading] = useState<boolean>(false); const [isLoading, setLoading] = useState<boolean>(false);
const [testInProgress, setTestInProgress] = useState<boolean>(false); const [testInProgress, setTestInProgress] = useState<boolean>(false);
const [testedEngineInfo, setTestedEngineInfo] = useState<any>(null);
const [passwords, setPasswords] = useState<Record<string, string>>({}); const [passwords, setPasswords] = useState<Record<string, string>>({});
const [sshTunnelPasswords, setSSHTunnelPasswords] = useState< const [sshTunnelPasswords, setSSHTunnelPasswords] = useState<
Record<string, string> Record<string, string>
@@ -735,6 +736,9 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
addSuccessToast(errorMsg); addSuccessToast(errorMsg);
setHasValidated(true); setHasValidated(true);
}, },
(engineInfo: any) => {
setTestedEngineInfo(engineInfo);
},
); );
}; };
@@ -797,6 +801,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
setSSHTunnelPrivateKeyPasswords({}); setSSHTunnelPrivateKeyPasswords({});
setConfirmedOverwrite(false); setConfirmedOverwrite(false);
setUseSSHTunneling(undefined); setUseSSHTunneling(undefined);
setTestedEngineInfo(null);
onHide(); onHide();
}; };
@@ -1771,6 +1776,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
<ExtraOptions <ExtraOptions
extraExtension={dbConfigExtraExtension} extraExtension={dbConfigExtraExtension}
db={db as DatabaseObject} db={db as DatabaseObject}
testedEngineInfo={testedEngineInfo}
onInputChange={( onInputChange={(
e: CheckboxChangeEvent | React.ChangeEvent<HTMLInputElement>, e: CheckboxChangeEvent | React.ChangeEvent<HTMLInputElement>,
) => { ) => {
@@ -2020,6 +2026,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
<ExtraOptions <ExtraOptions
extraExtension={dbConfigExtraExtension} extraExtension={dbConfigExtraExtension}
db={db as DatabaseObject} db={db as DatabaseObject}
testedEngineInfo={testedEngineInfo}
onInputChange={(e: CheckboxChangeEvent) => { onInputChange={(e: CheckboxChangeEvent) => {
const { target } = e; const { target } = e;
onChange(ActionType.InputChange, { onChange(ActionType.InputChange, {

View File

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

View File

@@ -17,15 +17,13 @@
* under the License. * under the License.
*/ */
import { import {
type AnyThemeConfig,
type SupersetTheme,
type SupersetThemeConfig,
type ThemeControllerOptions,
type ThemeStorage,
Theme, Theme,
ThemeMode, AnyThemeConfig,
ThemeStorage,
ThemeControllerOptions,
themeObject as supersetThemeObject, themeObject as supersetThemeObject,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { SupersetTheme, ThemeMode } from '@superset-ui/core/theme/types';
import { import {
getAntdConfig, getAntdConfig,
normalizeThemeConfig, normalizeThemeConfig,
@@ -96,7 +94,7 @@ export class ThemeController {
private currentMode: ThemeMode; private currentMode: ThemeMode;
private hasCustomThemes: boolean; private readonly hasBootstrapThemes: boolean;
private onChangeCallbacks: Set<(theme: Theme) => void> = new Set(); private onChangeCallbacks: Set<(theme: Theme) => void> = new Set();
@@ -111,13 +109,15 @@ export class ThemeController {
private dashboardCrudTheme: AnyThemeConfig | null = null; private dashboardCrudTheme: AnyThemeConfig | null = null;
constructor({ constructor(options: ThemeControllerOptions = {}) {
storage = new LocalStorageAdapter(), const {
modeStorageKey = STORAGE_KEYS.THEME_MODE, storage = new LocalStorageAdapter(),
themeObject = supersetThemeObject, modeStorageKey = STORAGE_KEYS.THEME_MODE,
defaultTheme = (supersetThemeObject.theme as AnyThemeConfig) ?? {}, themeObject = supersetThemeObject,
onChange = undefined, defaultTheme = (supersetThemeObject.theme as AnyThemeConfig) ?? {},
}: ThemeControllerOptions = {}) { onChange = null,
} = options;
this.storage = storage; this.storage = storage;
this.modeStorageKey = modeStorageKey; this.modeStorageKey = modeStorageKey;
@@ -129,14 +129,14 @@ export class ThemeController {
bootstrapDefaultTheme, bootstrapDefaultTheme,
bootstrapDarkTheme, bootstrapDarkTheme,
bootstrapThemeSettings, bootstrapThemeSettings,
hasCustomThemes, hasBootstrapThemes,
}: BootstrapThemeData = this.loadBootstrapData(); }: BootstrapThemeData = this.loadBootstrapData();
this.hasCustomThemes = hasCustomThemes; this.hasBootstrapThemes = hasBootstrapThemes;
this.themeSettings = bootstrapThemeSettings || {}; this.themeSettings = bootstrapThemeSettings || {};
// Set themes based on bootstrap data availability // Set themes based on bootstrap data availability
if (this.hasCustomThemes) { if (this.hasBootstrapThemes) {
this.darkTheme = bootstrapDarkTheme || bootstrapDefaultTheme || null; this.darkTheme = bootstrapDarkTheme || bootstrapDefaultTheme || null;
this.defaultTheme = this.defaultTheme =
bootstrapDefaultTheme || bootstrapDarkTheme || defaultTheme; bootstrapDefaultTheme || bootstrapDarkTheme || defaultTheme;
@@ -424,42 +424,6 @@ export class ThemeController {
return allowOSPreference === true; 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. * Handles system theme changes with error recovery.
*/ */
@@ -583,7 +547,7 @@ export class ThemeController {
bootstrapDefaultTheme: hasValidDefault ? defaultTheme : null, bootstrapDefaultTheme: hasValidDefault ? defaultTheme : null,
bootstrapDarkTheme: hasValidDark ? darkTheme : null, bootstrapDarkTheme: hasValidDark ? darkTheme : null,
bootstrapThemeSettings: hasValidSettings ? themeSettings : null, bootstrapThemeSettings: hasValidSettings ? themeSettings : null,
hasCustomThemes: hasValidDefault || hasValidDark, hasBootstrapThemes: hasValidDefault || hasValidDark,
}; };
} }
@@ -643,7 +607,7 @@ export class ThemeController {
resolvedMode = ThemeController.getSystemPreferredMode(); resolvedMode = ThemeController.getSystemPreferredMode();
} }
if (!this.hasCustomThemes) { if (!this.hasBootstrapThemes) {
const baseTheme = this.defaultTheme.token as Partial<SupersetTheme>; const baseTheme = this.defaultTheme.token as Partial<SupersetTheme>;
return getAntdConfig(baseTheme, resolvedMode === ThemeMode.DARK); return getAntdConfig(baseTheme, resolvedMode === ThemeMode.DARK);
} }

View File

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

View File

@@ -17,17 +17,12 @@
* under the License. * under the License.
*/ */
import { theme as antdThemeImport } from 'antd'; import { theme as antdThemeImport } from 'antd';
import { import { Theme } from '@superset-ui/core';
type AnyThemeConfig,
type SupersetThemeConfig,
Theme,
ThemeAlgorithm,
ThemeMode,
} from '@superset-ui/core';
import type { import type {
BootstrapThemeDataConfig, BootstrapThemeDataConfig,
CommonBootstrapData, CommonBootstrapData,
} from 'src/types/bootstrapTypes'; } from 'src/types/bootstrapTypes';
import { ThemeAlgorithm, ThemeMode } from '@superset-ui/core/theme/types';
import getBootstrapData from 'src/utils/getBootstrapData'; import getBootstrapData from 'src/utils/getBootstrapData';
import { LocalStorageAdapter, ThemeController } from '../ThemeController'; import { LocalStorageAdapter, ThemeController } from '../ThemeController';
@@ -48,7 +43,7 @@ const mockThemeFromConfig = jest.fn();
const mockSetConfig = jest.fn(); const mockSetConfig = jest.fn();
// Mock data constants // Mock data constants
const DEFAULT_THEME: AnyThemeConfig = { const DEFAULT_THEME = {
token: { token: {
colorBgBase: '#ededed', colorBgBase: '#ededed',
colorTextBase: '#120f0f', colorTextBase: '#120f0f',
@@ -60,7 +55,7 @@ const DEFAULT_THEME: AnyThemeConfig = {
}, },
}; };
const DARK_THEME: AnyThemeConfig = { const DARK_THEME = {
token: { token: {
colorBgBase: '#141118', colorBgBase: '#141118',
colorTextBase: '#fdc7c7', colorTextBase: '#fdc7c7',
@@ -70,7 +65,7 @@ const DARK_THEME: AnyThemeConfig = {
colorSuccess: '#3c7c1b', colorSuccess: '#3c7c1b',
colorWarning: '#dc9811', colorWarning: '#dc9811',
}, },
algorithm: ThemeAlgorithm.DARK, algorithm: ThemeMode.DARK,
}; };
const THEME_SETTINGS = { const THEME_SETTINGS = {
@@ -1054,298 +1049,4 @@ 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,7 +17,8 @@
* under the License. * under the License.
*/ */
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { type ThemeContextType, Theme, ThemeMode } from '@superset-ui/core'; import { Theme } from '@superset-ui/core';
import { ThemeContextType, ThemeMode } from '@superset-ui/core/theme/types';
import { act, render, screen } from '@superset-ui/core/spec'; import { act, render, screen } from '@superset-ui/core/spec';
import { renderHook } from '@testing-library/react-hooks'; import { renderHook } from '@testing-library/react-hooks';
import { SupersetThemeProvider, useThemeContext } from '../ThemeProvider'; import { SupersetThemeProvider, useThemeContext } from '../ThemeProvider';

View File

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

View File

@@ -712,14 +712,18 @@ export const testDatabaseConnection = (
connection: Partial<DatabaseObject>, connection: Partial<DatabaseObject>,
handleErrorMsg: (errorMsg: string) => void, handleErrorMsg: (errorMsg: string) => void,
addSuccessToast: (arg0: string) => void, addSuccessToast: (arg0: string) => void,
onEngineInfo?: (engineInfo: any) => void,
) => { ) => {
SupersetClient.post({ SupersetClient.post({
endpoint: 'api/v1/database/test_connection/', endpoint: 'api/v1/database/test_connection/',
body: JSON.stringify(connection), body: JSON.stringify(connection),
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}).then( }).then(
() => { (response) => {
addSuccessToast(t('Connection looks good!')); addSuccessToast(t('Connection looks good!'));
if (onEngineInfo && response?.json?.engine_information) {
onEngineInfo(response.json.engine_information);
}
}, },
createErrorHandler((errMsg: Record<string, string[] | string> | string) => { createErrorHandler((errMsg: Record<string, string[] | string> | string) => {
handleErrorMsg(t('ERROR: %s', parsedErrorMessage(errMsg))); handleErrorMsg(t('ERROR: %s', parsedErrorMessage(errMsg)));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -91,7 +91,7 @@ class TestConnectionDatabaseCommand(BaseCommand):
def run( # noqa: C901 def run( # noqa: C901
self, self,
) -> None: # pylint: disable=too-many-statements,too-many-branches ) -> Database: # pylint: disable=too-many-statements,too-many-branches
self.validate() self.validate()
ex_str = "" ex_str = ""
ssh_tunnel = self._properties.get("ssh_tunnel") ssh_tunnel = self._properties.get("ssh_tunnel")
@@ -168,6 +168,8 @@ class TestConnectionDatabaseCommand(BaseCommand):
action=get_log_connection_action("test_connection_success", ssh_tunnel), action=get_log_connection_action("test_connection_success", ssh_tunnel),
engine=database.db_engine_spec.__name__, engine=database.db_engine_spec.__name__,
) )
return database
except (NoSuchModuleError, ModuleNotFoundError) as ex: except (NoSuchModuleError, ModuleNotFoundError) as ex:
event_logger.log_with_context( event_logger.log_with_context(

View File

@@ -199,11 +199,6 @@ def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
:raises DatasetUnAllowedDataURI: If a dataset is trying :raises DatasetUnAllowedDataURI: If a dataset is trying
to load data from a URI that is not allowed. to load data from a URI that is not allowed.
""" """
from superset.examples.helpers import normalize_example_data_url
# Convert example URLs to align with configuration
data_uri = normalize_example_data_url(data_uri)
validate_data_uri(data_uri) validate_data_uri(data_uri)
logger.info("Downloading data from %s", data_uri) logger.info("Downloading data from %s", data_uri)
data = request.urlopen(data_uri) # pylint: disable=consider-using-with # noqa: S310 data = request.urlopen(data_uri) # pylint: disable=consider-using-with # noqa: S310

View File

@@ -190,12 +190,6 @@ def load_configs(
db_ssh_tunnel_priv_key_passws[config["uuid"]] db_ssh_tunnel_priv_key_passws[config["uuid"]]
) )
# Normalize example data URLs before schema validation
if prefix == "datasets" and "data" in config:
from superset.examples.helpers import normalize_example_data_url
config["data"] = normalize_example_data_url(config["data"])
schema.load(config) schema.load(config)
configs[file_name] = config configs[file_name] = config
except ValidationError as exc: except ValidationError as exc:

View File

@@ -1368,23 +1368,10 @@ class SqlaTable(
return get_template_processor(table=self, database=self.database, **kwargs) return get_template_processor(table=self, database=self.database, **kwargs)
def get_sqla_table(self) -> TableClause: def get_sqla_table(self) -> TableClause:
# For databases that support cross-catalog queries (like BigQuery), tbl = table(self.table_name)
# include the catalog in the table identifier to generate
# project.dataset.table format
if self.catalog and self.database.db_engine_spec.supports_cross_catalog_queries:
# SQLAlchemy doesn't have built-in catalog support for TableClause,
# so we need to construct the full identifier manually
if self.schema:
full_name = f"{self.catalog}.{self.schema}.{self.table_name}"
else:
full_name = f"{self.catalog}.{self.table_name}"
return table(full_name)
if self.schema: if self.schema:
return table(self.table_name, schema=self.schema) tbl.schema = self.schema
return tbl
return table(self.table_name)
def get_from_clause( def get_from_clause(
self, self,
@@ -1693,9 +1680,6 @@ class SqlaTable(
table=self, table=self,
) )
new_column.is_dttm = new_column.is_temporal 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) db_engine_spec.alter_new_orm_column(new_column)
else: else:
new_column = old_column new_column = old_column
@@ -1703,9 +1687,6 @@ class SqlaTable(
results.modified.append(col["column_name"]) results.modified.append(col["column_name"])
new_column.type = col["type"] new_column.type = col["type"]
new_column.expression = "" 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.groupby = True
new_column.filterable = True new_column.filterable = True
columns.append(new_column) columns.append(new_column)

View File

@@ -1258,6 +1258,17 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
properties: properties:
message: message:
type: string type: string
engine_information:
type: object
properties:
supports_file_upload:
type: boolean
disable_ssh_tunneling:
type: boolean
supports_dynamic_catalog:
type: boolean
supports_oauth2:
type: boolean
400: 400:
$ref: '#/components/responses/400' $ref: '#/components/responses/400'
422: 422:
@@ -1271,8 +1282,9 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
except ValidationError as error: except ValidationError as error:
return self.response_400(message=error.messages) return self.response_400(message=error.messages)
try: try:
TestConnectionDatabaseCommand(item).run() database = TestConnectionDatabaseCommand(item).run()
return self.response(200, message="OK") engine_information = database.db_engine_spec.get_public_information()
return self.response(200, message="OK", engine_information=engine_information)
except (SSHTunnelingNotEnabledError, SSHTunnelDatabasePortError) as ex: except (SSHTunnelingNotEnabledError, SSHTunnelDatabasePortError) as ex:
return self.response_400(message=str(ex)) return self.response_400(message=str(ex))

View File

@@ -38,7 +38,7 @@ def load_bart_lines(only_metadata: bool = False, force: bool = False) -> None:
if not only_metadata and (not table_exists or force): if not only_metadata and (not table_exists or force):
df = read_example_data( df = read_example_data(
"examples://bart-lines.json.gz", encoding="latin-1", compression="gzip" "bart-lines.json.gz", encoding="latin-1", compression="gzip"
) )
df["path_json"] = df.path.map(json.dumps) df["path_json"] = df.path.map(json.dumps)
df["polyline"] = df.path.map(polyline.encode) df["polyline"] = df.path.map(polyline.encode)

View File

@@ -57,7 +57,7 @@ def gen_filter(
def load_data(tbl_name: str, database: Database, sample: bool = False) -> None: def load_data(tbl_name: str, database: Database, sample: bool = False) -> None:
pdf = read_example_data("examples://birth_names2.json.gz", compression="gzip") pdf = read_example_data("birth_names2.json.gz", compression="gzip")
# TODO(bkyryliuk): move load examples data into the pytest fixture # TODO(bkyryliuk): move load examples data into the pytest fixture
if database.backend == "presto": if database.backend == "presto":
@@ -584,8 +584,8 @@ def create_dashboard(slices: list[Slice]) -> Dashboard:
} }
}""" }"""
) )
# pylint: disable=line-too-long # pylint: disable=echarts_timeseries_line-too-long
pos = json.loads( # noqa: TID251 pos = json.loads(
textwrap.dedent( textwrap.dedent(
"""\ """\
{ {
@@ -859,11 +859,11 @@ def create_dashboard(slices: list[Slice]) -> Dashboard:
""" # noqa: E501 """ # noqa: E501
) )
) )
# pylint: enable=line-too-long # pylint: enable=echarts_timeseries_line-too-long
# dashboard v2 doesn't allow add markup slice # dashboard v2 doesn't allow add markup slice
dash.slices = [slc for slc in slices if slc.viz_type != "markup"] dash.slices = [slc for slc in slices if slc.viz_type != "markup"]
update_slice_ids(pos) update_slice_ids(pos)
dash.dashboard_title = "USA Births Names" dash.dashboard_title = "USA Births Names"
dash.position_json = json.dumps(pos, indent=4) # noqa: TID251 dash.position_json = json.dumps(pos, indent=4)
dash.slug = "births" dash.slug = "births"
return dash return dash

View File

@@ -1490,4 +1490,4 @@ columns:
python_date_format: null python_date_format: null
version: 1.0.0 version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: examples://datasets/examples/fcc_survey_2018.csv.gz data: https://github.com/apache-superset/examples-data/raw/master/datasets/examples/fcc_survey_2018.csv.gz

View File

@@ -60,4 +60,4 @@ columns:
python_date_format: null python_date_format: null
version: 1.0.0 version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: examples://datasets/examples/slack/channel_members.csv data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/channel_members.csv

View File

@@ -360,4 +360,4 @@ columns:
python_date_format: null python_date_format: null
version: 1.0.0 version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: examples://datasets/examples/slack/channels.csv data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/channels.csv

View File

@@ -344,4 +344,4 @@ columns:
extra: null extra: null
version: 1.0.0 version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: examples://datasets/examples/sales.csv data: https://raw.githubusercontent.com/apache-superset/examples-data/lowercase_columns_examples/datasets/examples/sales.csv

View File

@@ -204,4 +204,4 @@ columns:
python_date_format: null python_date_format: null
version: 1.0.0 version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: examples://datasets/examples/covid_vaccines.csv data: https://raw.githubusercontent.com/apache-superset/examples-data/lowercase_columns_examples/datasets/examples/covid_vaccines.csv

View File

@@ -260,4 +260,4 @@ columns:
python_date_format: null python_date_format: null
version: 1.0.0 version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: examples://datasets/examples/slack/exported_stats.csv data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/exported_stats.csv

View File

@@ -480,4 +480,4 @@ columns:
python_date_format: null python_date_format: null
version: 1.0.0 version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: examples://datasets/examples/slack/messages.csv data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/messages.csv

View File

@@ -180,4 +180,4 @@ columns:
python_date_format: null python_date_format: null
version: 1.0.0 version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: examples://datasets/examples/slack/threads.csv data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/threads.csv

View File

@@ -90,4 +90,4 @@ columns:
python_date_format: null python_date_format: null
version: 1.0.0 version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: examples://datasets/examples/unicode_test.csv data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/unicode_test.csv

View File

@@ -220,4 +220,4 @@ columns:
python_date_format: null python_date_format: null
version: 1.0.0 version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: examples://datasets/examples/slack/users.csv data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/users.csv

View File

@@ -60,4 +60,4 @@ columns:
python_date_format: null python_date_format: null
version: 1.0.0 version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: examples://datasets/examples/slack/users_channels.csv data: https://raw.githubusercontent.com/apache-superset/examples-data/master/datasets/examples/slack/users_channels.csv

View File

@@ -153,4 +153,4 @@ columns:
python_date_format: null python_date_format: null
version: 1.0.0 version: 1.0.0
database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee database_uuid: a2dc77af-e654-49bb-b321-40f6b559a1ee
data: examples://datasets/examples/video_game_sales.csv data: https://github.com/apache-superset/examples-data/raw/lowercase_columns_examples/datasets/examples/video_game_sales.csv

View File

@@ -49,7 +49,7 @@ def load_country_map_data(only_metadata: bool = False, force: bool = False) -> N
if not only_metadata and (not table_exists or force): if not only_metadata and (not table_exists or force):
data = read_example_data( data = read_example_data(
"examples://birth_france_data_for_country_map.csv", encoding="utf-8" "birth_france_data_for_country_map.csv", encoding="utf-8"
) )
data["dttm"] = datetime.datetime.now().date() data["dttm"] = datetime.datetime.now().date()
data.to_sql( data.to_sql(

View File

@@ -50,7 +50,7 @@ def load_energy(
table_exists = database.has_table(Table(tbl_name, schema)) table_exists = database.has_table(Table(tbl_name, schema))
if not only_metadata and (not table_exists or force): if not only_metadata and (not table_exists or force):
pdf = read_example_data("examples://energy.json.gz", compression="gzip") pdf = read_example_data("energy.json.gz", compression="gzip")
pdf = pdf.head(100) if sample else pdf pdf = pdf.head(100) if sample else pdf
pdf.to_sql( pdf.to_sql(
tbl_name, tbl_name,

View File

@@ -38,12 +38,12 @@ def load_flights(only_metadata: bool = False, force: bool = False) -> None:
if not only_metadata and (not table_exists or force): if not only_metadata and (not table_exists or force):
pdf = read_example_data( pdf = read_example_data(
"examples://flight_data.csv.gz", encoding="latin-1", compression="gzip" "flight_data.csv.gz", encoding="latin-1", compression="gzip"
) )
# Loading airports info to join and get lat/long # Loading airports info to join and get lat/long
airports = read_example_data( airports = read_example_data(
"examples://airports.csv.gz", encoding="latin-1", compression="gzip" "airports.csv.gz", encoding="latin-1", compression="gzip"
) )
airports = airports.set_index("IATA_CODE") airports = airports.set_index("IATA_CODE")

View File

@@ -54,8 +54,6 @@ from superset.connectors.sqla.models import SqlaTable
from superset.models.slice import Slice from superset.models.slice import Slice
from superset.utils import json from superset.utils import json
EXAMPLES_PROTOCOL = "examples://"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Public sampledata mirror configuration # Public sampledata mirror configuration
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -127,20 +125,6 @@ def get_example_url(filepath: str) -> str:
return f"{BASE_URL}{filepath}" return f"{BASE_URL}{filepath}"
def normalize_example_data_url(url: str) -> str:
"""Convert example data URLs to use the configured CDN.
Transforms examples:// URLs to the configured CDN URL.
Non-example URLs are returned unchanged.
"""
if url.startswith(EXAMPLES_PROTOCOL):
relative_path = url[len(EXAMPLES_PROTOCOL) :]
return get_example_url(relative_path)
# Not an examples URL, return unchanged
return url
def read_example_data( def read_example_data(
filepath: str, filepath: str,
max_attempts: int = 5, max_attempts: int = 5,
@@ -148,7 +132,9 @@ def read_example_data(
**kwargs: Any, **kwargs: Any,
) -> pd.DataFrame: ) -> pd.DataFrame:
"""Load CSV or JSON from example data mirror with retry/backoff.""" """Load CSV or JSON from example data mirror with retry/backoff."""
url = normalize_example_data_url(filepath) from superset.examples.helpers import get_example_url
url = get_example_url(filepath)
is_json = filepath.endswith(".json") or filepath.endswith(".json.gz") is_json = filepath.endswith(".json") or filepath.endswith(".json.gz")
for attempt in range(1, max_attempts + 1): for attempt in range(1, max_attempts + 1):

View File

@@ -48,7 +48,7 @@ def load_long_lat_data(only_metadata: bool = False, force: bool = False) -> None
if not only_metadata and (not table_exists or force): if not only_metadata and (not table_exists or force):
pdf = read_example_data( pdf = read_example_data(
"examples://san_francisco.csv.gz", encoding="utf-8", compression="gzip" "san_francisco.csv.gz", encoding="utf-8", compression="gzip"
) )
start = datetime.datetime.now().replace( start = datetime.datetime.now().replace(
hour=0, minute=0, second=0, microsecond=0 hour=0, minute=0, second=0, microsecond=0

View File

@@ -49,7 +49,7 @@ def load_multiformat_time_series( # pylint: disable=too-many-locals
if not only_metadata and (not table_exists or force): if not only_metadata and (not table_exists or force):
pdf = read_example_data( pdf = read_example_data(
"examples://multiformat_time_series.json.gz", compression="gzip" "multiformat_time_series.json.gz", compression="gzip"
) )
# TODO(bkyryliuk): move load examples data into the pytest fixture # TODO(bkyryliuk): move load examples data into the pytest fixture

View File

@@ -37,7 +37,7 @@ def load_paris_iris_geojson(only_metadata: bool = False, force: bool = False) ->
table_exists = database.has_table(Table(tbl_name, schema)) table_exists = database.has_table(Table(tbl_name, schema))
if not only_metadata and (not table_exists or force): if not only_metadata and (not table_exists or force):
df = read_example_data("examples://paris_iris.json.gz", compression="gzip") df = read_example_data("paris_iris.json.gz", compression="gzip")
df["features"] = df.features.map(json.dumps) df["features"] = df.features.map(json.dumps)
df.to_sql( df.to_sql(

View File

@@ -46,9 +46,7 @@ def load_random_time_series_data(
table_exists = database.has_table(Table(tbl_name, schema)) table_exists = database.has_table(Table(tbl_name, schema))
if not only_metadata and (not table_exists or force): if not only_metadata and (not table_exists or force):
pdf = read_example_data( pdf = read_example_data("random_time_series.json.gz", compression="gzip")
"examples://random_time_series.json.gz", compression="gzip"
)
if database.backend == "presto": if database.backend == "presto":
pdf.ds = pd.to_datetime(pdf.ds, unit="s") pdf.ds = pd.to_datetime(pdf.ds, unit="s")
pdf.ds = pdf.ds.dt.strftime("%Y-%m-%d %H:%M%:%S") pdf.ds = pdf.ds.dt.strftime("%Y-%m-%d %H:%M%:%S")

View File

@@ -39,9 +39,7 @@ def load_sf_population_polygons(
table_exists = database.has_table(Table(tbl_name, schema)) table_exists = database.has_table(Table(tbl_name, schema))
if not only_metadata and (not table_exists or force): if not only_metadata and (not table_exists or force):
df = read_example_data( df = read_example_data("sf_population.json.gz", compression="gzip")
"examples://sf_population.json.gz", compression="gzip"
)
df["contour"] = df.contour.map(json.dumps) df["contour"] = df.contour.map(json.dumps)
df.to_sql( df.to_sql(

View File

@@ -55,7 +55,7 @@ def load_world_bank_health_n_pop( # pylint: disable=too-many-locals
table_exists = database.has_table(Table(tbl_name, schema)) table_exists = database.has_table(Table(tbl_name, schema))
if not only_metadata and (not table_exists or force): if not only_metadata and (not table_exists or force):
pdf = read_example_data("examples://countries.json.gz", compression="gzip") pdf = read_example_data("countries.json.gz", compression="gzip")
pdf.columns = [col.replace(".", "_") for col in pdf.columns] pdf.columns = [col.replace(".", "_") for col in pdf.columns]
if database.backend == "presto": if database.backend == "presto":
pdf.year = pd.to_datetime(pdf.year) pdf.year = pd.to_datetime(pdf.year)

View File

@@ -34,7 +34,6 @@ from flask_appbuilder.utils.base import get_safe_redirect
from flask_babel import lazy_gettext as _, refresh from flask_babel import lazy_gettext as _, refresh
from flask_compress import Compress from flask_compress import Compress
from flask_session import Session from flask_session import Session
from sqlalchemy import inspect
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from superset.constants import CHANGE_ME_SECRET_KEY from superset.constants import CHANGE_ME_SECRET_KEY
@@ -471,31 +470,6 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
icon="fa-lock", icon="fa-lock",
) )
def _init_database_dependent_features(self) -> None:
"""
Initialize features that require database tables to exist.
This is called during app initialization but checks table existence
to handle cases where the app starts before database migration.
"""
inspector = inspect(db.engine)
# Check if core tables exist (use 'dashboards' as proxy for Superset tables)
if not inspector.has_table("dashboards"):
logger.debug(
"Superset tables not yet created. Skipping database-dependent "
"initialization. These features will be initialized after migration."
)
return
# Register SQLA event listeners for tagging system
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
register_sqla_event_listeners()
# Seed system themes from configuration
from superset.commands.theme.seed import SeedSystemThemesCommand
SeedSystemThemesCommand().run()
def init_app_in_ctx(self) -> None: def init_app_in_ctx(self) -> None:
""" """
Runs init logic in the context of the app Runs init logic in the context of the app
@@ -513,8 +487,16 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
if flask_app_mutator := self.config["FLASK_APP_MUTATOR"]: if flask_app_mutator := self.config["FLASK_APP_MUTATOR"]:
flask_app_mutator(self.superset_app) flask_app_mutator(self.superset_app)
# Initialize database-dependent features only if database is ready if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
self._init_database_dependent_features() register_sqla_event_listeners()
# Seed system themes from configuration
try:
from superset.commands.theme.seed import SeedSystemThemesCommand
SeedSystemThemesCommand().run()
except Exception:
logger.exception("Failed to seed system themes")
self.init_views() self.init_views()

View File

@@ -262,16 +262,8 @@ class RLSAsSubqueryTransformer(RLSTransformer):
return node return node
if predicate := self.get_predicate(node): if predicate := self.get_predicate(node):
if node.alias: # use alias or name
alias = node.alias alias = node.alias or node.sql()
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.set("alias", None)
node = exp.Subquery( node = exp.Subquery(
this=exp.Select( this=exp.Select(
@@ -691,10 +683,7 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
""" """
return { return {
eq.this.sql( eq.this.sql(comments=False): eq.expression.sql(comments=False)
dialect=self._dialect,
comments=False,
): eq.expression.sql(comments=False)
for set_item in self._parsed.find_all(exp.SetItem) for set_item in self._parsed.find_all(exp.SetItem)
for eq in set_item.find_all(exp.EQ) for eq in set_item.find_all(exp.EQ)
} }

View File

@@ -287,412 +287,3 @@ def test_normalize_prequery_result_type_custom_sql() -> None:
sqla_table._normalize_prequery_result_type(row, dimension, columns_by_name) sqla_table._normalize_prequery_result_type(row, dimension, columns_by_name)
== "Car" == "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"
@pytest.mark.parametrize(
"supports_cross_catalog,table_name,catalog,schema,expected_name,expected_schema",
[
# Database supports cross-catalog queries (like BigQuery)
(
True,
"test_table",
"test_project",
"test_dataset",
"test_project.test_dataset.test_table",
None,
),
# Database supports cross-catalog queries, catalog only (no schema)
(
True,
"test_table",
"test_project",
None,
"test_project.test_table",
None,
),
# Database supports cross-catalog queries, schema only (no catalog)
(
True,
"test_table",
None,
"test_schema",
"test_table",
"test_schema",
),
# Database supports cross-catalog queries, no catalog or schema
(
True,
"test_table",
None,
None,
"test_table",
None,
),
# Database doesn't support cross-catalog queries, catalog ignored
(
False,
"test_table",
"test_catalog",
"test_schema",
"test_table",
"test_schema",
),
# Database doesn't support cross-catalog queries, no schema
(
False,
"test_table",
"test_catalog",
None,
"test_table",
None,
),
],
)
def test_get_sqla_table_with_catalog(
mocker: MockerFixture,
supports_cross_catalog: bool,
table_name: str,
catalog: str | None,
schema: str | None,
expected_name: str,
expected_schema: str | None,
) -> None:
"""Test that get_sqla_table handles catalog inclusion correctly based on
database cross-catalog support
"""
# Mock database with specified cross-catalog support
database = mocker.MagicMock()
database.db_engine_spec.supports_cross_catalog_queries = supports_cross_catalog
# Create table with specified parameters
table = SqlaTable(
table_name=table_name,
database=database,
schema=schema,
catalog=catalog,
)
# Get the SQLAlchemy table representation
sqla_table = table.get_sqla_table()
# Verify expected table name and schema
assert sqla_table.name == expected_name
assert sqla_table.schema == expected_schema

View File

@@ -1851,7 +1851,7 @@ FROM (
FROM some_table FROM some_table
WHERE WHERE
id = 42 id = 42
) AS "some_table" ) AS some_table
WHERE WHERE
1 = 1 1 = 1
""".strip(), """.strip(),
@@ -1868,7 +1868,7 @@ FROM (
FROM table FROM table
WHERE WHERE
id = 42 id = 42
) AS "table" ) AS table
WHERE WHERE
1 = 1 1 = 1
""".strip(), """.strip(),
@@ -1925,7 +1925,7 @@ JOIN (
FROM other_table FROM other_table
WHERE WHERE
id = 42 id = 42
) AS "other_table" ) AS other_table
ON table.id = other_table.id ON table.id = other_table.id
""".strip(), """.strip(),
), ),
@@ -1961,7 +1961,7 @@ FROM (
FROM some_table FROM some_table
WHERE WHERE
id = 42 id = 42
) AS "some_table" ) AS some_table
) )
""".strip(), """.strip(),
), ),
@@ -1977,7 +1977,7 @@ FROM (
FROM table FROM table
WHERE WHERE
id = 42 id = 42
) AS "table" ) AS table
UNION ALL UNION ALL
SELECT SELECT
* *
@@ -2000,7 +2000,7 @@ FROM (
FROM other_table FROM other_table
WHERE WHERE
id = 42 id = 42
) AS "other_table" ) AS other_table
""".strip(), """.strip(),
), ),
( (
@@ -2039,22 +2039,6 @@ INNER JOIN tbl_b AS b
ON a.col = b.col ON a.col = b.col
""".strip(), """.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( def test_rls_subquery_transformer(

View File

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