Compare commits

..

4 Commits

Author SHA1 Message Date
Joe Li
c964696723 fix(sqllab): frame Template Parameters editor at the wrapper level
Apply the border and radius to the EditorOutline wrapper instead of the
`.ace_editor` descendant so the frame renders regardless of which editor
provider EditorHost resolves to (AceEditorProvider does not forward
className to the rendered editor). Keeps the outline off `overflow:
hidden`, so Ace tooltips and autocomplete popovers are not clipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:43:16 -07:00
Joe Li
1f8e8b4cc2 test(sqllab): pin Template Parameters editor height to 360px
The Template Parameters editor previously rendered at 800px, which made
the popover overflow. Add a regression test asserting the editor receives
height="360px" so a regression back to the larger height is caught.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:52:05 -07:00
sadpandajoe
3d24afdabf fix(sqllab): move outline to inner .ace_editor to avoid clipping popovers
Address Copilot/Bito review: the previous EditorOutline wrapper used
overflow: hidden to clip the border-radius, which would also clip
Ace's autocomplete dropdown and gutter/annotation tooltips that Ace
mounts as descendants of .ace_editor. Apply the border + border-radius
directly to the .ace_editor element instead so the outline still
renders and popovers are no longer clipped at the wrapper boundary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 00:05:05 +00:00
sadpandajoe
c7a09acd01 fix(sqllab): shrink Template Parameters editor height and add outline
Reduce editor height from 800px to 360px so the popover fits on
laptop viewports without overflowing. Replace the broken
StyledEditorHost styled wrapper (whose &.ace_editor selector never
matched because the class lives on a deeper DOM node) with an
EditorOutline div that applies border + border-radius + overflow:hidden
directly, ensuring the outline is always visible in both light and
dark themes via theme.colorBorder.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 18:27:46 +00:00
52 changed files with 5260 additions and 5962 deletions

View File

@@ -7235,10 +7235,10 @@
"pypi_packages": [
"oracledb"
],
"connection_string": "oracle+oracledb://{username}:{password}@{hostname}:{port}",
"connection_string": "oracle://{username}:{password}@{hostname}:{port}",
"default_port": 1521,
"notes": "Previously used cx_Oracle, now uses oracledb.",
"docs_url": "https://python-oracledb.readthedocs.io/en/latest/user_guide/installation.html",
"docs_url": "https://cx-oracle.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.1 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
version: 0.16.0 # 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.1](https://img.shields.io/badge/Version-0.16.1-informational?style=flat-square)
![Version: 0.16.0](https://img.shields.io/badge/Version-0.16.0-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application

View File

@@ -62,9 +62,6 @@ 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>=4.1.0, <5.0",
"flask-migrate>=3.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 = ["oracledb>=2.0.0, <5"]
oracle = ["cx-Oracle>8.0.0, <8.4"]
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==4.1.0
flask-migrate==3.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==4.1.0
flask-migrate==3.1.0
# via
# -c requirements/base-constraint.txt
# apache-superset

View File

@@ -8450,6 +8450,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8467,6 +8470,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8484,6 +8490,9 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8501,6 +8510,9 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8518,6 +8530,9 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8535,6 +8550,9 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8552,6 +8570,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8569,6 +8590,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -26108,21 +26132,6 @@
}
}
},
"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",
@@ -43235,21 +43244,6 @@
}
}
},
"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,14 +18,6 @@
"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

