This is an automated email from the ASF dual-hosted git repository. beto pushed a commit to branch default-schema-catalog in repository https://gitbox.apache.org/repos/asf/superset.git
commit 4e52bc8a0bb496d1f9f5212e12275225774fa743 Author: Beto Dealmeida <robe...@dealmeida.net> AuthorDate: Wed Sep 3 15:57:01 2025 -0400 Update frontend --- .../AceEditorWrapper/useKeywords.test.ts | 26 ++-- .../components/AceEditorWrapper/useKeywords.ts | 2 +- .../DatabaseSelector/DatabaseSelector.test.tsx | 142 +++++++++++++++++++++ .../src/components/DatabaseSelector/index.tsx | 110 ++++++++++++---- .../src/hooks/apiResources/catalogs.ts | 25 +++- .../src/hooks/apiResources/schemas.test.ts | 42 +++--- .../src/hooks/apiResources/schemas.ts | 25 +++- superset-frontend/src/hooks/apiResources/tables.ts | 2 +- 8 files changed, 312 insertions(+), 62 deletions(-) diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts index e0f49b70e3..f1ced5c34c 100644 --- a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts +++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.test.ts @@ -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, + }, ), ); }); diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.ts b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.ts index 99e9912513..d5f96599aa 100644 --- a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.ts +++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.ts @@ -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, diff --git a/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx b/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx index f8d0425c5e..fd5c0b43c5 100644 --- a/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx +++ b/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx @@ -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(props.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); + }); +}); diff --git a/superset-frontend/src/components/DatabaseSelector/index.tsx b/superset-frontend/src/components/DatabaseSelector/index.tsx index c50abd4442..eb8d38813f 100644 --- a/superset-frontend/src/components/DatabaseSelector/index.tsx +++ b/superset-frontend/src/components/DatabaseSelector/index.tsx @@ -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,10 +283,38 @@ export function DatabaseSelector({ refetch: refetchCatalogs, } = useCatalogs({ dbId: showCatalogSelector ? currentDb?.value : undefined, - onSuccess: (catalogs, isFetched) => { + onSuccess: (catalogs, isFetched, defaultValue) => { setErrorPayload(null); + autoSelectCatalog(catalogs, defaultValue); + if (showCatalogSelector && isFetched) { + addSuccessToast('List refreshed'); + } + }, + onError: error => { + if (showCatalogSelector) { + if (error?.errors) { + setErrorPayload(error?.errors?.[0]); + } else { + handleError(t('There was an error loading the catalogs')); + } + } + }, + }); + + 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 ( @@ -297,23 +324,49 @@ export function DatabaseSelector({ ) { changeCatalog(undefined); } - - if (showCatalogSelector && isFetched) { - addSuccessToast('List refreshed'); - } }, - onError: error => { - if (showCatalogSelector) { - if (error?.errors) { - setErrorPayload(error?.errors?.[0]); - } else { - handleError(t('There was an error loading the catalogs')); + [showCatalogSelector, 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], + ); - const catalogOptions = catalogData || EMPTY_CATALOG_OPTIONS; + // For non-catalog databases, set catalog to null immediately + useEffect(() => { + if (currentDb && !showCatalogSelector) { + setCurrentCatalog(null); + } + }, [currentDb?.id, showCatalogSelector]); + + // Auto-select when data becomes available (handles both fresh data and cached data) + useEffect(() => { + if (catalogData?.result) { + autoSelectCatalog(catalogData.result, catalogData.default); + } + }, [catalogData?.result, catalogData?.default, autoSelectCatalog]); + + useEffect(() => { + if (schemaData?.result) { + autoSelectSchema(schemaData.result, schemaData.default); + } + }, [schemaData?.result, schemaData?.default, autoSelectSchema]); function changeDatabase( value: { label: string; value: number }, @@ -325,6 +378,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 +458,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} diff --git a/superset-frontend/src/hooks/apiResources/catalogs.ts b/superset-frontend/src/hooks/apiResources/catalogs.ts index 26f56b1dd6..7e759245a6 100644 --- a/superset-frontend/src/hooks/apiResources/catalogs.ts +++ b/superset-frontend/src/hooks/apiResources/catalogs.ts @@ -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); diff --git a/superset-frontend/src/hooks/apiResources/schemas.test.ts b/superset-frontend/src/hooks/apiResources/schemas.test.ts index 85751a2fee..74ac610e9a 100644 --- a/superset-frontend/src/hooks/apiResources/schemas.test.ts +++ b/superset-frontend/src/hooks/apiResources/schemas.test.ts @@ -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(() => { diff --git a/superset-frontend/src/hooks/apiResources/schemas.ts b/superset-frontend/src/hooks/apiResources/schemas.ts index 4439b894cf..b56877bebb 100644 --- a/superset-frontend/src/hooks/apiResources/schemas.ts +++ b/superset-frontend/src/hooks/apiResources/schemas.ts @@ -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); diff --git a/superset-frontend/src/hooks/apiResources/tables.ts b/superset-frontend/src/hooks/apiResources/tables.ts index 81792b4a52..985bd3de73 100644 --- a/superset-frontend/src/hooks/apiResources/tables.ts +++ b/superset-frontend/src/hooks/apiResources/tables.ts @@ -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], );