Compare commits

..

1 Commits

Author SHA1 Message Date
GitHub Action
ed229e18a6 chore(🦾): bump python sqlglot 27.3.0 -> 27.4.1 2025-07-28 19:13:50 +00:00
68 changed files with 338 additions and 1868 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
# ideally we'd COPY only their package.json. Here npm ci will be cached as long
# as the full content of these folders don't change, yielding a decent cache reuse rate.
# Note that 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 \
--mount=type=bind,source=./superset-frontend/package-lock.json,target=./package-lock.json \
--mount=type=cache,target=/root/.cache \

View File

@@ -120,78 +120,6 @@ docker volume rm superset_db_home
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
:::note

View File

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

View File

@@ -11,7 +11,9 @@ apispec==6.6.1
apsw==3.50.1.0
# via shillelagh
async-timeout==4.0.3
# via -r requirements/base.in
# via
# -r requirements/base.in
# redis
attrs==25.3.0
# via
# cattrs
@@ -97,6 +99,11 @@ email-validator==2.2.0
# via flask-appbuilder
et-xmlfile==2.0.0
# via openpyxl
exceptiongroup==1.3.0
# via
# cattrs
# trio
# trio-websocket
flask==2.3.3
# via
# apache-superset (pyproject.toml)
@@ -154,6 +161,7 @@ greenlet==3.1.1
# via
# apache-superset (pyproject.toml)
# shillelagh
# sqlalchemy
gunicorn==23.0.0
# via apache-superset (pyproject.toml)
h11==0.16.0
@@ -378,7 +386,7 @@ sqlalchemy-utils==0.38.3
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
sqlglot==27.3.0
sqlglot==27.4.1
# via apache-superset (pyproject.toml)
sshtunnel==0.4.0
# via apache-superset (pyproject.toml)
@@ -395,9 +403,11 @@ typing-extensions==4.14.0
# apache-superset (pyproject.toml)
# alembic
# cattrs
# exceptiongroup
# limits
# pyopenssl
# referencing
# rich
# selenium
# shillelagh
tzdata==2025.2

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,7 +39,7 @@ interface TelemetryPixelProps {
const PIXEL_ID = '0d3461e1-abb1-4691-a0aa-5ed50de66af0';
export const TelemetryPixel = ({
const TelemetryPixel = ({
version = 'unknownVersion',
sha = 'unknownSHA',
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 './TableView';
export * from './Tag';
export * from './TelemetryPixel';
export * from './ThemeSubMenu';
export * from './UnsavedChangesModal';
export * from './constants';
export * from './Result';

View File

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

View File

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

View File

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

View File

@@ -34,12 +34,6 @@ const parseLabel = value => {
return String(value);
};
function displayCell(value, allowRenderHtml) {
if (allowRenderHtml && typeof value === 'string') {
return safeHtmlSpan(value);
}
return parseLabel(value);
}
function displayHeaderCell(
needToggle,
ArrowIcon,
@@ -748,7 +742,7 @@ export class TableRenderer extends Component {
onContextMenu={e => this.props.onContextMenu(e, colKey, rowKey)}
style={style}
>
{displayCell(agg.format(aggValue), allowRenderHtml)}
{agg.format(aggValue)}
</td>
);
});
@@ -765,7 +759,7 @@ export class TableRenderer extends Component {
onClick={rowTotalCallbacks[flatRowKey]}
onContextMenu={e => this.props.onContextMenu(e, undefined, rowKey)}
>
{displayCell(agg.format(aggValue), allowRenderHtml)}
{agg.format(aggValue)}
</td>
);
}
@@ -829,7 +823,7 @@ export class TableRenderer extends Component {
onContextMenu={e => this.props.onContextMenu(e, colKey, undefined)}
style={{ padding: '5px' }}
>
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
{agg.format(aggValue)}
</td>
);
});
@@ -846,7 +840,7 @@ export class TableRenderer extends Component {
onClick={grandTotalCallback}
onContextMenu={e => this.props.onContextMenu(e, undefined, undefined)}
>
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
{agg.format(aggValue)}
</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 ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import {
type SupersetThemeConfig,
makeApi,
t,
logging,
} from '@superset-ui/core';
import { makeApi, t, logging, themeObject } from '@superset-ui/core';
import Switchboard from '@superset-ui/switchboard';
import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData';
import setupClient from 'src/setup/setupClient';
import setupPlugins from 'src/setup/setupPlugins';
import { useUiConfig } from 'src/components/UiConfigContext';
import { RootContextProviders } from 'src/views/RootContextProviders';
import { store, USER_LOADED } from 'src/views/store';
import { Loading } from '@superset-ui/core/components';
import { ErrorBoundary } from 'src/components';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import {
EmbeddedContextProviders,
getThemeController,
} from './EmbeddedContextProviders';
import { AnyThemeConfig } from 'packages/superset-ui-core/src/theme/types';
import { embeddedApi } from './api';
import { getDataMaskChangeTrigger } from './utils';
@@ -51,7 +44,9 @@ const debugMode = process.env.WEBPACK_MODE === 'development';
const bootstrapData = getBootstrapData();
function log(...info: unknown[]) {
if (debugMode) logging.debug(`[superset]`, ...info);
if (debugMode) {
logging.debug(`[superset]`, ...info);
}
}
const LazyDashboardPage = lazy(
@@ -90,12 +85,12 @@ const EmbededLazyDashboardPage = () => {
const EmbeddedRoute = () => (
<Suspense fallback={<Loading />}>
<EmbeddedContextProviders>
<RootContextProviders>
<ErrorBoundary>
<EmbededLazyDashboardPage />
</ErrorBoundary>
<ToastContainer position="top" />
</EmbeddedContextProviders>
</RootContextProviders>
</Suspense>
);
@@ -250,13 +245,12 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
Switchboard.defineMethod('getDataMask', embeddedApi.getDataMask);
Switchboard.defineMethod(
'setThemeConfig',
(payload: { themeConfig: SupersetThemeConfig }) => {
(payload: { themeConfig: AnyThemeConfig }) => {
const { themeConfig } = payload;
log('Received setThemeConfig request:', themeConfig);
try {
const themeController = getThemeController();
themeController.setThemeConfig(themeConfig);
themeObject.setConfig(themeConfig);
return { success: true, message: 'Theme applied' };
} catch (error) {
logging.error('Failed to apply theme config:', error);
@@ -264,22 +258,8 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
}
},
);
Switchboard.start();
}
});
// Clean up theme controller on page unload
window.addEventListener('beforeunload', () => {
try {
const controller = getThemeController();
if (controller) {
log('Destroying theme controller');
controller.destroy();
}
} catch (error) {
logging.warn('Failed to destroy theme controller:', error);
}
});
log('embed page is ready to receive messages');

View File

@@ -41,9 +41,6 @@ import {
import TableChartPlugin from '../../../../../plugins/plugin-chart-table/src';
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();
class MainPreset extends Preset {
@@ -259,22 +256,4 @@ describe('VizTypeControl', () => {
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);
}, []);
// Auto-focus the search input when the modal opens
useEffect(() => {
if (searchInputRef.current) {
searchInputRef.current.focus();
}
}, []);
const changeSearch: ChangeEventHandler<HTMLInputElement> = useCallback(
event => setSearchInputValue(event.target.value),
[],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -199,11 +199,6 @@ def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
:raises DatasetUnAllowedDataURI: If a dataset is trying
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)
logger.info("Downloading data from %s", data_uri)
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"]]
)
# 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)
configs[file_name] = config
except ValidationError as exc:

View File

@@ -1368,23 +1368,10 @@ class SqlaTable(
return get_template_processor(table=self, database=self.database, **kwargs)
def get_sqla_table(self) -> TableClause:
# For databases that support cross-catalog queries (like BigQuery),
# 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)
tbl = table(self.table_name)
if self.schema:
return table(self.table_name, schema=self.schema)
return table(self.table_name)
tbl.schema = self.schema
return tbl
def get_from_clause(
self,
@@ -1693,9 +1680,6 @@ class SqlaTable(
table=self,
)
new_column.is_dttm = new_column.is_temporal
# Set description from comment field if available
if col.get("comment"):
new_column.description = col["comment"]
db_engine_spec.alter_new_orm_column(new_column)
else:
new_column = old_column
@@ -1703,9 +1687,6 @@ class SqlaTable(
results.modified.append(col["column_name"])
new_column.type = col["type"]
new_column.expression = ""
# Set description from comment field if available
if col.get("comment"):
new_column.description = col["comment"]
new_column.groupby = True
new_column.filterable = True
columns.append(new_column)

View File

@@ -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):
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["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:
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
if database.backend == "presto":
@@ -584,8 +584,8 @@ def create_dashboard(slices: list[Slice]) -> Dashboard:
}
}"""
)
# pylint: disable=line-too-long
pos = json.loads( # noqa: TID251
# pylint: disable=echarts_timeseries_line-too-long
pos = json.loads(
textwrap.dedent(
"""\
{
@@ -859,11 +859,11 @@ def create_dashboard(slices: list[Slice]) -> Dashboard:
""" # noqa: E501
)
)
# pylint: enable=line-too-long
# pylint: enable=echarts_timeseries_line-too-long
# dashboard v2 doesn't allow add markup slice
dash.slices = [slc for slc in slices if slc.viz_type != "markup"]
update_slice_ids(pos)
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"
return dash

View File

@@ -1490,4 +1490,4 @@ columns:
python_date_format: null
version: 1.0.0
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
version: 1.0.0
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
version: 1.0.0
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
version: 1.0.0
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
version: 1.0.0
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
version: 1.0.0
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
version: 1.0.0
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
version: 1.0.0
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
version: 1.0.0
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
version: 1.0.0
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
version: 1.0.0
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
version: 1.0.0
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):
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.to_sql(

View File

@@ -50,7 +50,7 @@ def load_energy(
table_exists = database.has_table(Table(tbl_name, schema))
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.to_sql(
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):
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
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")

View File

@@ -54,8 +54,6 @@ from superset.connectors.sqla.models import SqlaTable
from superset.models.slice import Slice
from superset.utils import json
EXAMPLES_PROTOCOL = "examples://"
# ---------------------------------------------------------------------------
# Public sampledata mirror configuration
# ---------------------------------------------------------------------------
@@ -127,20 +125,6 @@ def get_example_url(filepath: str) -> str:
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(
filepath: str,
max_attempts: int = 5,
@@ -148,7 +132,9 @@ def read_example_data(
**kwargs: Any,
) -> pd.DataFrame:
"""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")
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):
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(
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):
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

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))
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.to_sql(

View File

@@ -46,9 +46,7 @@ def load_random_time_series_data(
table_exists = database.has_table(Table(tbl_name, schema))
if not only_metadata and (not table_exists or force):
pdf = read_example_data(
"examples://random_time_series.json.gz", compression="gzip"
)
pdf = read_example_data("random_time_series.json.gz", compression="gzip")
if database.backend == "presto":
pdf.ds = pd.to_datetime(pdf.ds, unit="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))
if not only_metadata and (not table_exists or force):
df = read_example_data(
"examples://sf_population.json.gz", compression="gzip"
)
df = read_example_data("sf_population.json.gz", compression="gzip")
df["contour"] = df.contour.map(json.dumps)
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))
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]
if database.backend == "presto":
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_compress import Compress
from flask_session import Session
from sqlalchemy import inspect
from werkzeug.middleware.proxy_fix import ProxyFix
from superset.constants import CHANGE_ME_SECRET_KEY
@@ -471,31 +470,6 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
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:
"""
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"]:
flask_app_mutator(self.superset_app)
# Initialize database-dependent features only if database is ready
self._init_database_dependent_features()
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
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()

View File

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

View File

@@ -287,412 +287,3 @@ def test_normalize_prequery_result_type_custom_sql() -> None:
sqla_table._normalize_prequery_result_type(row, dimension, columns_by_name)
== "Car"
)
def test_fetch_metadata_with_comment_field_new_columns(mocker: MockerFixture) -> None:
"""Test that fetch_metadata correctly assigns comment field to description
for new columns
"""
# Mock database
database = mocker.MagicMock()
database.get_metrics.return_value = []
# Mock db_engine_spec
mock_db_engine_spec = mocker.MagicMock()
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
database.db_engine_spec = mock_db_engine_spec
# Create table
table = SqlaTable(
table_name="test_table",
database=database,
)
# Mock external_metadata to return columns with comment fields
mock_columns = [
{
"column_name": "id",
"type": "INTEGER",
"comment": "Primary key identifier",
},
{
"column_name": "name",
"type": "VARCHAR",
"comment": "Full name of the user",
},
{
"column_name": "status",
"type": "VARCHAR",
# No comment field for this column
},
]
# Mock dependencies
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
mocker.patch("superset.connectors.sqla.models.db.session")
mocker.patch(
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
)
# Execute fetch_metadata
result = table.fetch_metadata()
# Verify results
assert len(result.added) == 3
assert set(result.added) == {"id", "name", "status"}
# Check that descriptions were set correctly from comments
columns_by_name = {col.column_name: col for col in table.columns}
assert columns_by_name["id"].description == "Primary key identifier"
assert columns_by_name["name"].description == "Full name of the user"
# Column without comment should have None description
assert columns_by_name["status"].description is None
def test_fetch_metadata_with_comment_field_existing_columns(
mocker: MockerFixture,
) -> None:
"""Test that fetch_metadata correctly updates description for existing columns"""
# Mock database
database = mocker.MagicMock()
database.get_metrics.return_value = []
# Mock db_engine_spec
mock_db_engine_spec = mocker.MagicMock()
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
database.db_engine_spec = mock_db_engine_spec
# Create table with existing columns
table = SqlaTable(
table_name="test_table_existing",
database=database,
)
table.id = 1 # Set ID so it's treated as existing table
# Create existing columns
existing_col1 = TableColumn(
column_name="id",
type="INTEGER",
table=table,
description="Old description",
)
existing_col2 = TableColumn(
column_name="name",
type="VARCHAR",
table=table,
)
table.columns = [existing_col1, existing_col2]
# Mock external_metadata to return updated columns with comments
mock_columns = [
{
"column_name": "id",
"type": "INTEGER",
"comment": "Updated primary key description",
},
{
"column_name": "name",
"type": "VARCHAR",
"comment": "Updated name description",
},
]
# Mock dependencies
mock_session = mocker.patch("superset.connectors.sqla.models.db.session")
mock_session.query.return_value.filter.return_value.all.return_value = [
existing_col1,
existing_col2,
]
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
mocker.patch(
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
)
# Execute fetch_metadata
result = table.fetch_metadata()
# Verify no new columns were added
assert len(result.added) == 0
# Check that descriptions were updated from comments
columns_by_name = {col.column_name: col for col in table.columns}
assert columns_by_name["id"].description == "Updated primary key description"
assert columns_by_name["name"].description == "Updated name description"
def test_fetch_metadata_mixed_comment_scenarios(mocker: MockerFixture) -> None:
"""Test fetch_metadata with mix of new/existing columns and with/without
comments
"""
# Mock database
database = mocker.MagicMock()
database.get_metrics.return_value = []
# Mock db_engine_spec
mock_db_engine_spec = mocker.MagicMock()
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
database.db_engine_spec = mock_db_engine_spec
# Create table with one existing column
table = SqlaTable(
table_name="test_table_mixed",
database=database,
)
table.id = 1
existing_col = TableColumn(
column_name="existing_col",
type="INTEGER",
table=table,
description="Existing description",
)
table.columns = [existing_col]
# Mock external_metadata with mixed scenarios
mock_columns = [
{
"column_name": "existing_col",
"type": "INTEGER",
"comment": "Updated existing column comment",
},
{
"column_name": "new_with_comment",
"type": "VARCHAR",
"comment": "New column with comment",
},
{
"column_name": "new_without_comment",
"type": "VARCHAR",
# No comment field
},
]
# Mock dependencies
mock_session = mocker.patch("superset.connectors.sqla.models.db.session")
mock_session.query.return_value.filter.return_value.all.return_value = [
existing_col
]
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
mocker.patch(
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
)
# Execute fetch_metadata
result = table.fetch_metadata()
# Check added columns
assert len(result.added) == 2
assert set(result.added) == {"new_with_comment", "new_without_comment"}
# Check all column descriptions
columns_by_name = {col.column_name: col for col in table.columns}
# Existing column should have updated description
assert (
columns_by_name["existing_col"].description == "Updated existing column comment"
)
# New column with comment should have description set
assert columns_by_name["new_with_comment"].description == "New column with comment"
# New column without comment should have None description
assert columns_by_name["new_without_comment"].description is None
def test_fetch_metadata_no_comment_field_safe_handling(
mocker: MockerFixture,
) -> None:
"""Test that fetch_metadata safely handles columns with no comment field"""
# Mock database
database = mocker.MagicMock()
database.get_metrics.return_value = []
# Mock db_engine_spec
mock_db_engine_spec = mocker.MagicMock()
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
database.db_engine_spec = mock_db_engine_spec
# Create table
table = SqlaTable(
table_name="test_table_no_comments",
database=database,
)
# Mock external_metadata with columns that have no comment fields
mock_columns = [
{"column_name": "col1", "type": "INTEGER"},
{"column_name": "col2", "type": "VARCHAR"},
]
# Mock dependencies
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
mocker.patch("superset.connectors.sqla.models.db.session")
mocker.patch(
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
)
# Execute fetch_metadata - should not raise any exceptions
result = table.fetch_metadata()
# Check that columns were added successfully
assert len(result.added) == 2
assert set(result.added) == {"col1", "col2"}
# Check that descriptions are None (not set)
columns_by_name = {col.column_name: col for col in table.columns}
assert columns_by_name["col1"].description is None
assert columns_by_name["col2"].description is None
def test_fetch_metadata_empty_comment_field_handling(mocker: MockerFixture) -> None:
"""Test that fetch_metadata handles empty comment fields correctly"""
# Mock database
database = mocker.MagicMock()
database.get_metrics.return_value = []
# Mock db_engine_spec
mock_db_engine_spec = mocker.MagicMock()
mock_db_engine_spec.alter_new_orm_column = mocker.MagicMock()
database.db_engine_spec = mock_db_engine_spec
# Create table
table = SqlaTable(
table_name="test_table_empty_comments",
database=database,
)
# Mock external_metadata with empty comment fields
mock_columns = [
{
"column_name": "col_with_empty_comment",
"type": "INTEGER",
"comment": "", # Empty string comment
},
{
"column_name": "col_with_none_comment",
"type": "VARCHAR",
"comment": None, # None comment
},
{
"column_name": "col_with_valid_comment",
"type": "VARCHAR",
"comment": "Valid comment",
},
]
# Mock dependencies
mocker.patch.object(table, "external_metadata", return_value=mock_columns)
mocker.patch("superset.connectors.sqla.models.db.session")
mocker.patch(
"superset.connectors.sqla.models.config", {"SQLA_TABLE_MUTATOR": lambda x: None}
)
# Execute fetch_metadata
result = table.fetch_metadata()
# Check that all columns were added
assert len(result.added) == 3
columns_by_name = {col.column_name: col for col in table.columns}
# Empty string comment should not be set (falsy)
assert columns_by_name["col_with_empty_comment"].description is None
# None comment should not be set
assert columns_by_name["col_with_none_comment"].description is None
# Valid comment should be set
assert columns_by_name["col_with_valid_comment"].description == "Valid comment"
@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
WHERE
id = 42
) AS "some_table"
) AS some_table
WHERE
1 = 1
""".strip(),
@@ -1868,7 +1868,7 @@ FROM (
FROM table
WHERE
id = 42
) AS "table"
) AS table
WHERE
1 = 1
""".strip(),
@@ -1925,7 +1925,7 @@ JOIN (
FROM other_table
WHERE
id = 42
) AS "other_table"
) AS other_table
ON table.id = other_table.id
""".strip(),
),
@@ -1961,7 +1961,7 @@ FROM (
FROM some_table
WHERE
id = 42
) AS "some_table"
) AS some_table
)
""".strip(),
),
@@ -1977,7 +1977,7 @@ FROM (
FROM table
WHERE
id = 42
) AS "table"
) AS table
UNION ALL
SELECT
*
@@ -2000,7 +2000,7 @@ FROM (
FROM other_table
WHERE
id = 42
) AS "other_table"
) AS other_table
""".strip(),
),
(
@@ -2039,22 +2039,6 @@ INNER JOIN tbl_b AS b
ON a.col = b.col
""".strip(),
),
(
"SELECT * FROM public.flights LIMIT 100",
{Table("flights", "public", "catalog1"): "\"AIRLINE\" like 'A%'"},
"""
SELECT
*
FROM (
SELECT
*
FROM public.flights
WHERE
"AIRLINE" LIKE 'A%'
) AS "public.flights"
LIMIT 100
""".strip(),
),
],
)
def test_rls_subquery_transformer(

View File

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