@@ -1,184 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @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,6 +223,8 @@ 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,10 +23,9 @@
* 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, chat) and re-exported here for the manifest schema.
* menus, editors) and re-exported here for the manifest schema.
*/
import { Chat } from '../chat';
import { Command } from '../commands';
import { View } from '../views';
import { Menu } from '../menus';
@@ -72,8 +71,7 @@ export interface MenuContributions {
}
/**
* Aggregates all contributions (commands, menus, views, editors, and chat)
* provided by an extension or module.
* Aggregates all contributions (commands, menus, views, and editors) provided by an extension or module.
*/
export interface Contributions {
/** List of commands. */
@@ -84,10 +82,4 @@ 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,12 +18,10 @@
*/
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

@@ -1,81 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @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,8 +39,10 @@ jest.mock('@superset-ui/core/components/Select/AsyncSelect', () => () => (
<div data-test="mock-async-select" />
));
jest.mock('src/core/editors', () => ({
EditorHost: ({ value }: { value: string }) => (
<div data-test="mock-async-ace-editor">{value}</div>
EditorHost: ({ value, height }: { value: string; height: string }) => (
<div data-test="mock-async-ace-editor" data-height={height}>
{value}
</div>
),
}));
@@ -79,6 +81,18 @@ 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,10 +30,9 @@ import {
import { EditorHost } from 'src/core/editors';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
const StyledEditorHost = styled(EditorHost)`
&.ace_editor {
border: 1px solid ${({ theme }) => theme.colorBorder};
}
const EditorOutline = styled.div`
border: 1px solid ${({ theme }) => theme.colorBorder};
border-radius: ${({ theme }) => theme.borderRadius}px;
`;
const StyledParagraph = styled.p`
@@ -87,14 +86,16 @@ const TemplateParamsEditor = ({
</a>{' '}
{t('syntax.')}
</StyledParagraph>
<StyledEditorHost
id={`template-params-${queryEditorId}`}
height="800px"
onChange={debounce(onChange, Constants.FAST_DEBOUNCE)}
language={language === 'yaml' ? 'yaml' : 'json'}
width="100%"
value={code}
/>
<EditorOutline>
<EditorHost
id={`template-params-${queryEditorId}`}
height="360px"
onChange={debounce(onChange, Constants.FAST_DEBOUNCE)}
language={language === 'yaml' ? 'yaml' : 'json'}
width="100%"
value={code}
/>
</EditorOutline>
</div>
);

View File

@@ -1,287 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { 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

@@ -1,149 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { 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

@@ -1,327 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { 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

@@ -1,240 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* @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,6 +254,33 @@ 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,7 +19,6 @@
import type { editors } from '@apache-superset/core';
import { Disposable } from '../models';
import { createEventEmitter } from '../utils';
type EditorLanguage = editors.EditorLanguage;
type EditorProvider = editors.EditorProvider;
@@ -28,8 +27,45 @@ 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.
@@ -47,9 +83,15 @@ class EditorProviders {
*/
private languageToProvider: Map<EditorLanguage, string> = new Map();
private registerEmitter = createEventEmitter<EditorRegisteredEvent>();
/**
* Event emitter for provider registration events.
*/
private registerEmitter = new EventEmitter<EditorRegisteredEvent>();
private unregisterEmitter = createEventEmitter<EditorUnregisteredEvent>();
/**
* Event emitter for provider unregistration events.
*/
private unregisterEmitter = new EventEmitter<EditorUnregisteredEvent>();
private syncListeners: Set<() => void> = new Set();

View File

@@ -27,13 +27,11 @@ 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,7 +27,6 @@
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;
@@ -48,19 +47,19 @@ const subscribe = (listener: () => void) => {
return () => syncListeners.delete(listener);
};
const registerEmitter = createEventEmitter<MenuItemRegisteredEvent>();
const unregisterEmitter = createEventEmitter<MenuItemUnregisteredEvent>();
const registerListeners = new Set<(e: MenuItemRegisteredEvent) => void>();
const unregisterListeners = new Set<(e: MenuItemUnregisteredEvent) => void>();
const menuCache = new Map<string, Menu | undefined>();
const notifyRegister = (event: MenuItemRegisteredEvent) => {
menuCache.clear();
syncListeners.forEach(l => l());
registerEmitter.fire(event);
registerListeners.forEach(l => l(event));
};
const notifyUnregister = (event: MenuItemUnregisteredEvent) => {
menuCache.clear();
syncListeners.forEach(l => l());
unregisterEmitter.fire(event);
unregisterListeners.forEach(l => l(event));
};
const registerMenuItem: typeof menusApi.registerMenuItem = (
@@ -118,11 +117,16 @@ export const useMenu = (location: string): Menu | undefined =>
export const onDidRegisterMenuItem: typeof menusApi.onDidRegisterMenuItem = (
listener: (e: MenuItemRegisteredEvent) => void,
): Disposable => registerEmitter.subscribe(listener);
): Disposable => {
registerListeners.add(listener);
return new Disposable(() => registerListeners.delete(listener));
};
export const onDidUnregisterMenuItem: typeof menusApi.onDidUnregisterMenuItem =
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable =>
unregisterEmitter.subscribe(listener);
(listener: (e: MenuItemUnregisteredEvent) => void): Disposable => {
unregisterListeners.add(listener);
return new Disposable(() => unregisterListeners.delete(listener));
};
export const menus: typeof menusApi = {
registerMenuItem,

View File

@@ -1,124 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// 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

@@ -1,79 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* 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,57 +21,6 @@ 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,7 +29,6 @@ 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;
@@ -48,19 +47,19 @@ const subscribe = (listener: () => void) => {
return () => syncListeners.delete(listener);
};
const registerEmitter = createEventEmitter<ViewRegisteredEvent>();
const unregisterEmitter = createEventEmitter<ViewUnregisteredEvent>();
const registerListeners = new Set<(e: ViewRegisteredEvent) => void>();
const unregisterListeners = new Set<(e: ViewUnregisteredEvent) => void>();
const viewsCache = new Map<string, View[] | undefined>();
const notifyRegister = (event: ViewRegisteredEvent) => {
viewsCache.clear();
syncListeners.forEach(l => l());
registerEmitter.fire(event);
registerListeners.forEach(l => l(event));
};
const notifyUnregister = (event: ViewUnregisteredEvent) => {
viewsCache.clear();
syncListeners.forEach(l => l());
unregisterEmitter.fire(event);
unregisterListeners.forEach(l => l(event));
};
const registerView: typeof viewsApi.registerView = (
@@ -117,11 +116,17 @@ export const useViews = (location: string): View[] | undefined =>
export const onDidRegisterView: typeof viewsApi.onDidRegisterView = (
listener: (e: ViewRegisteredEvent) => void,
): Disposable => registerEmitter.subscribe(listener);
): Disposable => {
registerListeners.add(listener);
return new Disposable(() => registerListeners.delete(listener));
};
export const onDidUnregisterView: typeof viewsApi.onDidUnregisterView = (
listener: (e: ViewUnregisteredEvent) => void,
): Disposable => unregisterEmitter.subscribe(listener);
): Disposable => {
unregisterListeners.add(listener);
return new Disposable(() => unregisterListeners.delete(listener));
};
export const views: typeof viewsApi = {
registerView,

View File

@@ -45,7 +45,6 @@ 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,
@@ -146,7 +145,7 @@ const NotFoundContent = () => (
<span>
{t('Add an annotation layer')}{' '}
<a
href={ensureAppRoot('/annotationlayer/list')}
href="/annotationlayer/list"
target="_blank"
rel="noopener noreferrer"
>

View File

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

View File

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

View File

@@ -16,78 +16,58 @@
* specific language governing permissions and limitations
* under the License.
*/
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';
import { useEffect } from 'react';
// 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';
import 'src/extensions/Namespaces';
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;
};
}
}
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 (prevPathname.current !== location.pathname) {
prevPathname.current = location.pathname;
notifyPageChange(location.pathname);
}
}, [location.pathname]);
if (userId == null) return;
// 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.
// Provide the implementations for @apache-superset/core
window.superset = {
...supersetCore,
authentication,
chat,
core,
commands,
editors,
extensions,
menus,
navigation,
sqlLab,
views,
};

View File

@@ -1,60 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* 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 { ensureAppRoot } from 'src/utils/pathUtils';
import { Typography } from '@superset-ui/core/components/Typography';
import { ensureAppRoot } from 'src/utils/pathUtils';
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([t('Dashboards')]);
setActiveTabs(['Dashboards']);
break;
case path.startsWith(Paths.Chart) || path.startsWith(Paths.Explore):
setActiveTabs([t('Charts')]);
setActiveTabs(['Charts']);
break;
case path.startsWith(Paths.Datasets):
setActiveTabs([datasetsLabel()]);
@@ -263,10 +263,9 @@ export function Menu({
const childItems: MenuItem[] = [];
childs?.forEach((child: MenuObjectChildProps | string, index1: number) => {
if (typeof child === 'string' && child === '-' && label !== t('Data')) {
if (typeof child === 'string' && child === '-' && label !== '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 ? (
@@ -367,7 +366,6 @@ 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') {
@@ -431,16 +429,15 @@ 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(t(child));
children.push(child);
} else if ((child as MenuObjectChildProps).label) {
Object.assign(child, { label: t(child.label) });
children.push(child);
}
});

View File

@@ -110,34 +110,6 @@ 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,34 +38,7 @@ const normalizePathWithFallback = (
fallback: string,
): string => (path ?? fallback).replace(/\/$/, '');
/**
* 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(
const APPLICATION_ROOT_NO_TRAILING_SLASH = normalizePathWithFallback(
getBootstrapData().common.application_root,
DEFAULT_BOOTSTRAP_DATA.common.application_root,
);

View File

@@ -38,9 +38,7 @@ 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';
@@ -114,13 +112,6 @@ 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", required=True, help="Database name to change")
@click.option("--uri", "-u", required=True, help="Database URI to change")
@click.option("--database_name", "-d", help="Database name to change")
@click.option("--uri", "-u", help="Database URI to change")
@click.option(
"--skip_create",
"-s",
is_flag=True,
default=False,
help="Don't create the DB if it doesn't exist",
help="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, allow_unicode=True)
file_content = yaml.safe_dump(payload, sort_keys=False)
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, allow_unicode=True)
file_content = yaml.safe_dump(payload, sort_keys=False)
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, allow_unicode=True)
file_content = yaml.safe_dump(payload, sort_keys=False)
return file_content
@staticmethod
@@ -140,9 +140,6 @@ class ExportDatabasesCommand(ExportModelsCommand):
yield (
file_path,
functools.partial( # type: ignore
yaml.safe_dump,
payload,
sort_keys=False,
allow_unicode=True,
yaml.safe_dump, payload, sort_keys=False
),
)

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, allow_unicode=True)
file_content = yaml.safe_dump(payload, sort_keys=False)
return file_content
@staticmethod
@@ -128,7 +128,4 @@ class ExportDatasetsCommand(ExportModelsCommand):
payload["version"] = EXPORT_VERSION
yield (
file_path,
lambda: yaml.safe_dump(payload, sort_keys=False, allow_unicode=True),
)
yield file_path, lambda: yaml.safe_dump(payload, sort_keys=False)

View File

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

View File

@@ -238,7 +238,6 @@ 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,84 +592,6 @@ 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,
@@ -803,67 +725,25 @@ 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,
)
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
for node_type in mutating_nodes:
if self._parsed.find(node_type):
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.upper() in self._POSTGRES_MUTATING_COMMAND_NAMES
and self._parsed.name == "DO"
):
# 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
@@ -984,13 +864,6 @@ 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,33 +178,6 @@ 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,36 +507,6 @@ 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,30 +395,6 @@ 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,42 +273,6 @@ 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")
@@ -638,7 +602,6 @@ 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,14 +1143,7 @@ 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",
@@ -1395,9 +1388,6 @@ 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:
@@ -1444,222 +1434,6 @@ 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",
[