Compare commits

..

23 Commits

Author SHA1 Message Date
Enzo Martellucci
ce0fcf99a1 Merge branch 'master' into enxdev/chat-prototype 2026-06-17 10:05:10 +02:00
Xie Yanbo
a27ec1923e chore(export): Added ability to export chart YAML files with Unicode characters, fix #20331 (#28008)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 07:55:19 +01:00
serverdevil
3e2174b50f fix(database): enable superset_app_root override for databaseview link (#33508)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Superset Dev <dev@superset.apache.org>
2026-06-16 20:24:49 -07:00
Gabriel Bourgeois
5b66443d48 fix(cli): inconsistent options for set-database-uri (#34893) 2026-06-16 17:50:51 -07:00
Korbinian Preisler
2ea7585490 chore(i18n): update German (de) translation (#40431)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:47:57 -07:00
Simon Rühle
eeac76146c fix(helm): add host alias to init job (#33968)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 17:44:47 -07:00
Shaitan
6a1091d576 fix(sql): broaden mutating-statement detection in SQL Lab parser (#40421)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: sha174n <pedro.sousa@preset.io>
2026-06-16 15:07:34 -07:00
Jakub Hrubý
8e82b6b2c3 fix(translation): loading translations in menu (#35640)
Co-authored-by: Jakub Hrubý <jakub.hruby@orgis.cz>
Co-authored-by: Jezevec <panjzvc@gmail.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-16 14:35:32 -07:00
Evan Rusackas
b0c5f99007 fix(oracle): replace deprecated cx-Oracle extra with oracledb (#41122)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-16 14:32:11 -07:00
Elizabeth Thompson
f1ae683923 fix(deps): replace deprecated np.NaN with np.nan (#41118)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:19:08 -07:00
dependabot[bot]
d51d98891e chore(deps): bump flask-migrate from 3.1.0 to 4.1.0 (#41011)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:18:08 -07:00
Michael S. Molina
d88a6730cd chore: Chat extension improvements (#41117) 2026-06-16 11:58:36 -03:00
Michael S. Molina
ec623f3b93 Merge branch 'master' into enxdev/chat-prototype 2026-06-16 09:28:23 -03:00
Michael S. Molina
395bbb9611 chore: More cleanup of chat extension code (#41116) 2026-06-16 09:14:08 -03:00
Enzo Martellucci
5c1609e3f9 chore(extensions): align naming conventions 2026-06-15 17:46:42 +02:00
Enzo Martellucci
715c07b5c7 chore(extensions): remove out-of-scope scaffolding from chat SIP branch 2026-06-15 15:27:24 +02:00
Michael S. Molina
a1eba0f9a1 chore: Cleanup changes in chat feature branch (#41008) 2026-06-12 16:58:21 -03:00
Enzo Martellucci
568337f370 feat(extensions): add dedicated chat contribution type (#41000) 2026-06-12 20:54:13 +02:00
Enzo Martellucci
f170dc1d9e refactor(chatbot): drop extension settings layer; resolve last-loaded chatbot (#40968) 2026-06-11 13:32:03 +02:00
Enzo Martellucci
09c09f3f6b refactor: rename chatbot registration location 2026-06-10 14:32:52 +02:00
Enzo Martellucci
c65c9523aa refactor(extensions): remove install/lifecycle/dependency machinery (#40916) 2026-06-09 22:20:40 +02:00
Enzo Martellucci
94e0071883 Merge branch 'master' into enxdev/chat-prototype
Bring the chatbot extension feature branch up to date with master. The
chatbot work lives in new paths (superset/extensions/*, the core chatbot
namespace, ChatbotMount, superset-core namespaces) and merged cleanly with
no conflicts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:55:53 +02:00
Enzo Martellucci
380e70060b feat(extensions): define the superset.chatbot contribution point (#40439) 2026-06-08 22:50:34 +02:00
52 changed files with 5967 additions and 5265 deletions

View File

@@ -7235,10 +7235,10 @@
"pypi_packages": [
"oracledb"
],
"connection_string": "oracle://{username}:{password}@{hostname}:{port}",
"connection_string": "oracle+oracledb://{username}:{password}@{hostname}:{port}",
"default_port": 1521,
"notes": "Previously used cx_Oracle, now uses oracledb.",
"docs_url": "https://cx-oracle.readthedocs.io/en/latest/user_guide/installation.html",
"docs_url": "https://python-oracledb.readthedocs.io/en/latest/user_guide/installation.html",
"category": "Other Databases"
},
"engine": "oracle",

View File

@@ -29,7 +29,7 @@ maintainers:
- name: craig-rueda
email: craig@craigrueda.com
url: https://github.com/craig-rueda
version: 0.16.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
version: 0.16.1 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
dependencies:
- name: postgresql
version: 16.7.27

View File

@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.16.0](https://img.shields.io/badge/Version-0.16.0-informational?style=flat-square)
![Version: 0.16.1](https://img.shields.io/badge/Version-0.16.1-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application

View File

@@ -62,6 +62,9 @@ spec:
{{- if .Values.init.initContainers }}
initContainers: {{- tpl (toYaml .Values.init.initContainers) . | nindent 6 }}
{{- end }}
{{- with .Values.hostAliases }}
hostAliases: {{- toYaml . | nindent 6 }}
{{- end }}
containers:
- name: {{ template "superset.name" . }}-init-db
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"

View File

@@ -53,7 +53,7 @@ dependencies = [
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",
"flask-login>=0.6.0, < 1.0",
"flask-migrate>=3.1.0, <5.0",
"flask-migrate>=4.1.0, <5.0",
"flask-session>=0.4.0, <1.0",
"flask-wtf>=1.3.0, <2.0",
"geopy",
@@ -177,7 +177,7 @@ ocient = [
"shapely",
"geojson",
]
oracle = ["cx-Oracle>8.0.0, <8.4"]
oracle = ["oracledb>=2.0.0, <5"]
parseable = ["sqlalchemy-parseable>=0.1.3,<0.2.0"]
pinot = ["pinotdb>=5.0.0, <10.0.0"]
playwright = ["playwright>=1.60.0, <2"]

View File

@@ -141,7 +141,7 @@ flask-login==0.6.3
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
flask-migrate==3.1.0
flask-migrate==4.1.0
# via apache-superset (pyproject.toml)
flask-session==0.8.0
# via apache-superset (pyproject.toml)

View File

@@ -293,7 +293,7 @@ flask-login==0.6.3
# -c requirements/base-constraint.txt
# apache-superset
# flask-appbuilder
flask-migrate==3.1.0
flask-migrate==4.1.0
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -8450,9 +8450,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8470,9 +8467,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8490,9 +8484,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8510,9 +8501,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8530,9 +8518,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8550,9 +8535,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8570,9 +8552,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8590,9 +8569,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -26132,6 +26108,21 @@
}
}
},
"node_modules/jsdom/node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/jsdom/node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
@@ -43244,6 +43235,21 @@
}
}
},
"node_modules/whatwg-url/node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/whatwg-url/node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",

View File

@@ -18,6 +18,14 @@
"types": "./lib/authentication/index.d.ts",
"default": "./lib/authentication/index.js"
},
"./chat": {
"types": "./lib/chat/index.d.ts",
"default": "./lib/chat/index.js"
},
"./navigation": {
"types": "./lib/navigation/index.d.ts",
"default": "./lib/navigation/index.js"
},
"./commands": {
"types": "./lib/commands/index.d.ts",
"default": "./lib/commands/index.js"

View File

@@ -0,0 +1,184 @@
/**
* 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.
*/
/**
* @fileoverview Chat contribution API for Superset extensions.
*
* Chat is a dedicated contribution type (not a view): an extension registers
* a chat via {@link registerChat} and the host owns where and how it is
* mounted. The host applies singleton resolution — multiple chat extensions
* may register, but exactly one is active at a time.
*
* @example
* ```typescript
* import { chat } from '@apache-superset/core';
*
* chat.registerChat(
* { id: 'acme.chat', name: 'Acme Chat' },
* () => <AcmeTrigger />,
* () => <AcmePanel />,
* );
* ```
*/
import { ReactElement } from 'react';
import type { Disposable, Event } from '../common';
export interface Chat {
/** The unique identifier for the chat. */
id: string;
/** The display name of the chat. */
name: string;
/** Optional description of the chat, for display in contribution manifests. */
description?: string;
}
export type DisplayMode = 'floating' | 'panel';
/**
* Registers a chat provider. The host applies singleton resolution — only one
* chat is active at a time: the most recently registered chat wins, and
* disposing it restores the previously registered one. Re-registering an id
* replaces that registration in place.
*
* When a registration with a different id takes over the active slot (or the
* active chat is disposed), the host closes the panel first, firing
* {@link onDidClose}; an in-place same-id replacement keeps the open state.
*
* Disposing the returned Disposable unregisters the chat.
*
* @param chat The chat descriptor (id, name).
* @param trigger A function returning the collapsed bubble element. Owned by
* the extension — dynamic state such as unread counts and badges lives here.
* Hidden by the host when in panel mode.
* @param panel A function returning the chat panel element. Mounted by the
* host as a floating overlay in 'floating' mode, or docked at the side of
* the viewport in 'panel' mode (the reference host docks a fixed-width
* overlay at the right edge; hosts may integrate a true layout slot
* instead). Same component in both modes.
* @returns A Disposable that unregisters the chat when disposed.
*
* @example
* ```typescript
* chat.registerChat(
* { id: 'acme.chat', name: 'Acme Chat' },
* () => <AcmeTrigger />,
* () => <AcmePanel />,
* );
* ```
*/
export declare function registerChat(
chat: Chat,
trigger: () => ReactElement,
panel: () => ReactElement,
): Disposable;
/**
* Returns the active chat descriptor.
*
* @returns A copy of the active Chat descriptor, or undefined if none is
* registered. Mutating the returned object has no effect on the registry.
*/
export declare function getChat(): Chat | undefined;
/**
* Event fired when a chat is registered.
*/
export declare const onDidRegisterChat: Event<Chat>;
/**
* Event fired when a chat is unregistered.
*/
export declare const onDidUnregisterChat: Event<Chat>;
/**
* Opens the active chat's panel.
*
* Acts on whichever chat is active, regardless of which extension calls it.
* No-op when no chat is registered or the panel is already open.
*/
export declare function open(): void;
/**
* Closes the active chat's panel.
*
* Acts on whichever chat is active, regardless of which extension calls it.
* No-op when the panel is not open.
*/
export declare function close(): void;
/**
* Returns whether the active chat's panel is currently open.
*
* @returns True if the chat panel is open.
*/
export declare function isOpen(): boolean;
/**
* Event fired when the chat panel opens. Also fired by the host's own
* controls, not only by an extension's open() call.
*/
export declare const onDidOpen: Event<void>;
/**
* Event fired when the chat panel closes. Also fired when the host closes the
* panel itself, e.g. because the active chat was disposed or displaced by a
* different chat.
*/
export declare const onDidClose: Event<void>;
/**
* Returns the current display mode.
*
* @returns The current DisplayMode.
*/
export declare function getDisplayMode(): DisplayMode;
/**
* Sets the display mode.
*
* The mode is host-global and applies to whichever chat is active, regardless
* of which extension calls it. Hosts may also change the mode through their
* own controls — use onDidChangeDisplayMode to observe all changes rather than
* assuming the last setDisplayMode() call won.
*
* @param displayMode The display mode to switch to.
*/
export declare function setDisplayMode(displayMode: DisplayMode): void;
/**
* Event fired when the display mode changes, whether triggered by an
* extension via setDisplayMode() or by host-provided controls.
*/
export declare const onDidChangeDisplayMode: Event<DisplayMode>;
/**
* Event fired when the panel is resized in panel mode.
*
* The host owns the resizer handle and drag interaction; a host without a
* resizer never fires this event. (The reference host mounts the panel at a
* fixed width and does not provide a resizer, so subscribers receive no
* events there.) Listen to this event to adapt internal layout to the
* available width; do not rely on it firing.
*/
export declare const onDidResizePanel: Event<{ width: number }>;
// TODO: client actions API — tool availability functions will be added here
// once the client_actions SIP is finalized. The chat namespace is the
// intended integration point between the two SIPs.

View File

@@ -223,8 +223,6 @@ export interface Extension {
dependencies: string[];
/** Human-readable description of the extension */
description: string;
/** List of other extensions that this extension depends on */
extensionDependencies: string[];
/** Unique identifier for the extension */
id: string;
/** Human-readable name of the extension */

View File

@@ -23,9 +23,10 @@
* This module defines the aggregate interfaces used by the extension.json
* manifest and the `superset-extensions` build command. Individual metadata
* types are defined in their respective namespace modules (commands, views,
* menus, editors) and re-exported here for the manifest schema.
* menus, editors, chat) and re-exported here for the manifest schema.
*/
import { Chat } from '../chat';
import { Command } from '../commands';
import { View } from '../views';
import { Menu } from '../menus';
@@ -71,7 +72,8 @@ export interface MenuContributions {
}
/**
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
* Aggregates all contributions (commands, menus, views, editors, and chat)
* provided by an extension or module.
*/
export interface Contributions {
/** List of commands. */
@@ -82,4 +84,10 @@ export interface Contributions {
views: ViewContributions;
/** List of editors. */
editors?: Editor[];
/**
* The chat contributed by the extension — at most one per extension, since
* the host applies singleton resolution and renders exactly one active
* chat at a time.
*/
chat?: Chat;
}

View File

@@ -18,10 +18,12 @@
*/
export * as common from './common';
export * as authentication from './authentication';
export * as chat from './chat';
export * as commands from './commands';
export * as editors from './editors';
export * as extensions from './extensions';
export * as menus from './menus';
export * as navigation from './navigation';
export * as sqlLab from './sqlLab';
export * as views from './views';
export * as contributions from './contributions';

View File

@@ -0,0 +1,81 @@
/**
* 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.
*/
/**
* @fileoverview Navigation namespace for Superset extensions.
*
* Exposes the current application surface so extensions can react to route
* changes without polling. Entity-level context (chart, dashboard, dataset)
* is intentionally not included here — surface-specific namespaces that
* resolve entity payloads are introduced in later phases.
*/
import { Event } from '../common';
/**
* The set of top-level application surfaces.
*
* `'explore'`, `'dashboard'` and `'dataset'` are the single-entity
* editing/viewing surfaces. `'chart_list'`, `'dashboard_list'` and
* `'dataset_list'` are the browse/list surfaces, distinct from those because no
* single entity is active. `'sqllab'` is the SQL editor where
* `sqlLab.getCurrentTab()` resolves; `'query_history'` and `'saved_queries'`
* are the related SQL Lab browse pages, which are not the editor. `'home'` is
* the welcome surface and the fallback for any route not explicitly enumerated.
*/
export type Page =
| 'dashboard'
| 'dashboard_list'
| 'explore'
| 'chart_list'
| 'sqllab'
| 'query_history'
| 'saved_queries'
| 'dataset'
| 'dataset_list'
| 'home';
/**
* Returns the current page surface.
*
* @example
* ```typescript
* const page = navigation.getPage();
* if (page === 'dashboard') {
* // react to being on a dashboard surface
* }
* ```
*/
export declare function getPage(): Page;
/**
* Event fired whenever the user navigates to a different surface.
*
* @example
* ```typescript
* const sub = navigation.onDidChangePage(page => {
* if (page === 'dashboard') {
* // react to navigating onto a dashboard surface
* }
* });
* // later:
* sub.dispose();
* ```
*/
export declare const onDidChangePage: Event<Page>;

View File

@@ -39,10 +39,8 @@ jest.mock('@superset-ui/core/components/Select/AsyncSelect', () => () => (
<div data-test="mock-async-select" />
));
jest.mock('src/core/editors', () => ({
EditorHost: ({ value, height }: { value: string; height: string }) => (
<div data-test="mock-async-ace-editor" data-height={height}>
{value}
</div>
EditorHost: ({ value }: { value: string }) => (
<div data-test="mock-async-ace-editor">{value}</div>
),
}));
@@ -81,18 +79,6 @@ describe('TemplateParamsEditor', () => {
});
});
test('renders the editor with a bounded height to avoid overflowing the popover', async () => {
const { container, getByTestId } = setup();
fireEvent.click(getByText(container, 'Parameters'));
await waitFor(() => {
expect(getByTestId('mock-async-ace-editor')).toBeInTheDocument();
});
expect(getByTestId('mock-async-ace-editor')).toHaveAttribute(
'data-height',
'360px',
);
});
test('renders templateParams', async () => {
const { container, getByTestId } = setup();
fireEvent.click(getByText(container, 'Parameters'));

View File

@@ -30,9 +30,10 @@ import {
import { EditorHost } from 'src/core/editors';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
const EditorOutline = styled.div`
border: 1px solid ${({ theme }) => theme.colorBorder};
border-radius: ${({ theme }) => theme.borderRadius}px;
const StyledEditorHost = styled(EditorHost)`
&.ace_editor {
border: 1px solid ${({ theme }) => theme.colorBorder};
}
`;
const StyledParagraph = styled.p`
@@ -86,16 +87,14 @@ const TemplateParamsEditor = ({
</a>{' '}
{t('syntax.')}
</StyledParagraph>
<EditorOutline>
<EditorHost
id={`template-params-${queryEditorId}`}
height="360px"
onChange={debounce(onChange, Constants.FAST_DEBOUNCE)}
language={language === 'yaml' ? 'yaml' : 'json'}
width="100%"
value={code}
/>
</EditorOutline>
<StyledEditorHost
id={`template-params-${queryEditorId}`}
height="800px"
onChange={debounce(onChange, Constants.FAST_DEBOUNCE)}
language={language === 'yaml' ? 'yaml' : 'json'}
width="100%"
value={code}
/>
</div>
);

View File

@@ -0,0 +1,287 @@
/**
* 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 { act, render, screen } from 'spec/helpers/testing-library';
import { chat } from 'src/core/chat';
import ChatMount from '.';
const disposables: Array<{ dispose: () => void }> = [];
afterEach(() => {
act(() => {
disposables.forEach(d => d.dispose());
disposables.length = 0;
// Reset host-owned state shared across tests in this module.
chat.close();
chat.setDisplayMode('floating');
});
});
test('renders nothing when no chat extension is registered', () => {
render(<ChatMount />);
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
});
test('renders the trigger bubble of the registered chat', () => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
),
);
render(<ChatMount />);
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
// The panel stays unmounted until the chat is opened.
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
});
test('mounts the panel when the chat opens and unmounts it on close', () => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
),
);
render(<ChatMount />);
act(() => chat.open());
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
// In floating mode the trigger stays mounted alongside the open panel.
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
act(() => chat.close());
expect(screen.queryByText('Acme Panel')).not.toBeInTheDocument();
});
test('renders the last-registered chat when several are installed', () => {
disposables.push(
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <div>First Bubble</div>,
() => <div>First Panel</div>,
),
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
),
);
render(<ChatMount />);
// Last-loaded wins: the second registration takes over the singleton slot.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
expect(screen.queryByText('First Bubble')).not.toBeInTheDocument();
});
test('reacts to a chat registering after the initial render', () => {
render(<ChatMount />);
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
act(() => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
),
);
});
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
});
test('a takeover mounts the incoming chat closed', () => {
disposables.push(
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <div>First Bubble</div>,
() => <div>First Panel</div>,
),
);
render(<ChatMount />);
act(() => chat.open());
expect(screen.getByText('First Panel')).toBeInTheDocument();
act(() => {
disposables.push(
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
),
);
});
// The displaced chat's open state must not leak into the winner.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
expect(screen.queryByText('Second Panel')).not.toBeInTheDocument();
expect(screen.queryByText('First Panel')).not.toBeInTheDocument();
});
test('panel mode docks the open panel and hides the trigger', () => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <div>Acme Panel</div>,
),
);
render(<ChatMount />);
act(() => {
chat.setDisplayMode('panel');
chat.open();
});
expect(screen.getByText('Acme Panel')).toBeInTheDocument();
expect(screen.queryByText('Acme Bubble')).not.toBeInTheDocument();
act(() => chat.close());
// A closed chat in panel mode renders nothing — the trigger is hidden too.
expect(screen.queryByTestId('chat-mount')).not.toBeInTheDocument();
});
test('a crashing panel does not take the trigger down with it', () => {
const FailingPanel = () => {
throw new Error('panel blew up');
};
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <button type="button">Acme Bubble</button>,
() => <FailingPanel />,
),
);
render(<ChatMount />);
act(() => chat.open());
// The panel's boundary contains the crash; the trigger keeps rendering so
// the user is not stranded without a way back.
expect(screen.queryByText('panel blew up')).not.toBeInTheDocument();
expect(screen.getByText('Acme Bubble')).toBeInTheDocument();
});
test('isolates a failing trigger so it does not crash the host', () => {
const FailingTrigger = () => {
throw new Error('chat blew up');
};
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <FailingTrigger />,
() => <div>Acme Panel</div>,
),
);
// The host-owned error boundary catches the failure; render does not throw.
expect(() => render(<ChatMount />)).not.toThrow();
// The mount slot still renders (the boundary lives inside it), confirming
// the provider was actually exercised and contained.
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
});
test('isolates a chat whose provider function itself throws', () => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => {
throw new Error('provider blew up');
},
() => <div>Acme Panel</div>,
),
);
// ChatRenderer wraps provider() in a component so ErrorBoundary catches
// synchronous throws from the provider function, not just from its output.
expect(() => render(<ChatMount />)).not.toThrow();
expect(screen.getByTestId('chat-mount')).toBeInTheDocument();
});
test('recovers from a crashed chat when a different chat takes over', () => {
const FailingTrigger = () => {
throw new Error('first chat blew up');
};
disposables.push(
chat.registerChat(
{ id: 'first.chat', name: 'First Chat' },
() => <FailingTrigger />,
() => <div>First Panel</div>,
),
);
render(<ChatMount />);
expect(screen.queryByText('Second Bubble')).not.toBeInTheDocument();
act(() => {
disposables.push(
chat.registerChat(
{ id: 'second.chat', name: 'Second Chat' },
() => <div>Second Bubble</div>,
() => <div>Second Panel</div>,
),
);
});
// The boundary is keyed per registration, so the latched crash from the
// first chat does not blank the second one.
expect(screen.getByText('Second Bubble')).toBeInTheDocument();
});
test('recovers when a crashed chat re-registers a fixed version under the same id', () => {
const FailingTrigger = () => {
throw new Error('broken release');
};
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <FailingTrigger />,
() => <div>Acme Panel</div>,
),
);
render(<ChatMount />);
expect(screen.queryByText('Fixed Bubble')).not.toBeInTheDocument();
act(() => {
disposables.push(
chat.registerChat(
{ id: 'acme.chat', name: 'Acme Chat' },
() => <div>Fixed Bubble</div>,
() => <div>Acme Panel</div>,
),
);
});
// Same id, new registrationId: the remounted boundary renders the fix.
expect(screen.getByText('Fixed Bubble')).toBeInTheDocument();
});

View File

@@ -0,0 +1,149 @@
/**
* 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 { type ReactElement, useRef, useSyncExternalStore } from 'react';
import { t } from '@apache-superset/core/translation';
import { logging } from '@apache-superset/core/utils';
import { css, useTheme } from '@apache-superset/core/theme';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { store } from 'src/views/store';
import { getChatSnapshot, subscribeToChatState } from 'src/core/chat';
const CHAT_EDGE_MARGIN = 24;
const PANEL_MODE_WIDTH = 400;
/**
* Wraps a chat provider in a React component so that ErrorBoundary can catch
* synchronous throws from the provider function itself. Calling `provider()`
* inline (e.g. `{activeChat.panel()}`) would throw outside React's render
* boundary and crash the host.
*/
const ChatRenderer = ({ provider }: { provider: () => ReactElement }) =>
provider();
const ChatMount = () => {
const theme = useTheme();
// Notify at most once per registration; a crash can re-render and would
// otherwise re-toast, while a replacement (new registrationId) deserves a
// fresh notification if it crashes too.
const crashNotifiedFor = useRef<number | null>(null);
// The active chat, the open state, and the display mode are read from one
// immutable registry snapshot so a render never mixes state from two
// different store versions (the tearing useSyncExternalStore prevents).
const {
open: panelOpen,
mode,
active,
} = useSyncExternalStore(subscribeToChatState, getChatSnapshot);
if (!active) {
return null;
}
const { registrationId } = active;
const onProviderError = (error: Error) => {
// Fault isolation: contain the crash, log it, surface a one-time
// notification, and leave the slot empty rather than parking a
// persistent error card.
logging.error('[chat] provider crashed', error);
if (crashNotifiedFor.current !== registrationId) {
crashNotifiedFor.current = registrationId;
store.dispatch(addDangerToast(t('The chat failed to load.')));
}
};
if (mode === 'panel') {
// Panel mode hides the trigger and docks the panel to the right edge.
// Interim approximation of the "layout slot between header and footer"
// from the chat API contract — the dock overlays the page until the host
// grows a real layout slot and resizer chrome.
if (!panelOpen) {
return null;
}
return (
<div
data-test="chat-mount"
css={css`
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: ${PANEL_MODE_WIDTH}px;
background: ${theme.colorBgContainer};
box-shadow: ${theme.boxShadow};
/* Above dashboard content and the toast layer, below modal dialogs. */
z-index: ${theme.zIndexPopupBase + 2};
`}
>
<ErrorBoundary
key={registrationId}
showMessage={false}
onError={onProviderError}
>
<ChatRenderer provider={active.panel} />
</ErrorBoundary>
</div>
);
}
return (
<div
data-test="chat-mount"
css={css`
position: fixed;
right: ${CHAT_EDGE_MARGIN}px;
bottom: ${CHAT_EDGE_MARGIN}px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: ${theme.sizeUnit * 2}px;
/* Above dashboard content and the toast layer, below modal dialogs. */
z-index: ${theme.zIndexPopupBase + 2};
`}
>
{/*
Each provider gets its own boundary so a crashing panel cannot take
the trigger down with it (the trigger is the user's only way back).
Keyed by registrationId: Superset's ErrorBoundary latches its error
state, so a takeover, fallback, or same-id re-registration must
remount the boundary to recover.
*/}
{panelOpen && (
<ErrorBoundary
key={`panel-${registrationId}`}
showMessage={false}
onError={onProviderError}
>
<ChatRenderer provider={active.panel} />
</ErrorBoundary>
)}
<ErrorBoundary
key={`trigger-${registrationId}`}
showMessage={false}
onError={onProviderError}
>
<ChatRenderer provider={active.trigger} />
</ErrorBoundary>
</div>
);
};
export default ChatMount;

View File

@@ -0,0 +1,327 @@
/**
* 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 { createElement } from 'react';
import { chat, getActiveChat, getChatSnapshot } from './index';
const disposables: Array<{ dispose: () => void }> = [];
const trigger = () => createElement('button', null, 'Bubble');
const panel = () => createElement('div', null, 'Panel');
afterEach(() => {
disposables.forEach(d => d.dispose());
disposables.length = 0;
// Reset host-owned state shared across tests in this module.
chat.close();
chat.setDisplayMode('floating');
});
test('getChat returns undefined when no chat is registered', () => {
expect(chat.getChat()).toBeUndefined();
expect(getActiveChat()).toBeUndefined();
});
test('registerChat resolves the registered chat with its providers', () => {
const descriptor = { id: 'acme.chat', name: 'Acme Chat' };
disposables.push(chat.registerChat(descriptor, trigger, panel));
expect(chat.getChat()).toEqual(descriptor);
expect(getActiveChat()).toMatchObject({ chat: descriptor, trigger, panel });
});
test('getChat returns a copy that cannot mutate the registry', () => {
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme Chat' }, trigger, panel),
);
const copy = chat.getChat();
copy!.name = 'Hijacked';
expect(chat.getChat()?.name).toBe('Acme Chat');
});
test('the last-registered chat wins when multiple are installed', () => {
disposables.push(
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
);
expect(chat.getChat()?.id).toBe('second.chat');
});
test('disposing the active chat falls back to the previous registration', () => {
disposables.push(
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
);
const second = chat.registerChat(
{ id: 'second.chat', name: 'Second' },
trigger,
panel,
);
expect(chat.getChat()?.id).toBe('second.chat');
second.dispose();
expect(chat.getChat()?.id).toBe('first.chat');
});
test('re-registering an id replaces the previous registration', () => {
const stalePanel = () => createElement('div', null, 'Stale');
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, stalePanel),
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
);
expect(chat.getChat()?.name).toBe('Acme v2');
expect(getActiveChat()?.panel).toBe(panel);
});
test('each registration gets a distinct registrationId, including same-id replacements', () => {
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
const first = getActiveChat()?.registrationId;
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
);
const second = getActiveChat()?.registrationId;
expect(first).toBeDefined();
expect(second).toBeDefined();
expect(second).not.toBe(first);
});
test('disposing a registration twice unregisters only once', () => {
const unregistered = jest.fn();
disposables.push(chat.onDidUnregisterChat(unregistered));
const registration = chat.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
registration.dispose();
registration.dispose();
expect(unregistered).toHaveBeenCalledTimes(1);
expect(chat.getChat()).toBeUndefined();
});
test('onDidRegisterChat and onDidUnregisterChat fire with the descriptor', () => {
const registered = jest.fn();
const unregistered = jest.fn();
disposables.push(
chat.onDidRegisterChat(registered),
chat.onDidUnregisterChat(unregistered),
);
const descriptor = { id: 'acme.chat', name: 'Acme' };
const registration = chat.registerChat(descriptor, trigger, panel);
expect(registered).toHaveBeenCalledWith(descriptor);
expect(unregistered).not.toHaveBeenCalled();
registration.dispose();
expect(unregistered).toHaveBeenCalledWith(descriptor);
});
test('a disposed event subscription stops receiving notifications', () => {
const registered = jest.fn();
const subscription = chat.onDidRegisterChat(registered);
subscription.dispose();
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
expect(registered).not.toHaveBeenCalled();
});
test('open and close toggle the panel and fire once', () => {
const opened = jest.fn();
const closed = jest.fn();
disposables.push(chat.onDidOpen(opened), chat.onDidClose(closed));
const descriptor = { id: 'acme.chat', name: 'Acme' };
disposables.push(chat.registerChat(descriptor, trigger, panel));
expect(chat.isOpen()).toBe(false);
chat.open();
// Opening an already-open panel is a no-op and must not re-fire.
chat.open();
expect(chat.isOpen()).toBe(true);
expect(opened).toHaveBeenCalledTimes(1);
chat.close();
chat.close();
expect(chat.isOpen()).toBe(false);
expect(closed).toHaveBeenCalledTimes(1);
});
test('open is a no-op while no chat is registered', () => {
const opened = jest.fn();
disposables.push(chat.onDidOpen(opened));
chat.open();
expect(chat.isOpen()).toBe(false);
expect(opened).not.toHaveBeenCalled();
// A registration arriving later therefore starts closed.
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
expect(chat.isOpen()).toBe(false);
});
test('a takeover by a different id closes the displaced chat panel', () => {
const closed = jest.fn();
disposables.push(chat.onDidClose(closed));
const first = { id: 'first.chat', name: 'First' };
disposables.push(chat.registerChat(first, trigger, panel));
chat.open();
disposables.push(
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
);
// The incoming chat must not mount into an open state it never requested.
expect(chat.isOpen()).toBe(false);
expect(closed).toHaveBeenCalledTimes(1);
});
test('a same-id replacement keeps the open state', () => {
const closed = jest.fn();
disposables.push(chat.onDidClose(closed));
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
chat.open();
// Upgrade in place: same id, new providers.
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme v2' }, trigger, panel),
);
expect(chat.isOpen()).toBe(true);
expect(closed).not.toHaveBeenCalled();
});
test('disposing the active chat while open closes it; the fallback starts closed', () => {
const closed = jest.fn();
disposables.push(chat.onDidClose(closed));
disposables.push(
chat.registerChat({ id: 'first.chat', name: 'First' }, trigger, panel),
);
const second = { id: 'second.chat', name: 'Second' };
const registration = chat.registerChat(second, trigger, panel);
chat.open();
registration.dispose();
expect(chat.getChat()?.id).toBe('first.chat');
expect(chat.isOpen()).toBe(false);
expect(closed).toHaveBeenCalledTimes(1);
});
test('disposing an inactive registration leaves the open state untouched', () => {
const closed = jest.fn();
disposables.push(chat.onDidClose(closed));
const inactive = chat.registerChat(
{ id: 'first.chat', name: 'First' },
trigger,
panel,
);
disposables.push(
chat.registerChat({ id: 'second.chat', name: 'Second' }, trigger, panel),
);
chat.open();
inactive.dispose();
expect(chat.isOpen()).toBe(true);
expect(closed).not.toHaveBeenCalled();
});
test('disposing the last chat while open resets the open state', () => {
const registration = chat.registerChat(
{ id: 'acme.chat', name: 'Acme' },
trigger,
panel,
);
chat.open();
expect(chat.isOpen()).toBe(true);
registration.dispose();
expect(chat.isOpen()).toBe(false);
// A registration arriving much later must not inherit a stale open state.
disposables.push(
chat.registerChat({ id: 'late.chat', name: 'Late' }, trigger, panel),
);
expect(chat.isOpen()).toBe(false);
});
test('mode defaults to floating and setDisplayMode fires only on change', () => {
const modeChanged = jest.fn();
disposables.push(chat.onDidChangeDisplayMode(modeChanged));
expect(chat.getDisplayMode()).toBe('floating');
// Setting the current mode is a no-op.
chat.setDisplayMode('floating');
expect(modeChanged).not.toHaveBeenCalled();
chat.setDisplayMode('panel');
expect(chat.getDisplayMode()).toBe('panel');
expect(modeChanged).toHaveBeenCalledWith('panel');
});
test('the snapshot is immutable per version and consistent with the registry', () => {
const before = getChatSnapshot();
disposables.push(
chat.registerChat({ id: 'acme.chat', name: 'Acme' }, trigger, panel),
);
chat.open();
const after = getChatSnapshot();
// Unchanged references for old snapshots; a new object per change.
expect(after).not.toBe(before);
expect(before.active).toBeUndefined();
expect(after).toMatchObject({
open: true,
mode: 'floating',
active: getActiveChat(),
});
expect(after.version).toBeGreaterThan(before.version);
// Stable reference between changes.
expect(getChatSnapshot()).toBe(after);
});

View File

@@ -0,0 +1,240 @@
/**
* 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.
*/
/**
* @fileoverview Host implementation of the `chat` contribution type.
*
* Chat is a dedicated contribution type, not a view: extensions register via
* the public `chat.registerChat()` and the host owns mounting, open/close
* state, and the display mode. Multiple chat extensions may register, but the
* host applies singleton resolution — the most-recently-registered chat is
* active; disposing it falls back to the previous one.
*
* Open-state policy across active-chat transitions: when the active chat's
* identity changes — a takeover by a different id, disposal falling back to a
* different id, or disposal of the last chat — the panel is closed (firing
* `onDidClose`) so the incoming chat never mounts into an open state it did
* not request. A same-id re-registration is an upgrade in place and keeps the
* open state.
*
* The public namespace (`chat`) is exposed to extensions on
* `window.superset`; the other exports are host-internal accessors for
* ChatMount and are NOT part of the public `@apache-superset/core` API.
*/
import { ReactElement } from 'react';
import type { chat as chatApi } from '@apache-superset/core';
import { Disposable } from '../models';
import { createValueEventEmitter, createEventEmitter } from '../utils';
type Chat = chatApi.Chat;
type DisplayMode = chatApi.DisplayMode;
/** A registered chat: its descriptor plus the host-mountable providers. */
export interface RegisteredChat {
/** The chat descriptor passed to `registerChat`. */
chat: Chat;
/** Renders the collapsed bubble. Hidden by the host in panel mode. */
trigger: () => ReactElement;
/** Renders the chat panel, mounted per the current {@link DisplayMode}. */
panel: () => ReactElement;
/**
* Unique per registration (a same-id re-registration gets a new one). The
* host UI keys mounts and fault containment on it, so a replacement resets
* crashed error boundaries instead of inheriting their latched state.
*/
registrationId: number;
}
/**
* Immutable snapshot of the whole chat state, rebuilt on every change.
* Returned by reference from `getChatSnapshot` so `useSyncExternalStore`
* consumers read registrations, open state, and mode from one consistent
* object instead of tearing across separate live reads.
*/
export interface ChatSnapshot {
/** Monotonic change counter, useful as a memo/effect dependency. */
version: number;
/** Whether the active chat's panel is open. */
open: boolean;
/** The current display mode. */
mode: DisplayMode;
/** The active registration, or undefined when none is registered. */
active: RegisteredChat | undefined;
}
/** Registration order is the singleton-resolution order: last entry wins. */
const registrations: RegisteredChat[] = [];
let panelOpen = false;
let nextRegistrationId = 1;
const registerEmitter = createEventEmitter<Chat>();
const unregisterEmitter = createEventEmitter<Chat>();
const openEmitter = createEventEmitter<void>();
const closeEmitter = createEventEmitter<void>();
const resizePanelEmitter = createEventEmitter<{ width: number }>();
const modeEmitter = createValueEventEmitter<DisplayMode>('floating');
/**
* Host-internal: resolves the active chat with its providers.
* The most-recently-registered chat wins; when it is disposed the previous
* registration takes over the slot again.
*/
export const getActiveChat = (): RegisteredChat | undefined =>
registrations[registrations.length - 1];
let snapshot: ChatSnapshot = {
version: 0,
open: false,
mode: modeEmitter.getCurrent(),
active: undefined,
};
const stateSubscribers = new Set<() => void>();
const notifyState = () => {
snapshot = {
version: snapshot.version + 1,
open: panelOpen,
mode: modeEmitter.getCurrent(),
active: getActiveChat(),
};
stateSubscribers.forEach(fn => fn());
};
export const subscribeToChatState = (listener: () => void): (() => void) => {
stateSubscribers.add(listener);
return () => {
stateSubscribers.delete(listener);
};
};
export const getChatSnapshot = (): ChatSnapshot => snapshot;
/** Closes the panel and fires `onDidClose`. */
const closePanel = () => {
panelOpen = false;
closeEmitter.fire();
};
const registerChat: typeof chatApi.registerChat = (
chat: Chat,
trigger: () => ReactElement,
panel: () => ReactElement,
): Disposable => {
const previousActive = getActiveChat();
// Re-registering an id replaces the previous entry and moves it to the
// most-recent position, mirroring the view registry's same-id semantics.
const existingIndex = registrations.findIndex(r => r.chat.id === chat.id);
if (existingIndex !== -1) {
registrations.splice(existingIndex, 1);
}
const entry: RegisteredChat = {
chat,
trigger,
panel,
registrationId: nextRegistrationId,
};
nextRegistrationId += 1;
registrations.push(entry);
registerEmitter.fire(chat);
// A takeover by a different id closes the displaced chat's panel so the
// incoming chat never mounts already-open; a same-id replacement is an
// upgrade in place and keeps the open state.
if (panelOpen && previousActive && previousActive.chat.id !== chat.id) {
closePanel();
}
notifyState();
return new Disposable(() => {
const index = registrations.indexOf(entry);
if (index === -1) {
// Already removed — replaced by a same-id registration or disposed twice.
return;
}
const wasActive = getActiveChat() === entry;
registrations.splice(index, 1);
unregisterEmitter.fire(chat);
// Disposing the active chat closes its panel; the fallback chat (if any)
// starts closed. Disposing an inactive registration leaves the open
// state of the active chat untouched.
if (panelOpen && wasActive) {
closePanel();
}
notifyState();
});
};
const getChat: typeof chatApi.getChat = (): Chat | undefined => {
const active = getActiveChat();
// Copy so extensions cannot mutate another extension's descriptor.
return active ? { ...active.chat } : undefined;
};
const open: typeof chatApi.open = (): void => {
const active = getActiveChat();
// Open state only exists while a chat is registered; opening an empty slot
// would otherwise leak `open` into a future, unrelated registration.
if (panelOpen || !active) return;
panelOpen = true;
openEmitter.fire();
notifyState();
};
const close: typeof chatApi.close = (): void => {
const active = getActiveChat();
if (!panelOpen || !active) return;
closePanel();
notifyState();
};
const isOpen: typeof chatApi.isOpen = (): boolean => panelOpen;
const getDisplayMode: typeof chatApi.getDisplayMode = (): DisplayMode =>
modeEmitter.getCurrent();
const setDisplayMode: typeof chatApi.setDisplayMode = (
displayMode: DisplayMode,
): void => {
if (displayMode === modeEmitter.getCurrent()) return;
modeEmitter.fire(displayMode);
notifyState();
};
export const chat: typeof chatApi = {
registerChat,
getChat,
onDidRegisterChat: registerEmitter.subscribe,
onDidUnregisterChat: unregisterEmitter.subscribe,
open,
close,
isOpen,
onDidOpen: openEmitter.subscribe,
onDidClose: closeEmitter.subscribe,
getDisplayMode,
setDisplayMode,
onDidChangeDisplayMode: modeEmitter.subscribe,
// The host fires this from its panel resizer; until that chrome exists the
// event is exposed but never fires.
onDidResizePanel: resizePanelEmitter.subscribe,
};

View File

@@ -254,33 +254,6 @@ test('event listeners can be disposed', () => {
expect(listener).toHaveBeenCalledTimes(1); // Still only 1 call
});
test('handles errors in event listeners gracefully', () => {
const manager = EditorProviders.getInstance();
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const errorListener = jest.fn(() => {
throw new Error('Listener error');
});
const successListener = jest.fn();
manager.onDidRegister(errorListener);
manager.onDidRegister(successListener);
manager.registerProvider(createMockEditor(), createMockEditorComponent());
// Both listeners should have been called
expect(errorListener).toHaveBeenCalledTimes(1);
expect(successListener).toHaveBeenCalledTimes(1);
// Error should have been logged
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error in event listener:',
expect.any(Error),
);
consoleErrorSpy.mockRestore();
});
test('reset clears all providers and language mappings', () => {
const manager = EditorProviders.getInstance();

View File

@@ -19,6 +19,7 @@
import type { editors } from '@apache-superset/core';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type EditorLanguage = editors.EditorLanguage;
type EditorProvider = editors.EditorProvider;
@@ -27,45 +28,8 @@ type EditorComponent = editors.EditorComponent;
type EditorRegisteredEvent = editors.EditorRegisteredEvent;
type EditorUnregisteredEvent = editors.EditorUnregisteredEvent;
/**
* Listener function type for events.
*/
type Listener<T> = (e: T) => void;
/**
* Simple event emitter for editor provider lifecycle events.
*/
class EventEmitter<T> {
private listeners: Set<Listener<T>> = new Set();
/**
* Subscribe to this event.
* @param listener The listener function to call when the event is fired.
* @returns A Disposable to unsubscribe from the event.
*/
subscribe(listener: Listener<T>): Disposable {
this.listeners.add(listener);
return new Disposable(() => {
this.listeners.delete(listener);
});
}
/**
* Fire the event with the given data.
* @param data The event data to pass to listeners.
*/
fire(data: T): void {
this.listeners.forEach(listener => {
try {
listener(data);
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error in event listener:', error);
}
});
}
}
/**
* Singleton manager for editor providers.
* Handles registration, resolution, and lifecycle of custom editor implementations.
@@ -83,15 +47,9 @@ class EditorProviders {
*/
private languageToProvider: Map<EditorLanguage, string> = new Map();
/**
* Event emitter for provider registration events.
*/
private registerEmitter = new EventEmitter<EditorRegisteredEvent>();
private registerEmitter = createEventEmitter<EditorRegisteredEvent>();
/**
* Event emitter for provider unregistration events.
*/
private unregisterEmitter = new EventEmitter<EditorUnregisteredEvent>();
private unregisterEmitter = createEventEmitter<EditorUnregisteredEvent>();
private syncListeners: Set<() => void> = new Set();

View File

@@ -27,11 +27,13 @@ export const core: typeof coreType = {
};
export * from './authentication';
export * from './chat';
export * from './commands';
export * from './editors';
export * from './extensions';
export * from './menus';
export * from './models';
export * from './navigation';
export * from './sqlLab';
export * from './utils';
export * from './views';

View File

@@ -27,6 +27,7 @@
import { useSyncExternalStore } from 'react';
import type { menus as menusApi } from '@apache-superset/core';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type MenuItem = menusApi.MenuItem;
type Menu = menusApi.Menu;
@@ -47,19 +48,19 @@ const subscribe = (listener: () => void) => {
return () => syncListeners.delete(listener);
};
const registerListeners = new Set<(e: MenuItemRegisteredEvent) => void>();
const unregisterListeners = new Set<(e: MenuItemUnregisteredEvent) => void>();
const registerEmitter = createEventEmitter<MenuItemRegisteredEvent>();
const unregisterEmitter = createEventEmitter<MenuItemUnregisteredEvent>();
const menuCache = new Map<string, Menu | undefined>();
const notifyRegister = (event: MenuItemRegisteredEvent) => {
menuCache.clear();
syncListeners.forEach(l => l());
registerListeners.forEach(l => l(event));
registerEmitter.fire(event);
};
const notifyUnregister = (event: MenuItemUnregisteredEvent) => {
menuCache.clear();
syncListeners.forEach(l => l());
unregisterListeners.forEach(l => l(event));
unregisterEmitter.fire(event);
};
const registerMenuItem: typeof menusApi.registerMenuItem = (
@@ -117,16 +118,11 @@ export const useMenu = (location: string): Menu | undefined =>
export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = (
listener: (e: MenuItemRegisteredEvent) => void,
): Disposable => {
registerListeners.add(listener);
return new Disposable(() => registerListeners.delete(listener));
};
): Disposable => registerEmitter.subscribe(listener);
export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem =
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable => {
unregisterListeners.add(listener);
return new Disposable(() => unregisterListeners.delete(listener));
};
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable =>
unregisterEmitter.subscribe(listener);
export const menus: typeof menusApi = {
registerMenuItem,

View File

@@ -0,0 +1,124 @@
/**
* 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.
*/
// Reset module state between tests so currentPage is re-initialized.
beforeEach(() => {
jest.resetModules();
Object.defineProperty(window, 'location', {
writable: true,
value: { pathname: '/' },
});
});
async function importNavigation() {
const mod = await import('./index');
return mod;
}
test('getPage falls back to "home" for the welcome page and unknown pathnames', async () => {
const { navigation, notifyPageChange } = await importNavigation();
// The default pathname ('/') is not enumerated and falls back to home.
expect(navigation.getPage()).toBe('home');
notifyPageChange('/superset/welcome/');
expect(navigation.getPage()).toBe('home');
});
test('getPage derives the page from window.location.pathname', async () => {
window.location.pathname = '/superset/dashboard/42/';
const { navigation } = await importNavigation();
expect(navigation.getPage()).toBe('dashboard');
});
test('notifyPageChange updates the current page type', async () => {
const { navigation, notifyPageChange } = await importNavigation();
notifyPageChange('/explore/?form_data={}');
expect(navigation.getPage()).toBe('explore');
});
test('notifyPageChange fires listeners on page type change', async () => {
const { navigation, notifyPageChange } = await importNavigation();
const listener = jest.fn();
const disposable = navigation.onDidChangePage(listener);
notifyPageChange('/superset/dashboard/1/');
expect(listener).toHaveBeenCalledWith('dashboard');
disposable.dispose();
});
test('notifyPageChange does not fire listeners when page type is unchanged', async () => {
window.location.pathname = '/superset/dashboard/1/';
const { navigation, notifyPageChange } = await importNavigation();
const listener = jest.fn();
navigation.onDidChangePage(listener);
notifyPageChange('/superset/dashboard/2/');
expect(listener).not.toHaveBeenCalled();
});
test('onDidChangePage listener is removed after dispose', async () => {
const { navigation, notifyPageChange } = await importNavigation();
const listener = jest.fn();
const disposable = navigation.onDidChangePage(listener);
disposable.dispose();
notifyPageChange('/superset/dashboard/1/');
expect(listener).not.toHaveBeenCalled();
});
test('sqllab path is matched with and without trailing slash', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/sqllab');
expect(navigation.getPage()).toBe('sqllab');
notifyPageChange('/explore/');
notifyPageChange('/sqllab/');
expect(navigation.getPage()).toBe('sqllab');
});
test('chart and dashboard list pages get their own page types', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/chart/list/');
expect(navigation.getPage()).toBe('chart_list');
notifyPageChange('/dashboard/list/');
expect(navigation.getPage()).toBe('dashboard_list');
});
test('dataset list and single-dataset pages get distinct page types', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/tablemodelview/list/');
expect(navigation.getPage()).toBe('dataset_list');
notifyPageChange('/dataset/42');
expect(navigation.getPage()).toBe('dataset');
});
test('sqllab editor, query history, and saved queries get distinct page types', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/sqllab/');
expect(navigation.getPage()).toBe('sqllab');
notifyPageChange('/sqllab/history/');
expect(navigation.getPage()).toBe('query_history');
notifyPageChange('/savedqueryview/list/');
expect(navigation.getPage()).toBe('saved_queries');
});
test('chart/add resolves to explore, not chart_list', async () => {
const { notifyPageChange, navigation } = await importNavigation();
notifyPageChange('/chart/add');
expect(navigation.getPage()).toBe('explore');
});

View File

@@ -0,0 +1,79 @@
/**
* 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.
*/
/**
* Host-internal implementation of the `navigation` namespace.
*
* Backed by browser location — no Redux dependency.
* The app shell calls `notifyPageChange(pathname)` whenever the route changes.
*/
import type { navigation as navigationApi } from '@apache-superset/core';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type Page = navigationApi.Page;
const pageChangeEmitter = createEventEmitter<Page>();
function derivePage(pathname: string): Page {
if (pathname.startsWith('/superset/dashboard/')) return 'dashboard';
if (pathname.startsWith('/dashboard/list')) return 'dashboard_list';
if (pathname.startsWith('/explore/')) return 'explore';
if (pathname.startsWith('/superset/explore/')) return 'explore';
if (pathname.startsWith('/chart/add')) return 'explore';
if (pathname.startsWith('/chart/list')) return 'chart_list';
if (pathname.startsWith('/sqllab/history')) return 'query_history';
if (pathname.startsWith('/savedqueryview/list')) return 'saved_queries';
if (pathname === '/sqllab' || pathname.startsWith('/sqllab/'))
return 'sqllab';
if (pathname.startsWith('/tablemodelview/list')) return 'dataset_list';
if (pathname.startsWith('/dataset/')) return 'dataset';
// The welcome page and any route not explicitly enumerated fall back to home.
return 'home';
}
let currentPage: Page | undefined;
function getOrInitPage(): Page {
if (currentPage === undefined) {
currentPage = derivePage(window.location.pathname);
}
return currentPage;
}
/** Called by ExtensionsStartup whenever the React Router location changes. */
export const notifyPageChange = (pathname: string): void => {
const next = derivePage(pathname);
if (next === getOrInitPage()) return;
currentPage = next;
pageChangeEmitter.fire(next);
};
const getPage: typeof navigationApi.getPage = () => getOrInitPage();
const onDidChangePage: typeof navigationApi.onDidChangePage = (
listener: (page: Page) => void,
thisArgs?: any,
): Disposable => pageChangeEmitter.subscribe(listener, thisArgs);
export const navigation: typeof navigationApi = {
getPage,
onDidChangePage,
};

View File

@@ -21,6 +21,57 @@ import { AnyAction } from 'redux';
import { listenerMiddleware, RootState, store } from 'src/views/store';
import { AnyListenerPredicate } from '@reduxjs/toolkit';
type Listener<T> = (e: T) => unknown;
/** A stateless event emitter exposing a VS Code-style `event` subscriber. */
export interface EventEmitter<T> {
/** Notifies every current subscriber with `value`. */
fire(value: T): void;
/** Registers a listener; returns a Disposable that removes it. */
subscribe: core.Event<T>;
}
/** An event emitter that also retains the last fired value. */
export interface ValueEventEmitter<T> extends EventEmitter<T> {
/** Returns the value last passed to {@link fire} (or the initial value). */
getCurrent(): T;
}
/**
* Creates a stateless event emitter. Listeners registered via `event` receive
* every subsequent `fire`; a returned Disposable removes the listener.
*/
export function createEventEmitter<T>(): EventEmitter<T> {
const listeners = new Set<Listener<T>>();
const subscribe: core.Event<T> = (listener, thisArgs) => {
const bound = thisArgs ? listener.bind(thisArgs) : listener;
listeners.add(bound);
return { dispose: () => listeners.delete(bound) };
};
return {
fire: value => listeners.forEach(fn => fn(value)),
subscribe,
};
}
/**
* Creates a value event emitter seeded with `initial`. Behaves like
* {@link createEventEmitter} but also tracks the last fired value, readable
* via `getCurrent` — useful for state that is both observed and queried.
*/
export function createValueEventEmitter<T>(initial: T): ValueEventEmitter<T> {
const { fire, subscribe } = createEventEmitter<T>();
let current = initial;
return {
fire: value => {
current = value;
fire(value);
},
subscribe,
getCurrent: () => current,
};
}
export function createActionListener<V>(
predicate: AnyListenerPredicate<RootState>,
listener: (v: V) => void,

View File

@@ -29,6 +29,7 @@ import type { views as viewsApi } from '@apache-superset/core';
import { ErrorBoundary } from 'src/components/ErrorBoundary';
import ExtensionPlaceholder from 'src/extensions/ExtensionPlaceholder';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type View = viewsApi.View;
type ViewRegisteredEvent = viewsApi.ViewRegisteredEvent;
@@ -47,19 +48,19 @@ const subscribe = (listener: () => void) => {
return () => syncListeners.delete(listener);
};
const registerListeners = new Set<(e: ViewRegisteredEvent) => void>();
const unregisterListeners = new Set<(e: ViewUnregisteredEvent) => void>();
const registerEmitter = createEventEmitter<ViewRegisteredEvent>();
const unregisterEmitter = createEventEmitter<ViewUnregisteredEvent>();
const viewsCache = new Map<string, View[] | undefined>();
const notifyRegister = (event: ViewRegisteredEvent) => {
viewsCache.clear();
syncListeners.forEach(l => l());
registerListeners.forEach(l => l(event));
registerEmitter.fire(event);
};
const notifyUnregister = (event: ViewUnregisteredEvent) => {
viewsCache.clear();
syncListeners.forEach(l => l());
unregisterListeners.forEach(l => l(event));
unregisterEmitter.fire(event);
};
const registerView: typeof viewsApi.registerView = (
@@ -116,17 +117,11 @@ export const useViews = (location: string): View[] | undefined =>
export const onDidRegisterView: typeof viewsApi.onDidRegisterView = (
listener: (e: ViewRegisteredEvent) => void,
): Disposable => {
registerListeners.add(listener);
return new Disposable(() => registerListeners.delete(listener));
};
): Disposable => registerEmitter.subscribe(listener);
export const onDidUnregisterView: typeof viewsApi.onDidUnregisterView = (
listener: (e: ViewUnregisteredEvent) => void,
): Disposable => {
unregisterListeners.add(listener);
return new Disposable(() => unregisterListeners.delete(listener));
};
): Disposable => unregisterEmitter.subscribe(listener);
export const views: typeof viewsApi = {
registerView,

View File

@@ -45,6 +45,7 @@ import TextControl from 'src/explore/components/controls/TextControl';
import CheckboxControl from 'src/explore/components/controls/CheckboxControl';
import PopoverSection from '@superset-ui/core/components/PopoverSection';
import ControlHeader from 'src/explore/components/ControlHeader';
import { ensureAppRoot } from 'src/utils/pathUtils';
import {
ANNOTATION_SOURCE_TYPES,
ANNOTATION_TYPES,
@@ -145,7 +146,7 @@ const NotFoundContent = () => (
<span>
{t('Add an annotation layer')}{' '}
<a
href="/annotationlayer/list"
href={ensureAppRoot('/annotationlayer/list')}
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -31,7 +31,6 @@ function createMockExtension(overrides: Partial<Extension> = {}): Extension {
version: '1.0.0',
dependencies: [],
remoteEntry: '',
extensionDependencies: [],
...overrides,
};
}

View File

@@ -72,6 +72,7 @@ afterEach(() => {
test('renders without crashing', () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -88,6 +89,7 @@ test('sets up global superset object when user is logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -95,6 +97,7 @@ test('sets up global superset object when user is logged in', async () => {
// Verify the global superset object is set up
expect((window as any).superset).toBeDefined();
expect((window as any).superset.authentication).toBeDefined();
expect((window as any).superset.chat).toBeDefined();
expect((window as any).superset.core).toBeDefined();
expect((window as any).superset.commands).toBeDefined();
expect((window as any).superset.extensions).toBeDefined();
@@ -109,6 +112,7 @@ test('sets up global superset object when user is logged in', async () => {
test('does not set up global superset object when user is not logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialStateNoUser,
});
@@ -127,6 +131,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -144,6 +149,7 @@ test('initializes ExtensionsLoader when user is logged in', async () => {
test('does not initialize ExtensionsLoader when user is not logged in', async () => {
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialStateNoUser,
});
@@ -169,6 +175,7 @@ test('only initializes once even with multiple renders', async () => {
const { rerender } = render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -205,6 +212,7 @@ test('initializes ExtensionsLoader when EnableExtensions feature flag is enabled
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -234,6 +242,7 @@ test('does not initialize ExtensionsLoader when EnableExtensions feature flag is
render(<ExtensionsStartup />, {
useRedux: true,
useRouter: true,
initialState: mockInitialState,
});
@@ -268,6 +277,7 @@ test('continues rendering children even when ExtensionsLoader initialization fai
</ExtensionsStartup>,
{
useRedux: true,
useRouter: true,
initialState: mockInitialState,
},
);

View File

@@ -16,58 +16,78 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { logging } from '@apache-superset/core/utils';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
// eslint-disable-next-line no-restricted-syntax
import * as supersetCore from '@apache-superset/core';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import {
authentication,
chat,
core,
commands,
editors,
extensions,
menus,
navigation,
sqlLab,
views,
} from 'src/core';
import { notifyPageChange } from 'src/core/navigation';
import { useSelector } from 'react-redux';
import { RootState } from 'src/views/store';
import ExtensionsLoader from './ExtensionsLoader';
declare global {
interface Window {
superset: {
authentication: typeof authentication;
core: typeof core;
commands: typeof commands;
editors: typeof editors;
extensions: typeof extensions;
menus: typeof menus;
sqlLab: typeof sqlLab;
views: typeof views;
};
}
}
import 'src/extensions/Namespaces';
const ExtensionsStartup: React.FC<{ children?: React.ReactNode }> = ({
children,
}) => {
const location = useLocation();
const prevPathname = useRef<string | null>(null);
const userId = useSelector<RootState, number | undefined>(
({ user }) => user.userId,
);
// Notify the navigation namespace on every route change.
useEffect(() => {
if (userId == null) return;
if (prevPathname.current !== location.pathname) {
prevPathname.current = location.pathname;
notifyPageChange(location.pathname);
}
}, [location.pathname]);
// Provide the implementations for @apache-superset/core
// Log unhandled rejections that may originate from extension code.
// Registered once for the lifetime of the app; does not suppress the
// browser's default error surfacing so host error reporting is unaffected.
useEffect(() => {
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
logging.error('[extensions] Unhandled rejection:', event.reason);
};
window.addEventListener('unhandledrejection', handleUnhandledRejection);
return () => {
window.removeEventListener(
'unhandledrejection',
handleUnhandledRejection,
);
};
}, []);
useEffect(() => {
// Provide the implementations for @apache-superset/core.
// Namespaces are listed explicitly — do not spread the core package here,
// as that would leak un-contracted symbols onto window.superset.
window.superset = {
...supersetCore,
authentication,
chat,
core,
commands,
editors,
extensions,
menus,
navigation,
sqlLab,
views,
};

View File

@@ -0,0 +1,60 @@
/**
* 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.
*/
/**
* Global `window.superset` type augmentation.
*
* Lives in its own module (rather than inline in ExtensionsStartup) so every
* file that reads or writes `window.superset` — notably ExtensionsLoader —
* sees the type regardless of how files are batched during compilation. Both
* the startup component and the loader import this module for its side effect.
*/
import type {
authentication,
chat,
commands,
core,
editors,
extensions,
menus,
navigation,
sqlLab,
views,
} from 'src/core';
/** The host namespaces exposed to extensions on `window.superset`. */
export interface Namespaces {
authentication: typeof authentication;
core: typeof core;
chat: typeof chat;
commands: typeof commands;
editors: typeof editors;
extensions: typeof extensions;
menus: typeof menus;
navigation: typeof navigation;
sqlLab: typeof sqlLab;
views: typeof views;
}
declare global {
interface Window {
superset: Namespaces;
}
}

View File

@@ -29,8 +29,8 @@ import {
DatasetObject,
} from 'src/features/datasets/AddDataset/types';
import { Table } from 'src/hooks/apiResources';
import { Typography } from '@superset-ui/core/components/Typography';
import { ensureAppRoot } from 'src/utils/pathUtils';
import { Typography } from '@superset-ui/core/components/Typography';
interface LeftPanelProps {
setDataset: Dispatch<SetStateAction<object>>;

View File

@@ -218,10 +218,10 @@ export function Menu({
const path = location.pathname;
switch (true) {
case path.startsWith(Paths.Dashboard):
setActiveTabs(['Dashboards']);
setActiveTabs([t('Dashboards')]);
break;
case path.startsWith(Paths.Chart) || path.startsWith(Paths.Explore):
setActiveTabs(['Charts']);
setActiveTabs([t('Charts')]);
break;
case path.startsWith(Paths.Datasets):
setActiveTabs([datasetsLabel()]);
@@ -263,9 +263,10 @@ export function Menu({
const childItems: MenuItem[] = [];
childs?.forEach((child: MenuObjectChildProps | string, index1: number) => {
if (typeof child === 'string' && child === '-' && label !== 'Data') {
if (typeof child === 'string' && child === '-' && label !== t('Data')) {
childItems.push({ type: 'divider', key: `divider-${index1}` });
} else if (typeof child !== 'string') {
Object.assign(child, { label: t(child.label) });
childItems.push({
key: `${child.label}`,
label: child.isFrontendRoute ? (
@@ -366,6 +367,7 @@ export function Menu({
items={menu.map(item => {
const props = {
...item,
label: t(item.label),
isFrontendRoute: isFrontendRoute(item.url),
childs: item.childs?.map(c => {
if (typeof c === 'string') {
@@ -429,15 +431,16 @@ export default function MenuWrapper({ data, ...rest }: MenuProps) {
// Apply any label override for this item (keyed by FAB internal name).
...(item.name && labelOverrides[item.name]
? { label: labelOverrides[item.name]() }
: {}),
: { label: t(item.label) }),
};
// Filter childs
if (item.childs) {
item.childs.forEach((child: MenuObjectChildProps | string) => {
if (typeof child === 'string') {
children.push(child);
children.push(t(child));
} else if ((child as MenuObjectChildProps).label) {
Object.assign(child, { label: t(child.label) });
children.push(child);
}
});

View File

@@ -110,6 +110,34 @@ describe('getBootstrapData and helpers', () => {
expect(staticAssetsPrefix()).toEqual(expectedStaticPrefix);
});
test.each([
['//evil.example.com', 'protocol-relative URL'],
// eslint-disable-next-line no-script-url -- intentional unsafe value under test
['javascript:alert(1)', 'javascript scheme'],
['https://evil.example.com', 'absolute URL'],
['/foo"><img src=x>', 'path with HTML meta-characters'],
])(
'should fall back to the default root when application_root is %s (%s)',
async unsafeRoot => {
const customData = {
common: {
application_root: unsafeRoot,
static_assets_prefix: '/custom-static/',
},
};
document.body.innerHTML = `<div id="app" data-bootstrap='${JSON.stringify(customData)}'></div>`;
jest.resetModules();
const { default: getBootstrapData, applicationRoot } =
await import('./getBootstrapData');
getBootstrapData();
const expectedAppRoot =
DEFAULT_BOOTSTRAP_DATA.common.application_root.replace(/\/$/, '');
expect(applicationRoot()).toEqual(expectedAppRoot);
},
);
test('should defaults without trailing slashes when #app element does not include application_root or static_assets_prefix', async () => {
// Set up the fake #app element
const customData = {

View File

@@ -38,7 +38,34 @@ const normalizePathWithFallback = (
fallback: string,
): string => (path ?? fallback).replace(/\/$/, '');
const APPLICATION_ROOT_NO_TRAILING_SLASH = normalizePathWithFallback(
/**
* Matches a plain absolute path prefix (e.g. "" for root deployments or
* "/analytics" for a subdirectory). The character after the leading slash must
* not be another slash, so protocol-relative URLs ("//host") and scheme-bearing
* values ("javascript:...") do not qualify.
*/
const SAFE_APPLICATION_ROOT_RE = /^(\/[\w\-.][\w\-./]*)?$/;
/**
* The application root (SUPERSET_APP_ROOT) is reflected into links and
* navigation, so constrain it to a plain absolute path before use. Anything
* that isn't a simple "/path" prefix falls back to the default root so a
* malformed value can't be reinterpreted as HTML or redirect off-origin. This
* also keeps the bootstrap-derived value from being treated as a tainted href
* source by static analysis.
*/
const sanitizeApplicationRoot = (
path: string | undefined,
fallback: string,
): string => {
const normalizedFallback = normalizePathWithFallback(fallback, fallback);
const normalized = normalizePathWithFallback(path, fallback);
return SAFE_APPLICATION_ROOT_RE.test(normalized)
? normalized
: normalizedFallback;
};
const APPLICATION_ROOT_NO_TRAILING_SLASH = sanitizeApplicationRoot(
getBootstrapData().common.application_root,
DEFAULT_BOOTSTRAP_DATA.common.application_root,
);

View File

@@ -38,7 +38,9 @@ import { Logger, LOG_ACTIONS_SPA_NAVIGATION } from 'src/logger/LogUtils';
import setupCodeOverrides from 'src/setup/setupCodeOverrides';
import { logEvent } from 'src/logger/actions';
import { store } from 'src/views/store';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import ExtensionsStartup from 'src/extensions/ExtensionsStartup';
import ChatMount from 'src/components/ChatMount';
import { RootContextProviders } from './RootContextProviders';
import { ScrollToTop } from './ScrollToTop';
@@ -112,6 +114,13 @@ const App = () => (
</Route>
))}
</Switch>
{/*
The singleton chat slot. Rendered as a sibling of the route
Switch — inside ExtensionsStartup so chat extensions have been
loaded and registered, but outside the Switch so the chat persists
across route changes.
*/}
{isFeatureEnabled(FeatureFlag.EnableExtensions) && <ChatMount />}
</ExtensionsStartup>
<ToastContainer />
</RootContextProviders>

View File

@@ -39,14 +39,14 @@ logger = logging.getLogger(__name__)
@click.command()
@with_appcontext
@transaction()
@click.option("--database_name", "-d", help="Database name to change")
@click.option("--uri", "-u", help="Database URI to change")
@click.option("--database_name", "-d", required=True, help="Database name to change")
@click.option("--uri", "-u", required=True, help="Database URI to change")
@click.option(
"--skip_create",
"-s",
is_flag=True,
default=False,
help="Create the DB if it doesn't exist",
help="Don't create the DB if it doesn't exist",
)
def set_database_uri(database_name: str, uri: str, skip_create: bool) -> None:
"""Updates a database connection URI"""

View File

@@ -78,7 +78,7 @@ class ExportChartsCommand(ExportModelsCommand):
if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
tags = getattr(model, "tags", [])
payload["tags"] = [tag.name for tag in tags if tag.type == TagType.custom]
file_content = yaml.safe_dump(payload, sort_keys=False)
file_content = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
return file_content
_include_tags: bool = True # Default to True

View File

@@ -202,7 +202,7 @@ class ExportDashboardsCommand(ExportModelsCommand):
tags = model.tags if hasattr(model, "tags") else []
payload["tags"] = [tag.name for tag in tags if tag.type == TagType.custom]
file_content = yaml.safe_dump(payload, sort_keys=False)
file_content = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
return file_content
@staticmethod

View File

@@ -108,7 +108,7 @@ class ExportDatabasesCommand(ExportModelsCommand):
payload["version"] = EXPORT_VERSION
file_content = yaml.safe_dump(payload, sort_keys=False)
file_content = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
return file_content
@staticmethod
@@ -140,6 +140,9 @@ class ExportDatabasesCommand(ExportModelsCommand):
yield (
file_path,
functools.partial( # type: ignore
yaml.safe_dump, payload, sort_keys=False
yaml.safe_dump,
payload,
sort_keys=False,
allow_unicode=True,
),
)

View File

@@ -84,7 +84,7 @@ class ExportDatasetsCommand(ExportModelsCommand):
# serialize. Convert all keys to regular strings to fix YAML serialization.
payload = {str(key): value for key, value in payload.items()}
file_content = yaml.safe_dump(payload, sort_keys=False)
file_content = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
return file_content
@staticmethod
@@ -128,4 +128,7 @@ class ExportDatasetsCommand(ExportModelsCommand):
payload["version"] = EXPORT_VERSION
yield file_path, lambda: yaml.safe_dump(payload, sort_keys=False)
yield (
file_path,
lambda: yaml.safe_dump(payload, sort_keys=False, allow_unicode=True),
)

View File

@@ -36,10 +36,10 @@ class OracleEngineSpec(BaseEngineSpec):
DatabaseCategory.PROPRIETARY,
],
"pypi_packages": ["oracledb"],
"connection_string": "oracle://{username}:{password}@{hostname}:{port}",
"connection_string": "oracle+oracledb://{username}:{password}@{hostname}:{port}",
"default_port": 1521,
"notes": "Previously used cx_Oracle, now uses oracledb.",
"docs_url": "https://cx-oracle.readthedocs.io/en/latest/user_guide/installation.html",
"docs_url": "https://python-oracledb.readthedocs.io/en/latest/user_guide/installation.html",
}
force_column_alias_quotes = True
max_column_name_length = 128

View File

@@ -238,6 +238,7 @@ def build_extension_data(extension: LoadedExtension) -> dict[str, Any]:
manifest = extension.manifest
extension_data: dict[str, Any] = {
"id": manifest.id,
"publisher": manifest.publisher,
"name": extension.name,
"version": extension.version,
"description": manifest.description or "",

View File

@@ -592,6 +592,84 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
This class is used for all engines with dialects that can be parsed using sqlglot.
"""
# Function names that mutate server-side state but appear in the AST as
# plain function calls inside a non-mutating wrapper. Used by
# ``is_mutating()`` to classify e.g. PostgreSQL large-object writers.
# Names are uppercased for comparison.
_MUTATING_FUNCTION_NAMES: frozenset[str] = frozenset(
{
"LO_FROM_BYTEA",
"LO_EXPORT",
"LO_IMPORT",
"LO_PUT",
"LO_CREATE",
"LOWRITE",
"LO_UNLINK",
# PostgreSQL sequence mutators. `SELECT setval('seq', N)` and
# `SELECT nextval('seq')` look like reads but change sequence state
# for every subsequent caller. (`currval` only reads the session's
# last value, so it is intentionally not listed.)
"SETVAL",
"NEXTVAL",
}
)
# PostgreSQL constructs that sqlglot represents as an opaque ``exp.Command``
# (no structured AST). Each can mutate server state or wrap a DML body that
# would otherwise be detected by node-type matching. Used by
# ``is_mutating()``.
_POSTGRES_MUTATING_COMMAND_NAMES: frozenset[str] = frozenset(
{
"DO", # PL/pgSQL anonymous block
"PREPARE", # PREPARE u AS UPDATE ... ; EXECUTE u
"EXECUTE", # body is the prepared DML
"CALL", # procedure body may mutate
"COPY", # server-side file ingest into a table
"GRANT",
"REVOKE",
# Only the command-fallback forms (e.g. SET ROLE / SET SESSION
# AUTHORIZATION, which change the effective user) reach here as an
# exp.Command. Structured `SET search_path = ...` /
# `SET statement_timeout = ...` parse as exp.Set and are NOT matched
# by this command-name path.
"SET",
"RESET", # RESET ROLE / RESET ALL reverts SET; same class as SET
"REFRESH", # REFRESH MATERIALIZED VIEW
"REINDEX",
"VACUUM",
# DDL head-tokens that sqlglot falls back to exp.Command for
# whenever the body uses syntax it does not model
# (CREATE EXTENSION/FUNCTION...LANGUAGE C/PUBLICATION/etc.,
# ALTER ROLE/SYSTEM/..., DROP EXTENSION/RULE/...). Well-formed
# CREATE TABLE/ALTER TABLE/DROP TABLE are already caught by the
# node-type tuple; these entries close the fallback path.
"CREATE",
"ALTER",
"DROP",
"LOAD", # LOAD '/path/lib.so' dlopens a shared library on the PG host
# NOTE: `SHOW` is intentionally NOT included. It is a read (mutates
# nothing), so classifying it as mutating would be wrong for every
# is_mutating()/has_mutation() consumer (the commit decision, the
# "only SELECT allowed" validators, limit handling), not just the
# read-only gate. Gating information-disclosure reads such as
# `SHOW server_version` belongs in a denylist (DISALLOWED_SQL_FUNCTIONS
# already blocks version()/pg_read_file), not in the mutation check.
}
)
# Dialects where `SELECT ... INTO target` is CTAS (creates a table, and so
# mutates schema). Elsewhere the same syntax assigns into a variable and is
# a read: Oracle PL/SQL `SELECT ... INTO v` and MySQL `SELECT ... INTO @v`
# parse into an identical `exp.Select` with an `into` arg, so the dialect is
# the only signal that distinguishes the mutating form from the read form.
_SELECT_INTO_CTAS_DIALECTS: frozenset[Dialects] = frozenset(
{
Dialects.POSTGRES,
Dialects.REDSHIFT,
Dialects.TSQL,
}
)
def __init__(
self,
statement: str | None = None,
@@ -725,25 +803,67 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
exp.Drop,
exp.TruncateTable,
exp.Alter,
# sqlglot has structured nodes for these DML/DCL forms in
# PostgreSQL and other dialects; without them an opaque exp.Command
# check would still miss the structured-parse path.
exp.Copy, # COPY <table> FROM/TO (server-side file ingest)
exp.Grant,
exp.Revoke,
# COMMENT ON TABLE/COLUMN/etc. writes to system catalog pg_description.
exp.Comment,
)
for node_type in mutating_nodes:
if self._parsed.find(node_type):
return True
if self._parsed.find(*mutating_nodes):
return True
# `SELECT ... INTO new_table FROM ...` parses as `exp.Select` with an
# `into` arg (Postgres-style CTAS variant). It creates a new table and
# therefore mutates schema. Only treat it as mutating for dialects where
# the syntax is CTAS; elsewhere it assigns into a variable (a read).
if (
self._dialect in self._SELECT_INTO_CTAS_DIALECTS
and isinstance(self._parsed, exp.Select)
and self._parsed.args.get("into")
):
return True
# Function calls that mutate server-side state without an enclosing
# mutating AST node. Notable example: PostgreSQL large-object writers
# (`lo_export` writes to the server filesystem, `lo_from_bytea`/
# `lo_create`/`lo_put`/`lo_import`/`lowrite` mutate the pg_largeobject
# catalog). These appear as plain function calls inside an `exp.Select`
# and would otherwise pass the read-only gate. Every name in
# _MUTATING_FUNCTION_NAMES is PostgreSQL-specific, so the walk is gated
# on the dialect: other engines may expose read-only functions/UDFs with
# the same names, and flagging those would wrongly block read-only
# queries. Each parses as an `exp.Anonymous`, whose `.name` is the bare
# function identifier. The walk is restricted to `exp.Anonymous` rather
# than the broader `exp.Func`, because for built-in function nodes (e.g.
# `exp.Upper`) `.name` returns the first argument's text, not the
# function name, so `SELECT upper('lo_export')` would otherwise be
# misclassified as mutating.
if self._dialect == Dialects.POSTGRES and any(
function.name.upper() in self._MUTATING_FUNCTION_NAMES
for function in self._parsed.find_all(exp.Anonymous)
):
return True
# depending on the dialect (Oracle, MS SQL) the `ALTER` is parsed as a
# command, not an expression - check at root level
if isinstance(self._parsed, exp.Command) and self._parsed.name == "ALTER":
return True # pragma: no cover
# PostgreSQL constructs that sqlglot represents as an opaque
# `exp.Command` rather than a structured AST. Each of these can mutate
# state or wrap a DML body that would otherwise be detected. The
# `.name` attribute on `exp.Command` preserves the source-case of the
# head keyword (so `create extension ...` would yield `'create'`),
# which means the set lookup must be case-insensitive.
if (
self._dialect == Dialects.POSTGRES
and isinstance(self._parsed, exp.Command)
and self._parsed.name == "DO"
and self._parsed.name.upper() in self._POSTGRES_MUTATING_COMMAND_NAMES
):
# anonymous blocks can be written in many different languages (the default
# is PL/pgSQL), so parsing them it out of scope of this class; we just
# assume the anonymous block is mutating
return True
# Postgres runs DMLs prefixed by `EXPLAIN ANALYZE`, see
@@ -864,6 +984,13 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
else:
present.add(function.name.upper())
# MySQL `@@<name>` syntax (also Oracle/SQL-Server `@@name`) parses as
# `exp.SessionParameter`, which is *not* a subclass of `exp.Func`, so
# the walk above misses it. Include those names so denylist entries
# like `version` or `hostname` match `SELECT @@version`.
for param in self._parsed.find_all(exp.SessionParameter):
present.add(param.name.upper())
return any(function.upper() in present for function in functions)
def check_tables_present(self, tables: set[str]) -> bool:

File diff suppressed because it is too large Load Diff

View File

@@ -178,6 +178,33 @@ class TestExportChartsCommand(SupersetTestCase):
]
assert expected == list(contents.keys())
@patch("superset.security.manager.g")
@pytest.mark.usefixtures("load_energy_table_with_slice")
def test_export_chart_command_unicode_chars(self, mock_g):
"""Test that unicode characters in a chart name are exported to the YAML"""
mock_g.user = security_manager.find_user("admin")
db.session.query(Slice).filter_by(slice_name="Energy Sankey").update(
{"slice_name": "中文"},
)
try:
example_chart = db.session.query(Slice).filter_by(slice_name="中文").one()
command = ExportChartsCommand([example_chart.id])
contents = dict(command.run())
path = f"charts/{example_chart.id}.yaml"
assert path in set(contents.keys())
yaml_content = contents[path]()
metadata = yaml.safe_load(yaml_content)
assert metadata["slice_name"] == "中文"
assert "slice_name: 中文" in yaml_content
finally:
# restore the original name so fixture teardown works even if an
# assertion above fails
db.session.query(Slice).filter_by(slice_name="中文").update(
{"slice_name": "Energy Sankey"},
)
class TestImportChartsCommand(SupersetTestCase):
@patch("superset.utils.core.g")

View File

@@ -507,6 +507,36 @@ class TestExportDashboardsCommand(SupersetTestCase):
}
assert expected_paths == set(contents.keys())
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
@patch("superset.security.manager.g")
@patch("superset.views.base.g")
def test_export_dashboard_command_unicode_chars(self, mock_g1, mock_g2):
mock_g1.user = security_manager.find_user("admin")
mock_g2.user = security_manager.find_user("admin")
db.session.query(Dashboard).filter_by(slug="world_health").update(
{"dashboard_title": "中文"},
)
try:
example_dashboard = (
db.session.query(Dashboard).filter_by(dashboard_title="中文").one()
)
command = ExportDashboardsCommand([example_dashboard.id])
contents = dict(command.run())
path = f"dashboards/{example_dashboard.id}.yaml"
assert path in set(contents.keys())
yaml_content = contents[path]()
metadata = yaml.safe_load(yaml_content)
assert metadata["dashboard_title"] == "中文"
assert "dashboard_title: 中文" in yaml_content
finally:
# restore the shared fixture title so later tests that rely on it
# (e.g. test_export_dashboard_command_no_related) are not affected
db.session.query(Dashboard).filter_by(slug="world_health").update(
{"dashboard_title": "World Bank's Data"},
)
class TestImportDashboardsCommand(SupersetTestCase):
def test_import_v0_dashboard_cli_export(self):

View File

@@ -395,6 +395,30 @@ class TestExportDatabasesCommand(SupersetTestCase):
assert "databases" in prefixes
assert "datasets" not in prefixes
@patch("superset.security.manager.g")
def test_export_database_command_unicode_chars(self, mock_g):
mock_g.user = security_manager.find_user("admin")
db.session.query(Database).filter_by(database_name="中文").delete()
db.session.commit()
command = CreateDatabaseCommand(
{"database_name": "中文", "sqlalchemy_uri": "sqlite:///:memory:"},
)
example_db = command.run()
try:
command = ExportDatabasesCommand([example_db.id], export_related=False)
contents = dict(command.run())
path = f"databases/{example_db.id}.yaml"
assert path in set(contents.keys())
yaml_content = contents[path]()
assert "database_name: 中文" in yaml_content
finally:
# CreateDatabaseCommand commits the new database, so the cleanup must
# also be committed and must run even if an assertion above fails
db.session.query(Database).filter_by(database_name="中文").delete()
db.session.commit()
class TestImportDatabasesCommand(SupersetTestCase):
@patch("superset.security.manager.g")

View File

@@ -273,6 +273,42 @@ class TestExportDatasetsCommand(SupersetTestCase):
f"datasets/examples/energy_usage_{example_dataset.id}.yaml",
]
@patch("superset.security.manager.g")
def test_export_dataset_command_unicode_chars(self, mock_g) -> None:
mock_g.user = security_manager.find_user("admin")
examples_db = get_example_database()
with examples_db.get_sqla_engine() as engine:
engine.execute("DROP TABLE IF EXISTS 中文")
engine.execute("CREATE TABLE 中文 AS SELECT 2 as col")
# scope cleanup to the example database so datasets with the same name
# on other databases are left untouched
stale = db.session.query(SqlaTable).filter_by(
table_name="中文", database_id=examples_db.id
)
if stale.count():
stale.delete()
with override_user(security_manager.find_user("admin")):
example_dataset = CreateDatasetCommand(
{
"table_name": "中文",
"database": examples_db.id,
}
).run()
command = ExportDatasetsCommand([example_dataset.id], export_related=False)
contents = dict(command.run())
path = f"datasets/examples/{example_dataset.id}.yaml"
assert path in set(contents.keys())
yaml_content = contents[path]()
assert "table_name: 中文" in yaml_content
db.session.delete(example_dataset)
db.session.commit()
with examples_db.get_sqla_engine() as engine:
engine.execute("DROP TABLE 中文")
db.session.commit()
class TestImportDatasetsCommand(SupersetTestCase):
@pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
@@ -602,6 +638,7 @@ class TestCreateDatasetCommand(SupersetTestCase):
assert [owner.username for owner in table.owners] == ["admin"]
db.session.delete(table)
db.session.commit()
with examples_db.get_sqla_engine() as engine:
engine.execute("DROP TABLE test_create_dataset_command")
db.session.commit()

View File

@@ -1143,7 +1143,14 @@ def test_split_kql(kql: str, expected: list[str]) -> None:
("postgresql", "DROP TABLE foo", True),
("postgresql", "EXPLAIN ANALYZE SELECT * FROM foo", False),
("postgresql", "EXPLAIN ANALYZE DELETE FROM foo", True),
# SHOW reads server configuration; it mutates nothing, so it is NOT
# classified as mutating (that would be wrong for the commit/limit/
# "only SELECT" consumers of has_mutation()). Gating disclosure reads
# belongs in DISALLOWED_SQL_FUNCTIONS, not the mutation check.
("postgresql", "SHOW search_path", False),
# SET search_path parses as exp.Set (a structured node), not
# exp.Command, so the SET-in-mutating-commands rule does NOT
# catch it. Pure GUC reads/writes stay non-mutating.
("postgresql", "SET search_path TO public", False),
(
"postgres",
@@ -1388,6 +1395,9 @@ def test_custom_dialect(app: None) -> None:
("SELECT 1", False),
("with source as ( select 1 as one ) select * from source", False),
("ALTER TABLE foo ADD COLUMN bar INT", True),
# COMMENT ON parses as a typed exp.Comment node across dialects; it
# writes to the catalog (pg_description on Postgres) so it is gated.
("COMMENT ON TABLE t IS 'note'", True),
],
)
def test_is_mutating(sql: str, engine: str, expected: bool) -> None:
@@ -1434,6 +1444,222 @@ def test_is_mutating_anonymous_block(sql: str, expected: bool) -> None:
assert SQLStatement(sql, "postgresql").is_mutating() == expected
@pytest.mark.parametrize(
"sql, expected",
[
# PostgreSQL large-object writers: each mutates server state. The bare
# SELECT wrapper is irrelevant because the function call itself is the
# side effect.
("SELECT lo_from_bytea(0, decode('deadbeef', 'hex'))", True),
("SELECT lo_export(12345, '/tmp/payload.bin')", True),
("SELECT lo_import('/etc/passwd')", True),
("SELECT lo_put(12345, 0, decode('00', 'hex'))", True),
("SELECT lo_create(0)", True),
("SELECT lowrite(12345, decode('00', 'hex'))", True),
# lo_unlink deletes a large object outright.
("SELECT lo_unlink(12345)", True),
# PostgreSQL sequence mutators. setval()/nextval() look like reads but
# advance sequence state for every subsequent caller.
("SELECT setval('public.my_seq', 1000)", True),
("SELECT SETVAL('public.my_seq', 1)", True),
("SELECT nextval('public.my_seq')", True),
# currval() only reads the session's last value, so it is not mutating.
("SELECT currval('public.my_seq')", False),
# Read-side large-object functions are intentionally NOT classified
# as mutating here. They are still blocked via the function denylist
# (see DISALLOWED_SQL_FUNCTIONS) but they do not write state.
("SELECT lo_get(12345)", False),
("SELECT loread(12345, 1024)", False),
# Case-insensitive matching: the AST stores the raw casing for
# anonymous functions, the check uppercases both sides.
("SELECT LO_EXPORT(12345, '/tmp/x')", True),
# `SELECT INTO new_table FROM existing` creates a new relation; treat
# as mutating even though sqlglot parses it as exp.Select.
("SELECT * INTO new_table FROM existing_table", True),
("SELECT col INTO TEMP new_table FROM existing_table", True),
# A built-in function whose first string argument happens to match a
# mutating name must NOT be flagged. sqlglot parses these into dedicated
# nodes (e.g. exp.Upper) whose `.name` is the argument text, not the
# function name, so the walk is restricted to exp.Anonymous to avoid a
# false positive on this read-only query.
("SELECT upper('lo_export')", False),
("SELECT length('setval')", False),
# Plain SELECT must remain non-mutating.
("SELECT 1", False),
("SELECT * FROM users WHERE id = 1", False),
],
)
def test_is_mutating_postgres_function_and_select_into(
sql: str, expected: bool
) -> None:
"""
`is_mutating` must catch mutating function calls (PostgreSQL large-object
writers) and `SELECT ... INTO new_table` even though the wrapping AST
node is a plain `exp.Select`.
"""
assert SQLStatement(sql, "postgresql").is_mutating() == expected
@pytest.mark.parametrize(
"engine, sql",
[
# `SELECT ... INTO new_table` is CTAS only in Postgres/Redshift/T-SQL.
# In Oracle PL/SQL and MySQL the same syntax assigns into a variable
# and is a read, so it must NOT be classified as mutating.
("oracle", "SELECT col INTO v FROM existing_table"),
("mysql", "SELECT col INTO @v FROM existing_table"),
],
)
def test_is_mutating_select_into_variable_is_read(engine: str, sql: str) -> None:
"""
`SELECT ... INTO target` is only CTAS (mutating) for dialects where the
syntax creates a table. On Oracle/MySQL it assigns into a variable and is
a read, so `is_mutating` must return False there.
"""
assert SQLStatement(sql, engine).is_mutating() is False
@pytest.mark.parametrize(
"engine, sql",
[
# `SELECT ... INTO new_table` is CTAS on Redshift and T-SQL just as it
# is on Postgres, so each dialect in _SELECT_INTO_CTAS_DIALECTS must
# classify the statement as mutating.
("redshift", "SELECT * INTO new_table FROM existing_table"),
("redshift", "SELECT col INTO new_table FROM existing_table"),
("mssql", "SELECT * INTO new_table FROM existing_table"),
("mssql", "SELECT col INTO new_table FROM existing_table"),
],
)
def test_is_mutating_select_into_ctas_dialects(engine: str, sql: str) -> None:
"""
`SELECT ... INTO new_table` creates a table on the CTAS dialects beyond
Postgres (Redshift, T-SQL), so `is_mutating` must return True there.
"""
assert SQLStatement(sql, engine).is_mutating() is True
@pytest.mark.parametrize(
"engine, sql",
[
# The mutating-function names are PostgreSQL built-ins. On other engines
# a same-named read-only function or UDF must NOT be flagged as
# mutating, otherwise read-only queries get wrongly blocked.
("mysql", "SELECT setval(my_col)"),
("mysql", "SELECT lo_export(id, path) FROM t"),
("base", "SELECT setval(my_col)"),
("trino", "SELECT lowrite(x)"),
],
)
def test_is_mutating_function_names_scoped_to_postgres(engine: str, sql: str) -> None:
"""
`_MUTATING_FUNCTION_NAMES` is PostgreSQL-specific, so the function-name walk
only runs for the Postgres dialect; same-named functions on other engines
must stay non-mutating.
"""
assert SQLStatement(sql, engine).is_mutating() is False
@pytest.mark.parametrize(
"sql, expected",
[
# PostgreSQL constructs that sqlglot parses as opaque exp.Command.
# Each can wrap a DML body or change effective server state.
("PREPARE u AS UPDATE t SET x = 1", True),
("PREPARE i AS INSERT INTO t VALUES (1)", True),
("EXECUTE my_plan", True),
("CALL my_writing_procedure()", True),
("COPY t FROM '/tmp/data.csv'", True),
("GRANT SELECT ON t TO public", True),
("REVOKE SELECT ON t FROM public", True),
("SET ROLE other_role", True),
("REFRESH MATERIALIZED VIEW mv", True),
("REINDEX TABLE t", True),
("VACUUM t", True),
# SHOW commands are reads (they mutate nothing), so they are NOT
# classified as mutating. Gating information-disclosure reads such as
# SHOW server_version belongs in DISALLOWED_SQL_FUNCTIONS (which already
# blocks pg_read_file, version(), etc.), not in the mutation check.
("SHOW search_path", False),
("SHOW all", False),
("SHOW server_version", False),
# RESET reverts a prior SET (e.g. RESET ROLE backs out SET ROLE).
("RESET ROLE", True),
# DDL head-tokens that sqlglot falls back to exp.Command for when the
# body uses syntax it does not model. One representative per
# head-token branch (CREATE/ALTER/DROP); they all hit the same
# set-lookup so additional CREATE PUBLICATION/SUBSCRIPTION/etc.
# cases would not add coverage.
(
"CREATE FUNCTION x() RETURNS int AS '/tmp/x.so', 'i' LANGUAGE C",
True,
),
("CREATE EXTENSION pg_trgm", True), # non-FUNCTION DDL via Command
("ALTER SYSTEM SET wal_level = 'logical'", True),
("DROP EXTENSION pg_trgm", True),
# LOAD dlopens a shared library on the PG host. Same RCE primitive
# as `CREATE FUNCTION ... LANGUAGE C` if the library path is
# attacker-controlled (e.g. via a prior COPY-to-program foothold).
("LOAD '/tmp/x.so'", True),
# Case-insensitive: sqlglot preserves source case on Command.name,
# so the set lookup must normalise. Regression for the original
# bug where a lowercase head-token bypassed the gate.
("create extension pg_trgm", True),
("load '/tmp/x.so'", True),
# Pre-existing positive controls
("DO $$ BEGIN UPDATE t SET x = 1; END $$", True),
("EXPLAIN ANALYZE UPDATE t SET x = 1", True),
],
)
def test_is_mutating_postgres_command_constructs(sql: str, expected: bool) -> None:
"""
Several PostgreSQL constructs are represented by sqlglot as opaque
`exp.Command` nodes (no structured AST). `is_mutating` recognises them
by command name so they cannot slip past the read-only gate.
"""
assert SQLStatement(sql, "postgresql").is_mutating() == expected
@pytest.mark.parametrize(
"sql, engine, functions, expected",
[
# MySQL `@@<name>` syntax parses as exp.SessionParameter, which is
# not a subclass of exp.Func. The walker must include it so the
# denylist entry for `version` still catches `SELECT @@version`.
("SELECT @@version", "mysql", {"version"}, True),
("SELECT @@global.version", "mysql", {"version"}, True),
("SELECT @@hostname", "mysql", {"hostname"}, True),
("SELECT @@datadir", "mysql", {"datadir"}, True),
# Negative control: a session parameter not in the denylist must
# not match.
("SELECT @@autocommit", "mysql", {"version", "hostname"}, False),
# A plain SELECT does not introduce session-parameter names.
("SELECT 1", "mysql", {"version"}, False),
# The pre-existing exp.Func walk still works for normal calls.
("SELECT version()", "mysql", {"version"}, True),
# PostgreSQL large-object functions are exp.Anonymous calls. The
# walk includes them; the denylist entry catches them.
("SELECT lo_export(12345, '/tmp/x')", "postgresql", {"lo_export"}, True),
(
"SELECT lo_from_bytea(0, decode('00','hex'))",
"postgresql",
{"lo_from_bytea"},
True,
),
("SELECT loread(12345, 1024)", "postgresql", {"loread"}, True),
],
)
def test_check_functions_present_session_parameter(
sql: str, engine: str, functions: set[str], expected: bool
) -> None:
"""
`check_functions_present` must visit `exp.SessionParameter` so that
denylist entries for names like `version` or `hostname` also match
`SELECT @@version` / `SELECT @@hostname` in MySQL.
"""
assert SQLScript(sql, engine).check_functions_present(functions) == expected
@pytest.mark.parametrize(
"sql, expected",
[