Compare commits

...

7 Commits

Author SHA1 Message Date
Beto Dealmeida
7ab6953f1a Small improvements 2025-09-10 10:51:59 -04:00
Beto Dealmeida
b55cb206a5 Update schema 2025-09-08 12:39:59 -04:00
Beto Dealmeida
4e52bc8a0b Update frontend 2025-09-03 15:57:01 -04:00
Beto Dealmeida
7f55d3ff30 feat: use default catalog/schema on DB selector 2025-09-03 14:36:31 -04:00
dependabot[bot]
355d7e1ee5 chore(deps-dev): bump eslint from 9.30.0 to 9.34.0 in /superset-websocket (#34936)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-03 10:21:54 -07:00
JUST.in DO IT
448a28545b fix(ui-core): Invalid postTransform process (#34874) 2025-09-03 10:17:19 -07:00
JUST.in DO IT
cefd046ea0 fix(sqllab): autocomplete and delete tabs (#34781) 2025-09-03 10:16:51 -07:00
21 changed files with 623 additions and 136 deletions

View File

@@ -0,0 +1,90 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, waitFor } from '@testing-library/react';
import {
ChartPlugin,
ChartMetadata,
DatasourceType,
getChartComponentRegistry,
} from '@superset-ui/core';
import SuperChartCore from './SuperChartCore';
const props = {
chartType: 'line',
};
const FakeChart = () => <span>test</span>;
beforeEach(() => {
const metadata = new ChartMetadata({
name: 'test-chart',
thumbnail: '',
});
const buildQuery = () => ({
datasource: { id: 1, type: DatasourceType.Table },
queries: [{ granularity: 'day' }],
force: false,
result_format: 'json',
result_type: 'full',
});
const controlPanel = { abc: 1 };
const plugin = new ChartPlugin({
metadata,
Chart: FakeChart,
buildQuery,
controlPanel,
});
plugin.configure({ key: props.chartType }).register();
});
test('should return the result from cache unless transformProps has changed', async () => {
const pre = jest.fn(x => x);
const transform = jest.fn(x => x);
const post = jest.fn(x => x);
expect(getChartComponentRegistry().get(props.chartType)).toBe(FakeChart);
expect(pre).toHaveBeenCalledTimes(0);
const { rerender } = render(
<SuperChartCore
{...props}
preTransformProps={pre}
overrideTransformProps={transform}
postTransformProps={post}
/>,
);
await waitFor(() => expect(pre).toHaveBeenCalledTimes(1));
expect(transform).toHaveBeenCalledTimes(1);
expect(post).toHaveBeenCalledTimes(1);
const updatedPost = jest.fn(x => x);
rerender(
<SuperChartCore
{...props}
preTransformProps={pre}
overrideTransformProps={transform}
postTransformProps={updatedPost}
/>,
);
await waitFor(() => expect(updatedPost).toHaveBeenCalledTimes(1));
expect(transform).toHaveBeenCalledTimes(1);
expect(pre).toHaveBeenCalledTimes(1);
});

View File

@@ -85,31 +85,74 @@ export default class SuperChartCore extends PureComponent<Props, {}> {
container?: HTMLElement | null;
/**
* memoized function so it will not recompute
* and return previous value
* memoized function so it will not recompute and return previous value
* unless one of
* - preTransformProps
* - transformProps
* - postTransformProps
* - chartProps
* is changed.
*/
processChartProps = createSelector(
preSelector = createSelector(
[
(input: {
chartProps: ChartProps;
preTransformProps?: PreTransformProps;
transformProps?: TransformProps;
postTransformProps?: PostTransformProps;
}) => input.chartProps,
input => input.preTransformProps,
],
(chartProps, pre = IDENTITY) => pre(chartProps),
);
/**
* memoized function so it will not recompute and return previous value
* unless one of the input arguments have changed.
*/
transformSelector = createSelector(
[
(input: { chartProps: ChartProps; transformProps?: TransformProps }) =>
input.chartProps,
input => input.transformProps,
],
(preprocessedChartProps, transform = IDENTITY) =>
transform(preprocessedChartProps),
);
/**
* memoized function so it will not recompute and return previous value
* unless one of the input arguments have changed.
*/
postSelector = createSelector(
[
(input: {
chartProps: ChartProps;
postTransformProps?: PostTransformProps;
}) => input.chartProps,
input => input.postTransformProps,
],
(chartProps, pre = IDENTITY, transform = IDENTITY, post = IDENTITY) =>
post(transform(pre(chartProps))),
(transformedChartProps, post = IDENTITY) => post(transformedChartProps),
);
/**
* Using each memoized function to retrieve the computed chartProps
*/
processChartProps = ({
chartProps,
preTransformProps,
transformProps,
postTransformProps,
}: {
chartProps: ChartProps;
preTransformProps?: PreTransformProps;
transformProps?: TransformProps;
postTransformProps?: PostTransformProps;
}) =>
this.postSelector({
chartProps: this.transformSelector({
chartProps: this.preSelector({ chartProps, preTransformProps }),
transformProps,
}),
postTransformProps,
});
/**
* memoized function so it will not recompute
* and return previous value

View File

@@ -73,11 +73,14 @@ beforeEach(() => {
dbId: expectDbId,
forceRefresh: false,
},
fakeSchemaApiResult.map(value => ({
value,
label: value,
title: value,
})),
{
result: fakeSchemaApiResult.map(value => ({
value,
label: value,
title: value,
})),
default: null,
},
),
);
store.dispatch(
@@ -307,11 +310,14 @@ test('returns long keywords with docText', async () => {
dbId: expectLongKeywordDbId,
forceRefresh: false,
},
['short', longKeyword].map(value => ({
value,
label: value,
title: value,
})),
{
result: ['short', longKeyword].map(value => ({
value,
label: value,
title: value,
})),
default: null,
},
),
);
});

View File

@@ -164,7 +164,7 @@ export function useKeywords(
const schemaKeywords = useMemo(
() =>
(schemaOptions ?? []).map(s => ({
(schemaOptions?.result ?? []).map(s => ({
name: s.label,
value: s.value,
score: SCHEMA_AUTOCOMPLETE_SCORE,

View File

@@ -286,8 +286,6 @@ const SqlEditor: FC<Props> = ({
const SqlFormExtension = extensionsRegistry.get('sqleditor.extension.form');
const isTempId = (value: unknown): boolean => Number.isNaN(Number(value));
const startQuery = useCallback(
(ctasArg = false, ctas_method = CtasEnum.Table) => {
if (!database) {
@@ -979,9 +977,7 @@ const SqlEditor: FC<Props> = ({
{({ height }) =>
isActive && (
<AceEditorWrapper
autocomplete={
autocompleteEnabled && !isTempId(queryEditor.id)
}
autocomplete={autocompleteEnabled}
onBlur={onSqlChanged}
onChange={onSqlChanged}
queryEditorId={queryEditor.id}

View File

@@ -163,7 +163,10 @@ export default function getInitialState({
if (localStorageData && sqlLabCacheData?.sqlLab) {
const { sqlLab } = sqlLabCacheData;
if (sqlLab.queryEditors.length === 0) {
if (
sqlLab.queryEditors.length === 0 &&
Object.keys(sqlLab.destroyedQueryEditors ?? {}).length === 0
) {
// migration was successful
localStorage.removeItem('redux');
} else {
@@ -221,18 +224,21 @@ export default function getInitialState({
});
}
if (sqlLab.tabHistory) {
tabHistory.push(...sqlLab.tabHistory);
tabHistory.push(
...sqlLab.tabHistory.filter(
tabId => !sqlLab.destroyedQueryEditors?.[tabId],
),
);
}
lastUpdatedActiveTab = tabHistory.slice(tabHistory.length - 1)[0] || '';
if (sqlLab.destroyedQueryEditors) {
Object.entries(sqlLab.destroyedQueryEditors).forEach(([id, ts]) => {
destroyedQueryEditors[id] = ts;
if (queryEditors[id]) {
destroyedQueryEditors[id] = ts;
delete queryEditors[id];
}
});
}
lastUpdatedActiveTab = tabHistory.slice(tabHistory.length - 1)[0] || '';
}
}
} catch (error) {

View File

@@ -135,7 +135,7 @@ export default function sqlLabReducer(state = {}, action) {
};
let newState = removeFromArr(state, 'queryEditors', queryEditor);
// List of remaining queryEditor ids
const qeIds = newState.queryEditors.map(qe => qe.id);
const qeIds = newState.queryEditors.map(qe => qe.tabViewId ?? qe.id);
const queries = {};
Object.keys(state.queries).forEach(k => {
@@ -150,7 +150,8 @@ export default function sqlLabReducer(state = {}, action) {
// Remove associated table schemas
const tables = state.tables.filter(
table => table.queryEditorId !== queryEditor.id,
table =>
table.queryEditorId !== (queryEditor.tabViewId ?? queryEditor.id),
);
newState = {
@@ -167,7 +168,9 @@ export default function sqlLabReducer(state = {}, action) {
},
destroyedQueryEditors: {
...newState.destroyedQueryEditors,
...(!queryEditor.inLocalStorage && { [queryEditor.id]: Date.now() }),
...(!queryEditor.inLocalStorage && {
[queryEditor.tabViewId ?? queryEditor.id]: Date.now(),
}),
},
};
return newState;

View File

@@ -240,11 +240,13 @@ describe('sqlLabReducer', () => {
);
});
it('should migrate query editor by new query editor id', () => {
const { length } = newState.queryEditors;
const index = newState.queryEditors.findIndex(({ id }) => id === qe.id);
const newQueryEditor = {
...qe,
id: 'updatedNewId',
tabViewId: 'updatedNewId',
schema: 'updatedSchema',
inLocalStorage: false,
};
const action = {
type: actions.MIGRATE_QUERY_EDITOR,
@@ -252,8 +254,18 @@ describe('sqlLabReducer', () => {
newQueryEditor,
};
newState = sqlLabReducer(newState, action);
expect(newState.queryEditors[index].id).toEqual('updatedNewId');
expect(newState.queryEditors[index].id).toEqual(qe.id);
expect(newState.queryEditors[index].tabViewId).toEqual('updatedNewId');
expect(newState.queryEditors[index]).toEqual(newQueryEditor);
const removeAction = {
type: actions.REMOVE_QUERY_EDITOR,
queryEditor: newQueryEditor,
};
newState = sqlLabReducer(newState, removeAction);
expect(newState.queryEditors).toHaveLength(length - 1);
expect(Object.keys(newState.destroyedQueryEditors)).toContain(
newQueryEditor.tabViewId,
);
});
it('should clear the destroyed query editors', () => {
const expectedQEId = '1233289';

View File

@@ -182,6 +182,7 @@ class ChartRenderer extends Component {
this.props.formData.subcategories ||
nextProps.cacheBusterProp !== this.props.cacheBusterProp ||
nextProps.emitCrossFilters !== this.props.emitCrossFilters ||
nextProps.postTransformProps !== this.props.postTransformProps ||
hasMatrixifyChanges()
);
}

View File

@@ -27,8 +27,10 @@ import { ChartSource } from 'src/types/ChartSource';
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
SuperChart: ({ formData }) => (
<div data-test="mock-super-chart">{JSON.stringify(formData)}</div>
SuperChart: ({ postTransformProps = x => x, ...props }) => (
<div data-test="mock-super-chart">
{JSON.stringify(postTransformProps(props).formData)}
</div>
),
}));
@@ -119,6 +121,23 @@ test('should detect changes in matrixify properties', () => {
});
});
test('should detect changes in postTransformProps', () => {
const postTransformProps = jest.fn(x => x);
const initialProps = {
...requiredProps,
queriesResponse: [{ data: 'initial' }],
chartStatus: 'success',
};
const { rerender } = render(<ChartRenderer {...initialProps} />);
const updatedProps = {
...initialProps,
postTransformProps,
};
expect(postTransformProps).toHaveBeenCalledTimes(0);
rerender(<ChartRenderer {...updatedProps} />);
expect(postTransformProps).toHaveBeenCalledTimes(1);
});
test('should identify matrixify property changes correctly', () => {
// Test that formData with different matrixify properties triggers updates
const initialProps = {

View File

@@ -163,11 +163,13 @@ const fakeDatabaseApiResultInReverseOrder = {
const fakeSchemaApiResult = {
count: 2,
result: ['information_schema', 'public'],
default: 'public',
};
const fakeCatalogApiResult = {
count: 0,
result: [],
default: null,
};
const fakeFunctionNamesApiResult = {
@@ -382,3 +384,143 @@ test('Sends the correct schema when changing the schema', async () => {
);
expect(props.onSchemaChange).toHaveBeenCalledTimes(1);
});
test('Should auto-select default schema on load', async () => {
const props = createProps();
// Remove initial schema to test auto-selection
const propsWithoutSchema = { ...props, schema: undefined };
render(<DatabaseSelector {...propsWithoutSchema} />, {
useRedux: true,
store,
});
// Wait for the default schema to be auto-selected
await waitFor(() => {
expect(propsWithoutSchema.onSchemaChange).toHaveBeenCalledWith('public');
});
});
test('Should auto-select default catalog for multi-catalog databases', async () => {
const multiCatalogApiResult = {
count: 2,
result: ['catalog1', 'catalog2'],
default: 'catalog1',
};
fetchMock.get(catalogApiRoute, multiCatalogApiResult, {
overwriteRoutes: true,
});
const props = createProps();
const multiCatalogDb = {
...props.db!,
allow_multi_catalog: true,
};
const multiCatalogProps = {
...props,
db: multiCatalogDb,
catalog: undefined,
onCatalogChange: jest.fn(),
};
render(<DatabaseSelector {...multiCatalogProps} />, {
useRedux: true,
store,
});
// Wait for the default catalog to be auto-selected
await waitFor(() => {
expect(multiCatalogProps.onCatalogChange).toHaveBeenCalledWith('catalog1');
});
});
test('Should disable schema dropdown while catalogs are loading', async () => {
const props = createProps();
const multiCatalogDb = {
...props.db!,
allow_multi_catalog: true,
};
const multiCatalogProps = {
...props,
db: multiCatalogDb,
catalog: undefined,
};
// Mock a delayed catalog response
fetchMock.get(
catalogApiRoute,
new Promise(resolve =>
setTimeout(
() =>
resolve({
count: 1,
result: ['default_catalog'],
default: 'default_catalog',
}),
100,
),
),
{ overwriteRoutes: true },
);
render(<DatabaseSelector {...multiCatalogProps} />, {
useRedux: true,
store,
});
// Initially, schema dropdown should be disabled while catalogs load
const schemaSelect = screen.getByRole('combobox', {
name: /select schema or type to search schemas/i,
});
expect(schemaSelect).toBeDisabled();
// Wait for catalogs to load and schema dropdown to be enabled
await waitFor(
() => {
expect(schemaSelect).toBeEnabled();
},
{ timeout: 200 },
);
});
test('Should not fetch schemas until catalog is selected for multi-catalog databases', async () => {
const props = createProps();
const multiCatalogDb = {
...props.db!,
allow_multi_catalog: true,
};
const multiCatalogProps = {
...props,
db: multiCatalogDb,
catalog: undefined,
};
render(<DatabaseSelector {...multiCatalogProps} />, {
useRedux: true,
store,
});
// Initially, schemas should not be fetched
expect(fetchMock.calls(schemaApiRoute).length).toBe(0);
// Wait a bit to ensure schemas are still not fetched
await new Promise(resolve => setTimeout(resolve, 100));
expect(fetchMock.calls(schemaApiRoute).length).toBe(0);
});
test('Should fetch schemas immediately for non-catalog databases', async () => {
const props = createProps();
// Non-catalog database (allow_multi_catalog is not set)
render(<DatabaseSelector {...props} />, {
useRedux: true,
store,
});
// Schemas should be fetched immediately for non-catalog databases
await waitFor(() => {
expect(fetchMock.calls(schemaApiRoute).length).toBe(1);
});
});

View File

@@ -238,23 +238,20 @@ export function DatabaseSelector({
}
}
const shouldFetchSchemas =
currentDb?.value &&
(!showCatalogSelector || (currentCatalog && currentCatalog.value));
const {
currentData: schemaData,
isFetching: loadingSchemas,
refetch: refetchSchemas,
} = useSchemas({
dbId: currentDb?.value,
dbId: shouldFetchSchemas ? currentDb?.value : undefined,
catalog: currentCatalog?.value,
onSuccess: (schemas, isFetched) => {
onSuccess: (schemas, isFetched, defaultValue) => {
setErrorPayload(null);
if (schemas.length === 1) {
changeSchema(schemas[0]);
} else if (
!schemas.find(schemaOption => schemaRef.current === schemaOption.value)
) {
changeSchema(undefined);
}
autoSelectSchema(schemas, defaultValue);
if (isFetched) {
addSuccessToast('List refreshed');
}
@@ -268,11 +265,13 @@ export function DatabaseSelector({
},
});
const schemaOptions = schemaData || EMPTY_SCHEMA_OPTIONS;
const schemaOptions = schemaData?.result || EMPTY_SCHEMA_OPTIONS;
function changeCatalog(catalog: CatalogOption | null | undefined) {
setCurrentCatalog(catalog);
setCurrentSchema(undefined);
// Clear schema ref so auto-selection works for the new catalog
schemaRef.current = undefined;
if (onCatalogChange && catalog?.value !== catalogRef.current) {
onCatalogChange(catalog?.value);
}
@@ -284,20 +283,9 @@ export function DatabaseSelector({
refetch: refetchCatalogs,
} = useCatalogs({
dbId: showCatalogSelector ? currentDb?.value : undefined,
onSuccess: (catalogs, isFetched) => {
onSuccess: (catalogs, isFetched, defaultValue) => {
setErrorPayload(null);
if (!showCatalogSelector) {
changeCatalog(null);
} else if (catalogs.length === 1) {
changeCatalog(catalogs[0]);
} else if (
!catalogs.find(
catalogOption => catalogRef.current === catalogOption.value,
)
) {
changeCatalog(undefined);
}
autoSelectCatalog(catalogs, defaultValue);
if (showCatalogSelector && isFetched) {
addSuccessToast('List refreshed');
}
@@ -313,7 +301,59 @@ export function DatabaseSelector({
},
});
const catalogOptions = catalogData || EMPTY_CATALOG_OPTIONS;
const catalogOptions = catalogData?.result || EMPTY_CATALOG_OPTIONS;
// Centralized auto-selection logic
const autoSelectCatalog = useCallback(
(catalogs: CatalogOption[], defaultValue?: string | null) => {
if (!showCatalogSelector) {
changeCatalog(null);
} else if (defaultValue && !catalogRef.current) {
const defaultCatalog = catalogs.find(
catalog => catalog.value === defaultValue,
);
if (defaultCatalog) {
changeCatalog(defaultCatalog);
}
} else if (catalogs.length === 1) {
changeCatalog(catalogs[0]);
} else if (
!catalogs.find(
catalogOption => catalogRef.current === catalogOption.value,
)
) {
changeCatalog(undefined);
}
},
[showCatalogSelector, changeCatalog, catalogRef],
);
const autoSelectSchema = useCallback(
(schemas: SchemaOption[], defaultValue?: string | null) => {
if (defaultValue && !schemaRef.current) {
const defaultSchema = schemas.find(
schema => schema.value === defaultValue,
);
if (defaultSchema) {
changeSchema(defaultSchema);
}
} else if (schemas.length === 1) {
changeSchema(schemas[0]);
} else if (
!schemas.find(schemaOption => schemaRef.current === schemaOption.value)
) {
changeSchema(undefined);
}
},
[schemaRef],
);
// For non-catalog databases, set catalog to null immediately
useEffect(() => {
if (currentDb && !showCatalogSelector) {
setCurrentCatalog(null);
}
}, [currentDb?.id, showCatalogSelector]);
function changeDatabase(
value: { label: string; value: number },
@@ -325,6 +365,9 @@ export function DatabaseSelector({
setCurrentDb(databaseWithId);
setCurrentCatalog(undefined);
setCurrentSchema(undefined);
// Clear refs so auto-selection works when switching back to a database
catalogRef.current = undefined;
schemaRef.current = undefined;
if (onDbChange) {
onDbChange(databaseWithId);
}
@@ -402,7 +445,11 @@ export function DatabaseSelector({
return renderSelectRow(
<Select
ariaLabel={t('Select schema or type to search schemas')}
disabled={!currentDb || readOnly}
disabled={
!currentDb ||
readOnly ||
(showCatalogSelector && (loadingCatalogs || !currentCatalog))
}
header={<FormLabel>{t('Schema')}</FormLabel>}
labelInValue
loading={loadingSchemas}

View File

@@ -27,10 +27,19 @@ export type CatalogOption = {
title: string;
};
export type CatalogResponse = {
result: CatalogOption[];
default: string | null;
};
export type FetchCatalogsQueryParams = {
dbId?: string | number;
forceRefresh: boolean;
onSuccess?: (data: CatalogOption[], isRefetched: boolean) => void;
onSuccess?: (
data: CatalogOption[],
isRefetched: boolean,
defaultValue?: string | null,
) => void;
onError?: (error: ClientErrorObject) => void;
};
@@ -38,19 +47,21 @@ type Params = Omit<FetchCatalogsQueryParams, 'forceRefresh'>;
const catalogApi = api.injectEndpoints({
endpoints: builder => ({
catalogs: builder.query<CatalogOption[], FetchCatalogsQueryParams>({
catalogs: builder.query<CatalogResponse, FetchCatalogsQueryParams>({
providesTags: [{ type: 'Catalogs', id: 'LIST' }],
query: ({ dbId, forceRefresh }) => ({
endpoint: `/api/v1/database/${dbId}/catalogs/`,
urlParams: {
force: forceRefresh,
},
transformResponse: ({ json }: JsonResponse) =>
json.result.sort().map((value: string) => ({
transformResponse: ({ json }: JsonResponse) => ({
result: json.result.sort().map((value: string) => ({
value,
label: value,
title: value,
})),
default: json.default,
}),
}),
serializeQueryArgs: ({ queryArgs: { dbId } }) => ({
dbId,
@@ -89,7 +100,11 @@ export function useCatalogs(options: Params) {
if (dbId && (!result.currentData || forceRefresh)) {
trigger({ dbId, forceRefresh }).then(({ isSuccess, isError, data }) => {
if (isSuccess) {
onSuccess?.(data || EMPTY_CATALOGS, forceRefresh);
onSuccess?.(
data?.result || EMPTY_CATALOGS,
forceRefresh,
data?.default,
);
}
if (isError) {
onError?.(result.error as ClientErrorObject);

View File

@@ -28,29 +28,41 @@ import { useSchemas } from './schemas';
const fakeApiResult = {
result: ['test schema 1', 'test schema b'],
default: 'test schema 1',
};
const fakeApiResult2 = {
result: ['test schema 2', 'test schema a'],
default: null,
};
const fakeApiResult3 = {
result: ['test schema 3', 'test schema c'],
default: 'test schema 3',
};
const expectedResult = fakeApiResult.result.map((value: string) => ({
value,
label: value,
title: value,
}));
const expectedResult2 = fakeApiResult2.result.map((value: string) => ({
value,
label: value,
title: value,
}));
const expectedResult3 = fakeApiResult3.result.map((value: string) => ({
value,
label: value,
title: value,
}));
const expectedResult = {
result: fakeApiResult.result.map((value: string) => ({
value,
label: value,
title: value,
})),
default: fakeApiResult.default,
};
const expectedResult2 = {
result: fakeApiResult2.result.map((value: string) => ({
value,
label: value,
title: value,
})),
default: fakeApiResult2.default,
};
const expectedResult3 = {
result: fakeApiResult3.result.map((value: string) => ({
value,
label: value,
title: value,
})),
default: fakeApiResult3.default,
};
describe('useSchemas hook', () => {
beforeEach(() => {

View File

@@ -27,11 +27,20 @@ export type SchemaOption = {
title: string;
};
export type SchemaResponse = {
result: SchemaOption[];
default: string | null;
};
export type FetchSchemasQueryParams = {
dbId?: string | number;
catalog?: string;
forceRefresh: boolean;
onSuccess?: (data: SchemaOption[], isRefetched: boolean) => void;
onSuccess?: (
data: SchemaOption[],
isRefetched: boolean,
defaultValue?: string | null,
) => void;
onError?: (error: ClientErrorObject) => void;
};
@@ -39,7 +48,7 @@ type Params = Omit<FetchSchemasQueryParams, 'forceRefresh'>;
const schemaApi = api.injectEndpoints({
endpoints: builder => ({
schemas: builder.query<SchemaOption[], FetchSchemasQueryParams>({
schemas: builder.query<SchemaResponse, FetchSchemasQueryParams>({
providesTags: [{ type: 'Schemas', id: 'LIST' }],
query: ({ dbId, catalog, forceRefresh }) => ({
endpoint: `/api/v1/database/${dbId}/schemas/`,
@@ -48,12 +57,14 @@ const schemaApi = api.injectEndpoints({
force: forceRefresh,
...(catalog !== undefined && { catalog }),
},
transformResponse: ({ json }: JsonResponse) =>
json.result.sort().map((value: string) => ({
transformResponse: ({ json }: JsonResponse) => ({
result: json.result.sort().map((value: string) => ({
value,
label: value,
title: value,
})),
default: json.default,
}),
}),
serializeQueryArgs: ({ queryArgs: { dbId, catalog } }) => ({
dbId,
@@ -98,7 +109,11 @@ export function useSchemas(options: Params) {
trigger({ dbId, catalog, forceRefresh }).then(
({ isSuccess, isError, data }) => {
if (isSuccess) {
onSuccess?.(data || EMPTY_SCHEMAS, forceRefresh);
onSuccess?.(
data?.result || EMPTY_SCHEMAS,
forceRefresh,
data?.default,
);
}
if (isError) {
onError?.(result.error as ClientErrorObject);

View File

@@ -172,7 +172,7 @@ export function useTables(options: Params) {
catalog: catalog || undefined,
});
const schemaOptionsMap = useMemo(
() => new Set(schemaOptions?.map(({ value }) => value)),
() => new Set(schemaOptions?.result?.map(({ value }) => value)),
[schemaOptions],
);

View File

@@ -30,7 +30,7 @@
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.26.0",
"@typescript-eslint/parser": "^8.33.0",
"eslint": "^9.30.0",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lodash": "^8.0.0",
"globals": "^16.3.0",
@@ -760,18 +760,19 @@
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz",
"integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==",
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz",
"integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/core": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz",
"integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==",
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
"integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -839,10 +840,11 @@
}
},
"node_modules/@eslint/js": {
"version": "9.30.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.0.tgz",
"integrity": "sha512-Wzw3wQwPvc9sHM+NjakWTcPx11mbZyiYHuwWa/QfZ7cIRX7WK54PSk7bdyXDaoaopUcMatv1zaQvOAAO8hCdww==",
"version": "9.34.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz",
"integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -860,13 +862,13 @@
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz",
"integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==",
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
"integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.14.0",
"@eslint/core": "^0.15.2",
"levn": "^0.4.1"
},
"engines": {
@@ -3239,19 +3241,20 @@
}
},
"node_modules/eslint": {
"version": "9.30.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.0.tgz",
"integrity": "sha512-iN/SiPxmQu6EVkf+m1qpBxzUhE12YqFLOSySuOyVLJLEF9nzTf+h/1AJYc1JWzCnktggeNrjvQGLngDzXirU6g==",
"version": "9.34.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz",
"integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.0",
"@eslint/config-helpers": "^0.3.0",
"@eslint/core": "^0.14.0",
"@eslint/config-helpers": "^0.3.1",
"@eslint/core": "^0.15.2",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.30.0",
"@eslint/plugin-kit": "^0.3.1",
"@eslint/js": "9.34.0",
"@eslint/plugin-kit": "^0.3.5",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
@@ -7635,15 +7638,15 @@
}
},
"@eslint/config-helpers": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz",
"integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==",
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz",
"integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==",
"dev": true
},
"@eslint/core": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz",
"integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==",
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
"integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
"dev": true,
"requires": {
"@types/json-schema": "^7.0.15"
@@ -7690,9 +7693,9 @@
}
},
"@eslint/js": {
"version": "9.30.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.0.tgz",
"integrity": "sha512-Wzw3wQwPvc9sHM+NjakWTcPx11mbZyiYHuwWa/QfZ7cIRX7WK54PSk7bdyXDaoaopUcMatv1zaQvOAAO8hCdww==",
"version": "9.34.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz",
"integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==",
"dev": true
},
"@eslint/object-schema": {
@@ -7702,12 +7705,12 @@
"dev": true
},
"@eslint/plugin-kit": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz",
"integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==",
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
"integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
"dev": true,
"requires": {
"@eslint/core": "^0.14.0",
"@eslint/core": "^0.15.2",
"levn": "^0.4.1"
}
},
@@ -9450,19 +9453,19 @@
"dev": true
},
"eslint": {
"version": "9.30.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.0.tgz",
"integrity": "sha512-iN/SiPxmQu6EVkf+m1qpBxzUhE12YqFLOSySuOyVLJLEF9nzTf+h/1AJYc1JWzCnktggeNrjvQGLngDzXirU6g==",
"version": "9.34.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz",
"integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==",
"dev": true,
"requires": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
"@eslint/config-array": "^0.21.0",
"@eslint/config-helpers": "^0.3.0",
"@eslint/core": "^0.14.0",
"@eslint/config-helpers": "^0.3.1",
"@eslint/core": "^0.15.2",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.30.0",
"@eslint/plugin-kit": "^0.3.1",
"@eslint/js": "9.34.0",
"@eslint/plugin-kit": "^0.3.5",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",

View File

@@ -38,7 +38,7 @@
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.26.0",
"@typescript-eslint/parser": "^8.33.0",
"eslint": "^9.30.0",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lodash": "^8.0.0",
"globals": "^16.3.0",

View File

@@ -727,7 +727,13 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
database,
catalogs,
)
return self.response(200, result=list(catalogs))
default_catalog = database.get_default_catalog()
# TODO: Consider refactoring API response structure during
# next breaking change window to return catalogs as objects
# with a 'default' flag instead of separate fields
# e.g., result=[{"name": "catalog1", "default": true},
# {"name": "catalog2", "default": false}]
return self.response(200, result=list(catalogs), default=default_catalog)
except OperationalError:
return self.response(
500,
@@ -796,9 +802,10 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
catalog,
schemas,
)
default_schema = database.get_default_schema(catalog)
if params.get("upload_allowed"):
if not database.allow_file_upload:
return self.response(200, result=[])
return self.response(200, result=[], default=default_schema)
if allowed_schemas := database.get_schema_access_for_file_upload():
# some databases might return the list of schemas in uppercase,
# while the list of allowed schemas is manually inputted so
@@ -811,8 +818,14 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
for schema in schemas
if schema.lower() in allowed_schemas
],
default=default_schema,
)
return self.response(200, result=list(schemas))
# TODO: Consider refactoring API response structure during
# next breaking change window to return schemas as objects
# with a 'default' flag instead of separate fields
# e.g., result=[{"name": "schema1", "default": true},
# {"name": "schema2", "default": false}]
return self.response(200, result=list(schemas), default=default_schema)
except OperationalError:
return self.response(
500, message="There was an error connecting to the database"

View File

@@ -724,12 +724,20 @@ class SchemasResponseSchema(Schema):
result = fields.List(
fields.String(metadata={"description": "A database schema name"})
)
default = fields.String(
allow_none=True,
metadata={"description": "The default schema for this database"},
)
class CatalogsResponseSchema(Schema):
result = fields.List(
fields.String(metadata={"description": "A database catalog name"})
)
default = fields.String(
allow_none=True,
metadata={"description": "The default catalog for this database"},
)
class DatabaseTablesResponse(Schema):

View File

@@ -2261,6 +2261,7 @@ def test_catalogs(
"""
database = mocker.MagicMock()
database.get_all_catalog_names.return_value = {"db1", "db2"}
database.get_default_catalog.return_value = "db1"
DatabaseDAO = mocker.patch("superset.databases.api.DatabaseDAO") # noqa: N806
DatabaseDAO.find_by_id.return_value = database
@@ -2272,7 +2273,7 @@ def test_catalogs(
response = client.get("/api/v1/database/1/catalogs/")
assert response.status_code == 200
assert response.json == {"result": ["db2"]}
assert response.json == {"result": ["db2"], "default": "db1"}
database.get_all_catalog_names.assert_called_with(
cache=database.catalog_cache_enabled,
cache_timeout=database.catalog_cache_timeout,
@@ -2344,6 +2345,7 @@ def test_schemas(
database = mocker.MagicMock()
database.get_all_schema_names.return_value = {"schema1", "schema2"}
database.get_default_schema.return_value = "public"
datamodel = mocker.patch.object(DatabaseRestApi, "datamodel")
datamodel.get.return_value = database
@@ -2355,13 +2357,14 @@ def test_schemas(
response = client.get("/api/v1/database/1/schemas/")
assert response.status_code == 200
assert response.json == {"result": ["schema2"]}
assert response.json == {"result": ["schema2"], "default": "public"}
database.get_all_schema_names.assert_called_with(
catalog=None,
cache=database.schema_cache_enabled,
cache_timeout=database.schema_cache_timeout,
force=False,
)
database.get_default_schema.assert_called_with(None)
security_manager.get_schemas_accessible_by_user.assert_called_with(
database,
None,
@@ -2383,6 +2386,7 @@ def test_schemas(
cache_timeout=database.schema_cache_timeout,
force=True,
)
database.get_default_schema.assert_called_with("catalog2")
security_manager.get_schemas_accessible_by_user.assert_called_with(
database,
"catalog2",
@@ -2390,6 +2394,58 @@ def test_schemas(
)
def test_catalogs_with_null_default(
mocker: MockerFixture,
client: Any,
full_api_access: None,
) -> None:
"""
Test the `catalogs` endpoint when default catalog is None.
"""
database = mocker.MagicMock()
database.get_all_catalog_names.return_value = {"db1", "db2"}
database.get_default_catalog.return_value = None
DatabaseDAO = mocker.patch("superset.databases.api.DatabaseDAO") # noqa: N806
DatabaseDAO.find_by_id.return_value = database
security_manager = mocker.patch(
"superset.databases.api.security_manager",
new=mocker.MagicMock(),
)
security_manager.get_catalogs_accessible_by_user.return_value = {"db2"}
response = client.get("/api/v1/database/1/catalogs/")
assert response.status_code == 200
assert response.json == {"result": ["db2"], "default": None}
def test_schemas_with_null_default(
mocker: MockerFixture,
client: Any,
full_api_access: None,
) -> None:
"""
Test the `schemas` endpoint when default schema is None.
"""
from superset.databases.api import DatabaseRestApi
database = mocker.MagicMock()
database.get_all_schema_names.return_value = {"schema1", "schema2"}
database.get_default_schema.return_value = None
datamodel = mocker.patch.object(DatabaseRestApi, "datamodel")
datamodel.get.return_value = database
security_manager = mocker.patch(
"superset.databases.api.security_manager",
new=mocker.MagicMock(),
)
security_manager.get_schemas_accessible_by_user.return_value = {"schema2"}
response = client.get("/api/v1/database/1/schemas/")
assert response.status_code == 200
assert response.json == {"result": ["schema2"], "default": None}
def test_schemas_with_oauth2(
mocker: MockerFixture,
client: Any,