You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by be...@apache.org on 2025/12/19 00:11:49 UTC

(superset) branch default-db-schema-dropdown created (now ff17daa424)

This is an automated email from the ASF dual-hosted git repository.

beto pushed a change to branch default-db-schema-dropdown
in repository https://gitbox.apache.org/repos/asf/superset.git


      at ff17daa424 Small fix

This branch includes the following new commits:

     new 4c6df01353 feat(api): add default field to catalogs and schemas API responses
     new b377ce564b test(api): add tests for default catalog/schema in API responses
     new 1910a5c607 feat(frontend): extract default catalog/schema from API responses
     new db201285e5 Testing
     new 89c5c55dcb Small fix
     new ff17daa424 Small fix

The 6 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(superset) 05/06: Small fix

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

beto pushed a commit to branch default-db-schema-dropdown
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 89c5c55dcb16295e3b271465092e3957dbfeaf97
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Thu Dec 18 18:59:18 2025 -0500

    Small fix
---
 .../AceEditorWrapper/useKeywords.test.ts           | 26 +++++++++++++---------
 .../components/AceEditorWrapper/useKeywords.ts     |  3 ++-
 2 files changed, 18 insertions(+), 11 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..71ac29cac2 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,
-        })),
+        {
+          schemas: fakeSchemaApiResult.map(value => ({
+            value,
+            label: value,
+            title: value,
+          })),
+          defaultSchema: 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,
-        })),
+        {
+          schemas: ['short', longKeyword].map(value => ({
+            value,
+            label: value,
+            title: value,
+          })),
+          defaultSchema: null,
+        },
       ),
     );
   });
diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.ts b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.ts
index 1a8eb7b368..b65eb41769 100644
--- a/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.ts
+++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper/useKeywords.ts
@@ -78,7 +78,7 @@ export function useKeywords(
   // skipFetch is used to prevent re-evaluating memoized keywords
   // due to updated api results by skip flag
   const skipFetch = hasFetchedKeywords && skip;
-  const { currentData: schemaOptions } = useSchemasQueryState(
+  const { currentData: schemaData } = useSchemasQueryState(
     {
       dbId,
       catalog: catalog || undefined,
@@ -86,6 +86,7 @@ export function useKeywords(
     },
     { skip: skipFetch || !dbId },
   );
+  const schemaOptions = schemaData?.schemas;
   const { currentData: tableData } = useTablesQueryState(
     {
       dbId,


(superset) 04/06: Testing

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

beto pushed a commit to branch default-db-schema-dropdown
in repository https://gitbox.apache.org/repos/asf/superset.git

commit db201285e58c5f614bba44a586804a58773c8bd1
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Thu Dec 18 18:51:57 2025 -0500

    Testing
---
 .../DatabaseSelector/DatabaseSelector.test.tsx     |  81 +++++++++++++
 .../src/components/DatabaseSelector/index.tsx      |  46 +++++++-
 .../{schemas.test.ts => catalogs.test.ts}          | 131 +++++++++------------
 .../src/hooks/apiResources/catalogs.ts             |  12 +-
 .../src/hooks/apiResources/schemas.test.ts         |  48 ++++++--
 .../src/hooks/apiResources/schemas.ts              |  12 +-
 superset-frontend/src/hooks/apiResources/tables.ts |   2 +-
 7 files changed, 236 insertions(+), 96 deletions(-)

diff --git a/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx b/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx
index f8d0425c5e..8d3d8bd8b0 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,82 @@ test('Sends the correct schema when changing the schema', async () => {
   );
   expect(props.onSchemaChange).toHaveBeenCalledTimes(1);
 });
+
+test('Auto-selects default schema on first load when no schema is provided', async () => {
+  fetchMock.get(
+    schemaApiRoute,
+    {
+      result: ['information_schema', 'public', 'other_schema'],
+      default: 'public',
+    },
+    { overwriteRoutes: true },
+  );
+
+  const props = {
+    ...createProps(),
+    schema: undefined,
+  };
+
+  render(<DatabaseSelector {...props} />, { useRedux: true, store });
+
+  // Wait for schemas to load and default to be applied
+  await waitFor(() => {
+    expect(props.onSchemaChange).toHaveBeenCalledWith('public');
+  });
+});
+
+test('Does not auto-select default schema when schema is already provided', async () => {
+  fetchMock.get(
+    schemaApiRoute,
+    {
+      result: ['information_schema', 'public', 'other_schema'],
+      default: 'public',
+    },
+    { overwriteRoutes: true },
+  );
+
+  const props = {
+    ...createProps(),
+    schema: 'information_schema',
+  };
+
+  render(<DatabaseSelector {...props} />, { useRedux: true, store });
+
+  // Wait for schemas to load
+  await waitFor(() => {
+    expect(fetchMock.calls(schemaApiRoute).length).toBe(1);
+  });
+
+  // Should not call onSchemaChange since schema is already set
+  expect(props.onSchemaChange).not.toHaveBeenCalled();
+});
+
+test('Auto-selects default catalog on first load for multi-catalog database', async () => {
+  fetchMock.get(
+    catalogApiRoute,
+    {
+      result: ['catalog_a', 'catalog_b', 'catalog_c'],
+      default: 'catalog_b',
+    },
+    { overwriteRoutes: true },
+  );
+
+  const props = {
+    ...createProps(),
+    db: {
+      id: 1,
+      database_name: 'test-multicatalog',
+      backend: 'test-postgresql',
+      allow_multi_catalog: true,
+    },
+    catalog: undefined,
+    onCatalogChange: jest.fn(),
+  };
+
+  render(<DatabaseSelector {...props} />, { useRedux: true, store });
+
+  // Wait for catalogs to load and default to be applied
+  await waitFor(() => {
+    expect(props.onCatalogChange).toHaveBeenCalledWith('catalog_b');
+  });
+});
diff --git a/superset-frontend/src/components/DatabaseSelector/index.tsx b/superset-frontend/src/components/DatabaseSelector/index.tsx
index 6fc50b3e55..7d110b4215 100644
--- a/superset-frontend/src/components/DatabaseSelector/index.tsx
+++ b/superset-frontend/src/components/DatabaseSelector/index.tsx
@@ -144,6 +144,9 @@ export function DatabaseSelector({
   );
   const schemaRef = useRef(schema);
   schemaRef.current = schema;
+  // Track if we've applied defaults to avoid re-applying after user clears selection
+  const appliedCatalogDefaultRef = useRef<string | null>(null);
+  const appliedSchemaDefaultRef = useRef<string | null>(null);
   const { addSuccessToast } = useToasts();
   const sortComparator = useCallback(
     (itemA: AntdLabeledValueWithOrder, itemB: AntdLabeledValueWithOrder) =>
@@ -240,20 +243,34 @@ export function DatabaseSelector({
   }
 
   const {
-    currentData: schemaData,
+    data: schemaData,
     isFetching: loadingSchemas,
     refetch: refetchSchemas,
   } = useSchemas({
     dbId: currentDb?.value,
     catalog: currentCatalog?.value,
-    onSuccess: (schemas, isFetched) => {
+    onSuccess: (schemas, isFetched, defaultSchema) => {
       setErrorPayload(null);
       if (schemas.length === 1) {
         changeSchema(schemas[0]);
       } else if (
         !schemas.find(schemaOption => schemaRef.current === schemaOption.value)
       ) {
-        changeSchema(undefined);
+        // Current selection not in list - try to apply default on first load
+        if (
+          defaultSchema &&
+          appliedSchemaDefaultRef.current !== defaultSchema
+        ) {
+          const defaultOption = schemas.find(s => s.value === defaultSchema);
+          if (defaultOption) {
+            appliedSchemaDefaultRef.current = defaultSchema;
+            changeSchema(defaultOption);
+          } else {
+            changeSchema(undefined);
+          }
+        } else {
+          changeSchema(undefined);
+        }
       }
 
       if (isFetched) {
@@ -274,6 +291,8 @@ export function DatabaseSelector({
   function changeCatalog(catalog: CatalogOption | null | undefined) {
     setCurrentCatalog(catalog);
     setCurrentSchema(undefined);
+    // Reset schema default ref so default can be applied for the new catalog
+    appliedSchemaDefaultRef.current = null;
     if (onCatalogChange && catalog?.value !== catalogRef.current) {
       onCatalogChange(catalog?.value);
     }
@@ -285,7 +304,7 @@ export function DatabaseSelector({
     refetch: refetchCatalogs,
   } = useCatalogs({
     dbId: showCatalogSelector ? currentDb?.value : undefined,
-    onSuccess: (catalogs, isFetched) => {
+    onSuccess: (catalogs, isFetched, defaultCatalog) => {
       setErrorPayload(null);
       if (!showCatalogSelector) {
         changeCatalog(null);
@@ -296,7 +315,21 @@ export function DatabaseSelector({
           catalogOption => catalogRef.current === catalogOption.value,
         )
       ) {
-        changeCatalog(undefined);
+        // Current selection not in list - try to apply default on first load
+        if (
+          defaultCatalog &&
+          appliedCatalogDefaultRef.current !== defaultCatalog
+        ) {
+          const defaultOption = catalogs.find(c => c.value === defaultCatalog);
+          if (defaultOption) {
+            appliedCatalogDefaultRef.current = defaultCatalog;
+            changeCatalog(defaultOption);
+          } else {
+            changeCatalog(undefined);
+          }
+        } else {
+          changeCatalog(undefined);
+        }
       }
 
       if (showCatalogSelector && isFetched) {
@@ -326,6 +359,9 @@ export function DatabaseSelector({
     setCurrentDb(databaseWithId);
     setCurrentCatalog(undefined);
     setCurrentSchema(undefined);
+    // Reset default refs so defaults can be applied for the new database
+    appliedCatalogDefaultRef.current = null;
+    appliedSchemaDefaultRef.current = null;
     if (onDbChange) {
       onDbChange(databaseWithId);
     }
diff --git a/superset-frontend/src/hooks/apiResources/schemas.test.ts b/superset-frontend/src/hooks/apiResources/catalogs.test.ts
similarity index 54%
copy from superset-frontend/src/hooks/apiResources/schemas.test.ts
copy to superset-frontend/src/hooks/apiResources/catalogs.test.ts
index b47ef894d0..ea5bd1a077 100644
--- a/superset-frontend/src/hooks/apiResources/schemas.test.ts
+++ b/superset-frontend/src/hooks/apiResources/catalogs.test.ts
@@ -24,16 +24,15 @@ import {
   defaultStore as store,
 } from 'spec/helpers/testing-library';
 import { api } from 'src/hooks/apiResources/queryApi';
-import { useSchemas } from './schemas';
+import { useCatalogs } from './catalogs';
 
 const fakeApiResult = {
-  result: ['test schema 1', 'test schema b'],
+  result: ['catalog_a', 'catalog_b'],
+  default: 'catalog_a',
 };
 const fakeApiResult2 = {
-  result: ['test schema 2', 'test schema a'],
-};
-const fakeApiResult3 = {
-  result: ['test schema 3', 'test schema c'],
+  result: ['catalog_c', 'catalog_d'],
+  default: null,
 };
 
 const expectedResult = fakeApiResult.result.map((value: string) => ({
@@ -46,28 +45,23 @@ const expectedResult2 = fakeApiResult2.result.map((value: string) => ({
   label: value,
   title: value,
 }));
-const expectedResult3 = fakeApiResult3.result.map((value: string) => ({
-  value,
-  label: value,
-  title: value,
-}));
 
 // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
-describe('useSchemas hook', () => {
+describe('useCatalogs hook', () => {
   beforeEach(() => {
     fetchMock.reset();
     store.dispatch(api.util.resetApiState());
   });
 
-  test('returns api response mapping json result', async () => {
+  test('returns api response mapping json result with default catalog', async () => {
     const expectDbId = 'db1';
     const forceRefresh = false;
-    const schemaApiRoute = `glob:*/api/v1/database/${expectDbId}/schemas/*`;
-    fetchMock.get(schemaApiRoute, fakeApiResult);
+    const catalogApiRoute = `glob:*/api/v1/database/${expectDbId}/catalogs/*`;
+    fetchMock.get(catalogApiRoute, fakeApiResult);
     const onSuccess = jest.fn();
     const { result, waitFor } = renderHook(
       () =>
-        useSchemas({
+        useCatalogs({
           dbId: expectDbId,
           onSuccess,
         }),
@@ -78,11 +72,14 @@ describe('useSchemas hook', () => {
         }),
       },
     );
-    await waitFor(() => expect(fetchMock.calls(schemaApiRoute).length).toBe(1));
+    await waitFor(() =>
+      expect(fetchMock.calls(catalogApiRoute).length).toBe(1),
+    );
     expect(result.current.data).toEqual(expectedResult);
+    expect(result.current.defaultCatalog).toBe('catalog_a');
     expect(
       fetchMock.calls(
-        `end:/api/v1/database/${expectDbId}/schemas/?q=${rison.encode({
+        `end:/api/v1/database/${expectDbId}/catalogs/?q=${rison.encode({
           force: forceRefresh,
         })}`,
       ).length,
@@ -91,25 +88,28 @@ describe('useSchemas hook', () => {
     act(() => {
       result.current.refetch();
     });
-    await waitFor(() => expect(fetchMock.calls(schemaApiRoute).length).toBe(2));
+    await waitFor(() =>
+      expect(fetchMock.calls(catalogApiRoute).length).toBe(2),
+    );
     expect(
       fetchMock.calls(
-        `end:/api/v1/database/${expectDbId}/schemas/?q=${rison.encode({
+        `end:/api/v1/database/${expectDbId}/catalogs/?q=${rison.encode({
           force: true,
         })}`,
       ).length,
     ).toBe(1);
     expect(onSuccess).toHaveBeenCalledTimes(2);
     expect(result.current.data).toEqual(expectedResult);
+    expect(result.current.defaultCatalog).toBe('catalog_a');
   });
 
   test('returns cached data without api request', async () => {
     const expectDbId = 'db1';
-    const schemaApiRoute = `glob:*/api/v1/database/${expectDbId}/schemas/*`;
-    fetchMock.get(schemaApiRoute, fakeApiResult);
+    const catalogApiRoute = `glob:*/api/v1/database/${expectDbId}/catalogs/*`;
+    fetchMock.get(catalogApiRoute, fakeApiResult);
     const { result, rerender, waitFor } = renderHook(
       () =>
-        useSchemas({
+        useCatalogs({
           dbId: expectDbId,
         }),
       {
@@ -120,22 +120,24 @@ describe('useSchemas hook', () => {
       },
     );
     await waitFor(() => expect(result.current.data).toEqual(expectedResult));
-    expect(fetchMock.calls(schemaApiRoute).length).toBe(1);
+    expect(result.current.defaultCatalog).toBe('catalog_a');
+    expect(fetchMock.calls(catalogApiRoute).length).toBe(1);
     rerender();
     await waitFor(() => expect(result.current.data).toEqual(expectedResult));
-    expect(fetchMock.calls(schemaApiRoute).length).toBe(1);
+    expect(result.current.defaultCatalog).toBe('catalog_a');
+    expect(fetchMock.calls(catalogApiRoute).length).toBe(1);
   });
 
-  test('returns refreshed data after expires', async () => {
+  test('returns refreshed data after switching databases', async () => {
     const expectDbId = 'db1';
-    const schemaApiRoute = `glob:*/api/v1/database/*/schemas/*`;
-    fetchMock.get(schemaApiRoute, url =>
+    const catalogApiRoute = `glob:*/api/v1/database/*/catalogs/*`;
+    fetchMock.get(catalogApiRoute, url =>
       url.includes(expectDbId) ? fakeApiResult : fakeApiResult2,
     );
     const onSuccess = jest.fn();
     const { result, rerender, waitFor } = renderHook(
       ({ dbId }) =>
-        useSchemas({
+        useCatalogs({
           dbId,
           onSuccess,
         }),
@@ -148,74 +150,55 @@ describe('useSchemas hook', () => {
       },
     );
 
-    await waitFor(() =>
-      expect(result.current.currentData).toEqual(expectedResult),
-    );
-    expect(fetchMock.calls(schemaApiRoute).length).toBe(1);
+    await waitFor(() => expect(result.current.data).toEqual(expectedResult));
+    expect(result.current.defaultCatalog).toBe('catalog_a');
+    expect(fetchMock.calls(catalogApiRoute).length).toBe(1);
     expect(onSuccess).toHaveBeenCalledTimes(1);
 
     rerender({ dbId: 'db2' });
-    await waitFor(() =>
-      expect(result.current.currentData).toEqual(expectedResult2),
-    );
-    expect(fetchMock.calls(schemaApiRoute).length).toBe(2);
+    await waitFor(() => expect(result.current.data).toEqual(expectedResult2));
+    expect(result.current.defaultCatalog).toBeNull();
+    expect(fetchMock.calls(catalogApiRoute).length).toBe(2);
     expect(onSuccess).toHaveBeenCalledTimes(2);
 
     rerender({ dbId: expectDbId });
-    await waitFor(() =>
-      expect(result.current.currentData).toEqual(expectedResult),
-    );
-    expect(fetchMock.calls(schemaApiRoute).length).toBe(2);
+    await waitFor(() => expect(result.current.data).toEqual(expectedResult));
+    expect(result.current.defaultCatalog).toBe('catalog_a');
+    expect(fetchMock.calls(catalogApiRoute).length).toBe(2);
     expect(onSuccess).toHaveBeenCalledTimes(2);
 
     // clean up cache
     act(() => {
-      store.dispatch(api.util.invalidateTags(['Schemas']));
+      store.dispatch(api.util.invalidateTags(['Catalogs']));
     });
 
-    await waitFor(() => expect(fetchMock.calls(schemaApiRoute).length).toBe(4));
-    expect(fetchMock.calls(schemaApiRoute)[2][0]).toContain(expectDbId);
     await waitFor(() =>
-      expect(result.current.currentData).toEqual(expectedResult),
+      expect(fetchMock.calls(catalogApiRoute).length).toBe(4),
     );
+    expect(fetchMock.calls(catalogApiRoute)[2][0]).toContain(expectDbId);
+    await waitFor(() => expect(result.current.data).toEqual(expectedResult));
+    expect(result.current.defaultCatalog).toBe('catalog_a');
   });
 
-  test('returns correct schema list by a catalog', async () => {
-    const dbId = '1';
-    const expectCatalog = 'catalog3';
-    const schemaApiRoute = `glob:*/api/v1/database/*/schemas/*`;
-    fetchMock.get(schemaApiRoute, url =>
-      url.includes(`catalog:${expectCatalog}`)
-        ? fakeApiResult3
-        : fakeApiResult2,
-    );
-    const onSuccess = jest.fn();
-    const { result, rerender, waitFor } = renderHook(
-      ({ dbId, catalog }) =>
-        useSchemas({
-          dbId,
-          catalog,
-          onSuccess,
+  test('returns null defaultCatalog when API response has no default', async () => {
+    const expectDbId = 'db-no-default';
+    const catalogApiRoute = `glob:*/api/v1/database/${expectDbId}/catalogs/*`;
+    fetchMock.get(catalogApiRoute, { result: ['catalog1', 'catalog2'] });
+    const { result, waitFor } = renderHook(
+      () =>
+        useCatalogs({
+          dbId: expectDbId,
         }),
       {
-        initialProps: { dbId, catalog: expectCatalog },
         wrapper: createWrapper({
           useRedux: true,
           store,
         }),
       },
     );
-
-    await waitFor(() => expect(fetchMock.calls(schemaApiRoute).length).toBe(1));
-    expect(result.current.data).toEqual(expectedResult3);
-    expect(onSuccess).toHaveBeenCalledTimes(1);
-
-    rerender({ dbId, catalog: 'catalog2' });
-    await waitFor(() => expect(fetchMock.calls(schemaApiRoute).length).toBe(2));
-    expect(result.current.data).toEqual(expectedResult2);
-
-    rerender({ dbId, catalog: expectCatalog });
-    expect(result.current.data).toEqual(expectedResult3);
-    expect(fetchMock.calls(schemaApiRoute).length).toBe(2);
+    await waitFor(() =>
+      expect(fetchMock.calls(catalogApiRoute).length).toBe(1),
+    );
+    expect(result.current.defaultCatalog).toBeNull();
   });
 });
diff --git a/superset-frontend/src/hooks/apiResources/catalogs.ts b/superset-frontend/src/hooks/apiResources/catalogs.ts
index 636b5d92ec..69aaff9a55 100644
--- a/superset-frontend/src/hooks/apiResources/catalogs.ts
+++ b/superset-frontend/src/hooks/apiResources/catalogs.ts
@@ -30,7 +30,11 @@ export type CatalogOption = {
 export type FetchCatalogsQueryParams = {
   dbId?: string | number;
   forceRefresh: boolean;
-  onSuccess?: (data: CatalogOption[], isRefetched: boolean) => void;
+  onSuccess?: (
+    data: CatalogOption[],
+    isRefetched: boolean,
+    defaultCatalog: string | null,
+  ) => void;
   onError?: (error: ClientErrorObject) => void;
 };
 
@@ -97,7 +101,11 @@ export function useCatalogs(options: Params) {
       if (dbId && (!result.currentData || forceRefresh)) {
         trigger({ dbId, forceRefresh }).then(({ isSuccess, isError, data }) => {
           if (isSuccess) {
-            onSuccess?.(data?.catalogs || EMPTY_CATALOGS, forceRefresh);
+            onSuccess?.(
+              data?.catalogs || EMPTY_CATALOGS,
+              forceRefresh,
+              data?.defaultCatalog ?? null,
+            );
           }
           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 b47ef894d0..d0c7801581 100644
--- a/superset-frontend/src/hooks/apiResources/schemas.test.ts
+++ b/superset-frontend/src/hooks/apiResources/schemas.test.ts
@@ -28,12 +28,15 @@ 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 c',
 };
 
 const expectedResult = fakeApiResult.result.map((value: string) => ({
@@ -80,6 +83,7 @@ describe('useSchemas hook', () => {
     );
     await waitFor(() => expect(fetchMock.calls(schemaApiRoute).length).toBe(1));
     expect(result.current.data).toEqual(expectedResult);
+    expect(result.current.defaultSchema).toBe('test schema 1');
     expect(
       fetchMock.calls(
         `end:/api/v1/database/${expectDbId}/schemas/?q=${rison.encode({
@@ -120,9 +124,11 @@ describe('useSchemas hook', () => {
       },
     );
     await waitFor(() => expect(result.current.data).toEqual(expectedResult));
+    expect(result.current.defaultSchema).toBe('test schema 1');
     expect(fetchMock.calls(schemaApiRoute).length).toBe(1);
     rerender();
     await waitFor(() => expect(result.current.data).toEqual(expectedResult));
+    expect(result.current.defaultSchema).toBe('test schema 1');
     expect(fetchMock.calls(schemaApiRoute).length).toBe(1);
   });
 
@@ -148,23 +154,20 @@ describe('useSchemas hook', () => {
       },
     );
 
-    await waitFor(() =>
-      expect(result.current.currentData).toEqual(expectedResult),
-    );
+    await waitFor(() => expect(result.current.data).toEqual(expectedResult));
+    expect(result.current.defaultSchema).toBe('test schema 1');
     expect(fetchMock.calls(schemaApiRoute).length).toBe(1);
     expect(onSuccess).toHaveBeenCalledTimes(1);
 
     rerender({ dbId: 'db2' });
-    await waitFor(() =>
-      expect(result.current.currentData).toEqual(expectedResult2),
-    );
+    await waitFor(() => expect(result.current.data).toEqual(expectedResult2));
+    expect(result.current.defaultSchema).toBeNull();
     expect(fetchMock.calls(schemaApiRoute).length).toBe(2);
     expect(onSuccess).toHaveBeenCalledTimes(2);
 
     rerender({ dbId: expectDbId });
-    await waitFor(() =>
-      expect(result.current.currentData).toEqual(expectedResult),
-    );
+    await waitFor(() => expect(result.current.data).toEqual(expectedResult));
+    expect(result.current.defaultSchema).toBe('test schema 1');
     expect(fetchMock.calls(schemaApiRoute).length).toBe(2);
     expect(onSuccess).toHaveBeenCalledTimes(2);
 
@@ -175,9 +178,7 @@ describe('useSchemas hook', () => {
 
     await waitFor(() => expect(fetchMock.calls(schemaApiRoute).length).toBe(4));
     expect(fetchMock.calls(schemaApiRoute)[2][0]).toContain(expectDbId);
-    await waitFor(() =>
-      expect(result.current.currentData).toEqual(expectedResult),
-    );
+    await waitFor(() => expect(result.current.data).toEqual(expectedResult));
   });
 
   test('returns correct schema list by a catalog', async () => {
@@ -208,14 +209,37 @@ describe('useSchemas hook', () => {
 
     await waitFor(() => expect(fetchMock.calls(schemaApiRoute).length).toBe(1));
     expect(result.current.data).toEqual(expectedResult3);
+    expect(result.current.defaultSchema).toBe('test schema c');
     expect(onSuccess).toHaveBeenCalledTimes(1);
 
     rerender({ dbId, catalog: 'catalog2' });
     await waitFor(() => expect(fetchMock.calls(schemaApiRoute).length).toBe(2));
     expect(result.current.data).toEqual(expectedResult2);
+    expect(result.current.defaultSchema).toBeNull();
 
     rerender({ dbId, catalog: expectCatalog });
     expect(result.current.data).toEqual(expectedResult3);
+    expect(result.current.defaultSchema).toBe('test schema c');
     expect(fetchMock.calls(schemaApiRoute).length).toBe(2);
   });
+
+  test('returns null defaultSchema when API response has no default', async () => {
+    const expectDbId = 'db-no-default';
+    const schemaApiRoute = `glob:*/api/v1/database/${expectDbId}/schemas/*`;
+    fetchMock.get(schemaApiRoute, { result: ['schema1', 'schema2'] });
+    const { result, waitFor } = renderHook(
+      () =>
+        useSchemas({
+          dbId: expectDbId,
+        }),
+      {
+        wrapper: createWrapper({
+          useRedux: true,
+          store,
+        }),
+      },
+    );
+    await waitFor(() => expect(fetchMock.calls(schemaApiRoute).length).toBe(1));
+    expect(result.current.defaultSchema).toBeNull();
+  });
 });
diff --git a/superset-frontend/src/hooks/apiResources/schemas.ts b/superset-frontend/src/hooks/apiResources/schemas.ts
index 41a729c118..20261b4f3e 100644
--- a/superset-frontend/src/hooks/apiResources/schemas.ts
+++ b/superset-frontend/src/hooks/apiResources/schemas.ts
@@ -31,7 +31,11 @@ export type FetchSchemasQueryParams = {
   dbId?: string | number;
   catalog?: string;
   forceRefresh: boolean;
-  onSuccess?: (data: SchemaOption[], isRefetched: boolean) => void;
+  onSuccess?: (
+    data: SchemaOption[],
+    isRefetched: boolean,
+    defaultSchema: string | null,
+  ) => void;
   onError?: (error: ClientErrorObject) => void;
 };
 
@@ -106,7 +110,11 @@ export function useSchemas(options: Params) {
         trigger({ dbId, catalog, forceRefresh }).then(
           ({ isSuccess, isError, data }) => {
             if (isSuccess) {
-              onSuccess?.(data?.schemas || EMPTY_SCHEMAS, forceRefresh);
+              onSuccess?.(
+                data?.schemas || EMPTY_SCHEMAS,
+                forceRefresh,
+                data?.defaultSchema ?? null,
+              );
             }
             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..621ca7cc54 100644
--- a/superset-frontend/src/hooks/apiResources/tables.ts
+++ b/superset-frontend/src/hooks/apiResources/tables.ts
@@ -167,7 +167,7 @@ export const {
 export function useTables(options: Params) {
   const { dbId, catalog, schema, onSuccess, onError } = options || {};
   const isMountedRef = useRef(false);
-  const { currentData: schemaOptions, isFetching } = useSchemas({
+  const { data: schemaOptions, isFetching } = useSchemas({
     dbId,
     catalog: catalog || undefined,
   });


(superset) 02/06: test(api): add tests for default catalog/schema in API responses

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

beto pushed a commit to branch default-db-schema-dropdown
in repository https://gitbox.apache.org/repos/asf/superset.git

commit b377ce564b97adfe13e662078bcc9129faf06f6a
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Thu Dec 18 18:11:08 2025 -0500

    test(api): add tests for default catalog/schema in API responses
    
    Add and update tests for the catalogs and schemas endpoints to verify:
    - Default catalog/schema is returned when accessible
    - Default is null when not in user's accessible list
    - Default is null when retrieval fails (error handling)
    - Default works correctly with upload_allowed filter
    - Default is null when not in upload-allowed schemas list
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    Co-Authored-By: Claude Opus 4.5 <no...@anthropic.com>
---
 tests/unit_tests/databases/api_test.py | 344 +++++++++++++++++++++++++--------
 1 file changed, 265 insertions(+), 79 deletions(-)

diff --git a/tests/unit_tests/databases/api_test.py b/tests/unit_tests/databases/api_test.py
index 0785d762e5..a0f035630d 100644
--- a/tests/unit_tests/databases/api_test.py
+++ b/tests/unit_tests/databases/api_test.py
@@ -225,93 +225,96 @@ def test_database_connection(
     mocker.patch("superset.utils.log.DBEventLogger.log")
 
     response = client.get("/api/v1/database/1/connection")
-    assert response.json == {
-        "id": 1,
-        "result": {
-            "allow_ctas": False,
-            "allow_cvas": False,
-            "allow_dml": False,
-            "allow_file_upload": False,
-            "allow_run_async": False,
-            "backend": "gsheets",
-            "cache_timeout": None,
-            "configuration_method": "sqlalchemy_form",
-            "database_name": "my_database",
-            "driver": "gsheets",
-            "engine_information": {
-                "disable_ssh_tunneling": True,
-                "supports_dynamic_catalog": False,
-                "supports_file_upload": True,
-                "supports_oauth2": True,
-            },
-            "expose_in_sqllab": True,
-            "extra": '{\n    "metadata_params": {},\n    "engine_params": {},\n    "metadata_cache_timeout": {},\n    "schemas_allowed_for_file_upload": []\n}\n',  # noqa: E501
-            "force_ctas_schema": None,
+    assert (
+        response.json
+        == {
             "id": 1,
-            "impersonate_user": False,
-            "is_managed_externally": False,
-            "masked_encrypted_extra": json.dumps(
-                {
+            "result": {
+                "allow_ctas": False,
+                "allow_cvas": False,
+                "allow_dml": False,
+                "allow_file_upload": False,
+                "allow_run_async": False,
+                "backend": "gsheets",
+                "cache_timeout": None,
+                "configuration_method": "sqlalchemy_form",
+                "database_name": "my_database",
+                "driver": "gsheets",
+                "engine_information": {
+                    "disable_ssh_tunneling": True,
+                    "supports_dynamic_catalog": False,
+                    "supports_file_upload": True,
+                    "supports_oauth2": True,
+                },
+                "expose_in_sqllab": True,
+                "extra": '{\n    "metadata_params": {},\n    "engine_params": {},\n    "metadata_cache_timeout": {},\n    "schemas_allowed_for_file_upload": []\n}\n',  # noqa: E501
+                "force_ctas_schema": None,
+                "id": 1,
+                "impersonate_user": False,
+                "is_managed_externally": False,
+                "masked_encrypted_extra": json.dumps(
+                    {
+                        "service_account_info": {
+                            "type": "service_account",
+                            "project_id": "black-sanctum-314419",
+                            "private_key_id": "259b0d419a8f840056158763ff54d8b08f7b8173",  # noqa: E501
+                            "private_key": "XXXXXXXXXX",
+                            "client_email": "google-spreadsheets-demo-servi@black-sanctum-314419.iam.gserviceaccount.com",  # noqa: E501
+                            "client_id": "114567578578109757129",
+                            "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+                            "token_uri": "https://oauth2.googleapis.com/token",
+                            "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
+                            "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/google-spreadsheets-demo-servi%40black-sanctum-314419.iam.gserviceaccount.com",
+                        }
+                    }
+                ),
+                "parameters": {
                     "service_account_info": {
-                        "type": "service_account",
-                        "project_id": "black-sanctum-314419",
-                        "private_key_id": "259b0d419a8f840056158763ff54d8b08f7b8173",
-                        "private_key": "XXXXXXXXXX",
+                        "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
+                        "auth_uri": "https://accounts.google.com/o/oauth2/auth",
                         "client_email": "google-spreadsheets-demo-servi@black-sanctum-314419.iam.gserviceaccount.com",  # noqa: E501
                         "client_id": "114567578578109757129",
-                        "auth_uri": "https://accounts.google.com/o/oauth2/auth",
-                        "token_uri": "https://oauth2.googleapis.com/token",
-                        "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
                         "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/google-spreadsheets-demo-servi%40black-sanctum-314419.iam.gserviceaccount.com",
+                        "private_key": "XXXXXXXXXX",
+                        "private_key_id": "259b0d419a8f840056158763ff54d8b08f7b8173",
+                        "project_id": "black-sanctum-314419",
+                        "token_uri": "https://oauth2.googleapis.com/token",
+                        "type": "service_account",
                     }
-                }
-            ),
-            "parameters": {
-                "service_account_info": {
-                    "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
-                    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
-                    "client_email": "google-spreadsheets-demo-servi@black-sanctum-314419.iam.gserviceaccount.com",  # noqa: E501
-                    "client_id": "114567578578109757129",
-                    "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/google-spreadsheets-demo-servi%40black-sanctum-314419.iam.gserviceaccount.com",
-                    "private_key": "XXXXXXXXXX",
-                    "private_key_id": "259b0d419a8f840056158763ff54d8b08f7b8173",
-                    "project_id": "black-sanctum-314419",
-                    "token_uri": "https://oauth2.googleapis.com/token",
-                    "type": "service_account",
-                }
-            },
-            "parameters_schema": {
-                "properties": {
-                    "catalog": {"type": "object"},
-                    "oauth2_client_info": {
-                        "default": {
-                            "authorization_request_uri": "https://accounts.google.com/o/oauth2/v2/auth",
-                            "scope": (
-                                "https://www.googleapis.com/auth/drive.readonly "
-                                "https://www.googleapis.com/auth/spreadsheets "
-                                "https://spreadsheets.google.com/feeds"
-                            ),
-                            "token_request_uri": "https://oauth2.googleapis.com/token",
+                },
+                "parameters_schema": {
+                    "properties": {
+                        "catalog": {"type": "object"},
+                        "oauth2_client_info": {
+                            "default": {
+                                "authorization_request_uri": "https://accounts.google.com/o/oauth2/v2/auth",
+                                "scope": (
+                                    "https://www.googleapis.com/auth/drive.readonly "
+                                    "https://www.googleapis.com/auth/spreadsheets "
+                                    "https://spreadsheets.google.com/feeds"
+                                ),
+                                "token_request_uri": "https://oauth2.googleapis.com/token",
+                            },
+                            "description": "OAuth2 client information",
+                            "nullable": True,
+                            "type": "string",
+                            "x-encrypted-extra": True,
+                        },
+                        "service_account_info": {
+                            "description": "Contents of GSheets JSON credentials.",
+                            "type": "string",
+                            "x-encrypted-extra": True,
                         },
-                        "description": "OAuth2 client information",
-                        "nullable": True,
-                        "type": "string",
-                        "x-encrypted-extra": True,
-                    },
-                    "service_account_info": {
-                        "description": "Contents of GSheets JSON credentials.",
-                        "type": "string",
-                        "x-encrypted-extra": True,
                     },
+                    "type": "object",
                 },
-                "type": "object",
+                "server_cert": None,
+                "sqlalchemy_uri": "gsheets://",
+                "ssh_tunnel": None,
+                "uuid": "02feae18-2dd6-4bb4-a9c0-49e9d4f29d58",
             },
-            "server_cert": None,
-            "sqlalchemy_uri": "gsheets://",
-            "ssh_tunnel": None,
-            "uuid": "02feae18-2dd6-4bb4-a9c0-49e9d4f29d58",
-        },
-    }
+        }
+    )
 
     response = client.get("/api/v1/database/1")
     assert response.json == {
@@ -2104,6 +2107,7 @@ def test_catalogs(
     """
     database = mocker.MagicMock()
     database.get_all_catalog_names.return_value = {"db1", "db2"}
+    database.get_default_catalog.return_value = "db2"
     DatabaseDAO = mocker.patch("superset.databases.api.DatabaseDAO")  # noqa: N806
     DatabaseDAO.find_by_id.return_value = database
 
@@ -2115,7 +2119,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": "db2"}
     database.get_all_catalog_names.assert_called_with(
         cache=database.catalog_cache_enabled,
         cache_timeout=database.catalog_cache_timeout,
@@ -2187,6 +2191,7 @@ def test_schemas(
 
     database = mocker.MagicMock()
     database.get_all_schema_names.return_value = {"schema1", "schema2"}
+    database.get_default_schema.return_value = "schema2"
     datamodel = mocker.patch.object(DatabaseRestApi, "datamodel")
     datamodel.get.return_value = database
 
@@ -2198,7 +2203,7 @@ 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": "schema2"}
     database.get_all_schema_names.assert_called_with(
         catalog=None,
         cache=database.schema_cache_enabled,
@@ -2274,3 +2279,184 @@ def test_schemas_with_oauth2(
             }
         ]
     }
+
+
+def test_catalogs_default_not_accessible(
+    mocker: MockerFixture,
+    client: Any,
+    full_api_access: None,
+) -> None:
+    """
+    Test that `default` is null when the default catalog is not accessible to the user.
+    """
+    database = mocker.MagicMock()
+    database.get_all_catalog_names.return_value = {"db1", "db2"}
+    database.get_default_catalog.return_value = "db1"  # default is db1
+    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(),
+    )
+    # User only has access to db2, not the default db1
+    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_catalogs_default_retrieval_fails(
+    mocker: MockerFixture,
+    client: Any,
+    full_api_access: None,
+) -> None:
+    """
+    Test that the endpoint still works when get_default_catalog fails.
+    """
+    database = mocker.MagicMock()
+    database.get_all_catalog_names.return_value = {"db1", "db2"}
+    database.get_default_catalog.side_effect = Exception("Connection failed")
+    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 = {"db1", "db2"}
+
+    response = client.get("/api/v1/database/1/catalogs/")
+    assert response.status_code == 200
+    # Result should still be returned, default is null due to error
+    assert set(response.json["result"]) == {"db1", "db2"}
+    assert response.json["default"] is None
+
+
+def test_schemas_default_not_accessible(
+    mocker: MockerFixture,
+    client: Any,
+    full_api_access: None,
+) -> None:
+    """
+    Test that `default` is null when the default schema is not accessible to the user.
+    """
+    from superset.databases.api import DatabaseRestApi
+
+    database = mocker.MagicMock()
+    database.get_all_schema_names.return_value = {"public", "private"}
+    database.get_default_schema.return_value = "public"  # default is public
+    datamodel = mocker.patch.object(DatabaseRestApi, "datamodel")
+    datamodel.get.return_value = database
+
+    security_manager = mocker.patch(
+        "superset.databases.api.security_manager",
+        new=mocker.MagicMock(),
+    )
+    # User only has access to private, not the default public
+    security_manager.get_schemas_accessible_by_user.return_value = {"private"}
+
+    response = client.get("/api/v1/database/1/schemas/")
+    assert response.status_code == 200
+    assert response.json == {"result": ["private"], "default": None}
+
+
+def test_schemas_default_retrieval_fails(
+    mocker: MockerFixture,
+    client: Any,
+    full_api_access: None,
+) -> None:
+    """
+    Test that the endpoint still works when get_default_schema fails.
+    """
+    from superset.databases.api import DatabaseRestApi
+
+    database = mocker.MagicMock()
+    database.get_all_schema_names.return_value = {"public", "private"}
+    database.get_default_schema.side_effect = Exception("Connection failed")
+    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 = {"public", "private"}
+
+    response = client.get("/api/v1/database/1/schemas/")
+    assert response.status_code == 200
+    # Result should still be returned, default is null due to error
+    assert set(response.json["result"]) == {"public", "private"}
+    assert response.json["default"] is None
+
+
+def test_schemas_default_with_upload_allowed(
+    mocker: MockerFixture,
+    client: Any,
+    full_api_access: None,
+) -> None:
+    """
+    Test that default schema is returned correctly with upload_allowed filter.
+    """
+    from superset.databases.api import DatabaseRestApi
+
+    database = mocker.MagicMock()
+    database.get_all_schema_names.return_value = {"public", "uploads", "private"}
+    database.get_default_schema.return_value = "public"
+    database.allow_file_upload = True
+    database.get_schema_access_for_file_upload.return_value = ["uploads", "public"]
+    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 = {
+        "public",
+        "uploads",
+        "private",
+    }
+
+    response = client.get("/api/v1/database/1/schemas/?q=(upload_allowed:!t)")
+    assert response.status_code == 200
+    # Only upload-allowed schemas should be returned
+    assert set(response.json["result"]) == {"public", "uploads"}
+    # Default should be public since it's in the allowed list
+    assert response.json["default"] == "public"
+
+
+def test_schemas_default_not_in_upload_allowed(
+    mocker: MockerFixture,
+    client: Any,
+    full_api_access: None,
+) -> None:
+    """
+    Test that default schema is null when not in upload_allowed schemas.
+    """
+    from superset.databases.api import DatabaseRestApi
+
+    database = mocker.MagicMock()
+    database.get_all_schema_names.return_value = {"public", "uploads", "private"}
+    database.get_default_schema.return_value = "private"  # default not in allowed list
+    database.allow_file_upload = True
+    database.get_schema_access_for_file_upload.return_value = ["uploads", "public"]
+    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 = {
+        "public",
+        "uploads",
+        "private",
+    }
+
+    response = client.get("/api/v1/database/1/schemas/?q=(upload_allowed:!t)")
+    assert response.status_code == 200
+    assert set(response.json["result"]) == {"public", "uploads"}
+    # Default should be null since "private" is not in allowed list
+    assert response.json["default"] is None


(superset) 01/06: feat(api): add default field to catalogs and schemas API responses

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

beto pushed a commit to branch default-db-schema-dropdown
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 4c6df01353a7dca40363cd80d8223bbe4584a202
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Thu Dec 18 18:07:22 2025 -0500

    feat(api): add default field to catalogs and schemas API responses
    
    Add a `default` field to the `/api/v1/database/{id}/catalogs/` and
    `/api/v1/database/{id}/schemas/` endpoints. This field contains the
    default catalog/schema for the database, or null if it cannot be
    determined or is not accessible to the user.
    
    The default is retrieved from the database engine spec via
    `database.get_default_catalog()` and `database.get_default_schema()`.
    Error handling ensures the API never fails if default retrieval is slow
    or fails - it simply returns null.
    
    RBAC is respected: the default is only returned if the user has access
    to it. For the upload_allowed filter, the default is checked against
    the allowed schemas list.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    Co-Authored-By: Claude Opus 4.5 <no...@anthropic.com>
---
 superset/databases/api.py     | 60 +++++++++++++++++++++++++++++++++++++------
 superset/databases/schemas.py | 10 ++++++++
 2 files changed, 62 insertions(+), 8 deletions(-)

diff --git a/superset/databases/api.py b/superset/databases/api.py
index b0f29c5247..f15391a6df 100644
--- a/superset/databases/api.py
+++ b/superset/databases/api.py
@@ -317,6 +317,32 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
         "changed_by": [["id", BaseFilterRelatedUsers, lambda: []]],
     }
 
+    @staticmethod
+    def _get_default_schema(
+        database: Database,
+        catalog: str | None,
+        accessible_schemas: set[str],
+        pk: int,
+    ) -> str | None:
+        """
+        Get the default schema for a database/catalog, with error handling.
+
+        Returns None if the default cannot be determined or is not accessible.
+        """
+        try:
+            default_schema = database.get_default_schema(catalog)
+            # Only include if user has access to it
+            if default_schema and default_schema not in accessible_schemas:
+                return None
+            return default_schema
+        except Exception:  # pylint: disable=broad-except
+            logger.debug(
+                "Could not get default schema for database %s, catalog %s",
+                pk,
+                catalog,
+            )
+            return None
+
     @expose("/<int:pk>/connection", methods=("GET",))
     @protect()
     @safe
@@ -726,7 +752,18 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
                 database,
                 catalogs,
             )
-            return self.response(200, result=list(catalogs))
+
+            # Get default catalog with error handling
+            default_catalog = None
+            try:
+                default_catalog = database.get_default_catalog()
+                # Only include if user has access to it
+                if default_catalog and default_catalog not in catalogs:
+                    default_catalog = None
+            except Exception:  # pylint: disable=broad-except
+                logger.debug("Could not get default catalog for database %s", pk)
+
+            return self.response(200, result=list(catalogs), default=default_catalog)
         except OperationalError:
             return self.response(
                 500,
@@ -795,23 +832,30 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
                 catalog,
                 schemas,
             )
+            default_schema = self._get_default_schema(database, catalog, schemas, pk)
+
             if params.get("upload_allowed"):
                 if not database.allow_file_upload:
-                    return self.response(200, result=[])
+                    return self.response(200, result=[], default=None)
                 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
                     # could be lowercase
                     allowed_schemas = {schema.lower() for schema in allowed_schemas}
+                    filtered_schemas = [
+                        schema
+                        for schema in schemas
+                        if schema.lower() in allowed_schemas
+                    ]
+                    # Check if default is in filtered list
+                    if default_schema and default_schema.lower() not in allowed_schemas:
+                        default_schema = None
                     return self.response(
                         200,
-                        result=[
-                            schema
-                            for schema in schemas
-                            if schema.lower() in allowed_schemas
-                        ],
+                        result=filtered_schemas,
+                        default=default_schema,
                     )
-            return self.response(200, result=list(schemas))
+            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"
diff --git a/superset/databases/schemas.py b/superset/databases/schemas.py
index 105496efa4..5ca999b15b 100644
--- a/superset/databases/schemas.py
+++ b/superset/databases/schemas.py
@@ -742,12 +742,22 @@ class SchemasResponseSchema(Schema):
     result = fields.List(
         fields.String(metadata={"description": "A database schema name"})
     )
+    default = fields.String(
+        allow_none=True,
+        load_default=None,
+        metadata={"description": "The default schema for this database/catalog"},
+    )
 
 
 class CatalogsResponseSchema(Schema):
     result = fields.List(
         fields.String(metadata={"description": "A database catalog name"})
     )
+    default = fields.String(
+        allow_none=True,
+        load_default=None,
+        metadata={"description": "The default catalog for this database"},
+    )
 
 
 class DatabaseTablesResponse(Schema):


(superset) 06/06: Small fix

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

beto pushed a commit to branch default-db-schema-dropdown
in repository https://gitbox.apache.org/repos/asf/superset.git

commit ff17daa4246f7569ec3f7b64cf10e84f336896ff
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Thu Dec 18 19:10:21 2025 -0500

    Small fix
---
 .../DatabaseSelector/DatabaseSelector.test.tsx     |   5 +-
 .../src/components/DatabaseSelector/index.tsx      | 129 ++++++++++++---------
 2 files changed, 80 insertions(+), 54 deletions(-)

diff --git a/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx b/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx
index 8d3d8bd8b0..fb6a5275cc 100644
--- a/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx
+++ b/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx
@@ -371,10 +371,11 @@ test('Sends the correct schema when changing the schema', async () => {
   });
   await waitFor(() => expect(fetchMock.calls(databaseApiRoute).length).toBe(1));
   rerender(<DatabaseSelector {...props} />);
-  expect(props.onSchemaChange).toHaveBeenCalledTimes(0);
-  const select = screen.getByRole('combobox', {
+  // Wait for schema data to load
+  const select = await screen.findByRole('combobox', {
     name: 'Select schema or type to search schemas: public',
   });
+  expect(props.onSchemaChange).toHaveBeenCalledTimes(0);
   expect(select).toBeInTheDocument();
   await userEvent.click(select);
   const schemaOption = await screen.findByText('information_schema');
diff --git a/superset-frontend/src/components/DatabaseSelector/index.tsx b/superset-frontend/src/components/DatabaseSelector/index.tsx
index 7d110b4215..068d9f5033 100644
--- a/superset-frontend/src/components/DatabaseSelector/index.tsx
+++ b/superset-frontend/src/components/DatabaseSelector/index.tsx
@@ -246,33 +246,11 @@ export function DatabaseSelector({
     data: schemaData,
     isFetching: loadingSchemas,
     refetch: refetchSchemas,
+    defaultSchema,
   } = useSchemas({
     dbId: currentDb?.value,
     catalog: currentCatalog?.value,
-    onSuccess: (schemas, isFetched, defaultSchema) => {
-      setErrorPayload(null);
-      if (schemas.length === 1) {
-        changeSchema(schemas[0]);
-      } else if (
-        !schemas.find(schemaOption => schemaRef.current === schemaOption.value)
-      ) {
-        // Current selection not in list - try to apply default on first load
-        if (
-          defaultSchema &&
-          appliedSchemaDefaultRef.current !== defaultSchema
-        ) {
-          const defaultOption = schemas.find(s => s.value === defaultSchema);
-          if (defaultOption) {
-            appliedSchemaDefaultRef.current = defaultSchema;
-            changeSchema(defaultOption);
-          } else {
-            changeSchema(undefined);
-          }
-        } else {
-          changeSchema(undefined);
-        }
-      }
-
+    onSuccess: (schemas, isFetched) => {
       if (isFetched) {
         addSuccessToast('List refreshed');
       }
@@ -288,6 +266,36 @@ export function DatabaseSelector({
 
   const schemaOptions = schemaData || EMPTY_SCHEMA_OPTIONS;
 
+  // Handle schema auto-selection when data changes
+  useEffect(() => {
+    if (!schemaData || loadingSchemas) return;
+
+    setErrorPayload(null);
+
+    if (schemaData.length === 1) {
+      changeSchema(schemaData[0]);
+    } else if (
+      !schemaData.find(schemaOption => schemaRef.current === schemaOption.value)
+    ) {
+      // Current selection not in list - try to apply default on first load
+      if (
+        defaultSchema &&
+        appliedSchemaDefaultRef.current !== defaultSchema
+      ) {
+        const defaultOption = schemaData.find(s => s.value === defaultSchema);
+        if (defaultOption) {
+          appliedSchemaDefaultRef.current = defaultSchema;
+          changeSchema(defaultOption);
+        } else {
+          changeSchema(undefined);
+        }
+      } else {
+        changeSchema(undefined);
+      }
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [schemaData, defaultSchema, loadingSchemas]);
+
   function changeCatalog(catalog: CatalogOption | null | undefined) {
     setCurrentCatalog(catalog);
     setCurrentSchema(undefined);
@@ -302,36 +310,10 @@ export function DatabaseSelector({
     data: catalogData,
     isFetching: loadingCatalogs,
     refetch: refetchCatalogs,
+    defaultCatalog,
   } = useCatalogs({
     dbId: showCatalogSelector ? currentDb?.value : undefined,
-    onSuccess: (catalogs, isFetched, defaultCatalog) => {
-      setErrorPayload(null);
-      if (!showCatalogSelector) {
-        changeCatalog(null);
-      } else if (catalogs.length === 1) {
-        changeCatalog(catalogs[0]);
-      } else if (
-        !catalogs.find(
-          catalogOption => catalogRef.current === catalogOption.value,
-        )
-      ) {
-        // Current selection not in list - try to apply default on first load
-        if (
-          defaultCatalog &&
-          appliedCatalogDefaultRef.current !== defaultCatalog
-        ) {
-          const defaultOption = catalogs.find(c => c.value === defaultCatalog);
-          if (defaultOption) {
-            appliedCatalogDefaultRef.current = defaultCatalog;
-            changeCatalog(defaultOption);
-          } else {
-            changeCatalog(undefined);
-          }
-        } else {
-          changeCatalog(undefined);
-        }
-      }
-
+    onSuccess: (catalogs, isFetched) => {
       if (showCatalogSelector && isFetched) {
         addSuccessToast('List refreshed');
       }
@@ -349,6 +331,49 @@ export function DatabaseSelector({
 
   const catalogOptions = catalogData || EMPTY_CATALOG_OPTIONS;
 
+  // Handle catalog auto-selection when data changes
+  useEffect(() => {
+    if (loadingCatalogs) return;
+
+    setErrorPayload(null);
+
+    if (!showCatalogSelector) {
+      // Only clear catalog if it's not already null
+      if (currentCatalog !== null) {
+        setCurrentCatalog(null);
+        if (onCatalogChange && catalogRef.current !== null) {
+          onCatalogChange(null);
+        }
+      }
+    } else if (catalogData && catalogData.length === 1) {
+      changeCatalog(catalogData[0]);
+    } else if (
+      catalogData &&
+      !catalogData.find(
+        catalogOption => catalogRef.current === catalogOption.value,
+      )
+    ) {
+      // Current selection not in list - try to apply default on first load
+      if (
+        defaultCatalog &&
+        appliedCatalogDefaultRef.current !== defaultCatalog
+      ) {
+        const defaultOption = catalogData.find(
+          c => c.value === defaultCatalog,
+        );
+        if (defaultOption) {
+          appliedCatalogDefaultRef.current = defaultCatalog;
+          changeCatalog(defaultOption);
+        } else {
+          changeCatalog(undefined);
+        }
+      } else {
+        changeCatalog(undefined);
+      }
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [catalogData, defaultCatalog, loadingCatalogs, showCatalogSelector]);
+
   function changeDatabase(
     value: { label: string; value: number },
     database: DatabaseValue,


(superset) 03/06: feat(frontend): extract default catalog/schema from API responses

Posted by be...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

beto pushed a commit to branch default-db-schema-dropdown
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 1910a5c6073b362e71e3874d710cb375f1afa0ff
Author: Beto Dealmeida <ro...@dealmeida.net>
AuthorDate: Thu Dec 18 18:15:44 2025 -0500

    feat(frontend): extract default catalog/schema from API responses
    
    Update useCatalogs and useSchemas hooks to:
    - Parse the new `default` field from API responses
    - Expose `defaultCatalog` and `defaultSchema` from the hooks
    - Maintain backwards compatibility with the existing API
    
    The internal response types are updated to include both the list of
    options and the default value, while the external hook interface
    continues to return the options as `data` for compatibility.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    Co-Authored-By: Claude Opus 4.5 <no...@anthropic.com>
---
 superset-frontend/src/hooks/apiResources/catalogs.ts | 18 ++++++++++++++----
 superset-frontend/src/hooks/apiResources/schemas.ts  | 18 ++++++++++++++----
 2 files changed, 28 insertions(+), 8 deletions(-)

diff --git a/superset-frontend/src/hooks/apiResources/catalogs.ts b/superset-frontend/src/hooks/apiResources/catalogs.ts
index 26f56b1dd6..636b5d92ec 100644
--- a/superset-frontend/src/hooks/apiResources/catalogs.ts
+++ b/superset-frontend/src/hooks/apiResources/catalogs.ts
@@ -36,21 +36,29 @@ export type FetchCatalogsQueryParams = {
 
 type Params = Omit<FetchCatalogsQueryParams, 'forceRefresh'>;
 
+// Internal type for transformed API response
+type CatalogsApiResponse = {
+  catalogs: CatalogOption[];
+  defaultCatalog: string | null;
+};
+
 const catalogApi = api.injectEndpoints({
   endpoints: builder => ({
-    catalogs: builder.query<CatalogOption[], FetchCatalogsQueryParams>({
+    catalogs: builder.query<CatalogsApiResponse, 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) => ({
+          catalogs: json.result.sort().map((value: string) => ({
             value,
             label: value,
             title: value,
           })),
+          defaultCatalog: json.default ?? null,
+        }),
       }),
       serializeQueryArgs: ({ queryArgs: { dbId } }) => ({
         dbId,
@@ -89,7 +97,7 @@ 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?.catalogs || EMPTY_CATALOGS, forceRefresh);
           }
           if (isError) {
             onError?.(result.error as ClientErrorObject);
@@ -110,5 +118,7 @@ export function useCatalogs(options: Params) {
   return {
     ...result,
     refetch,
+    data: result.data?.catalogs,
+    defaultCatalog: result.data?.defaultCatalog ?? null,
   };
 }
diff --git a/superset-frontend/src/hooks/apiResources/schemas.ts b/superset-frontend/src/hooks/apiResources/schemas.ts
index 4439b894cf..41a729c118 100644
--- a/superset-frontend/src/hooks/apiResources/schemas.ts
+++ b/superset-frontend/src/hooks/apiResources/schemas.ts
@@ -37,9 +37,15 @@ export type FetchSchemasQueryParams = {
 
 type Params = Omit<FetchSchemasQueryParams, 'forceRefresh'>;
 
+// Internal type for transformed API response
+type SchemasApiResponse = {
+  schemas: SchemaOption[];
+  defaultSchema: string | null;
+};
+
 const schemaApi = api.injectEndpoints({
   endpoints: builder => ({
-    schemas: builder.query<SchemaOption[], FetchSchemasQueryParams>({
+    schemas: builder.query<SchemasApiResponse, FetchSchemasQueryParams>({
       providesTags: [{ type: 'Schemas', id: 'LIST' }],
       query: ({ dbId, catalog, forceRefresh }) => ({
         endpoint: `/api/v1/database/${dbId}/schemas/`,
@@ -48,12 +54,14 @@ const schemaApi = api.injectEndpoints({
           force: forceRefresh,
           ...(catalog !== undefined && { catalog }),
         },
-        transformResponse: ({ json }: JsonResponse) =>
-          json.result.sort().map((value: string) => ({
+        transformResponse: ({ json }: JsonResponse) => ({
+          schemas: json.result.sort().map((value: string) => ({
             value,
             label: value,
             title: value,
           })),
+          defaultSchema: json.default ?? null,
+        }),
       }),
       serializeQueryArgs: ({ queryArgs: { dbId, catalog } }) => ({
         dbId,
@@ -98,7 +106,7 @@ export function useSchemas(options: Params) {
         trigger({ dbId, catalog, forceRefresh }).then(
           ({ isSuccess, isError, data }) => {
             if (isSuccess) {
-              onSuccess?.(data || EMPTY_SCHEMAS, forceRefresh);
+              onSuccess?.(data?.schemas || EMPTY_SCHEMAS, forceRefresh);
             }
             if (isError) {
               onError?.(result.error as ClientErrorObject);
@@ -120,5 +128,7 @@ export function useSchemas(options: Params) {
   return {
     ...result,
     refetch,
+    data: result.currentData?.schemas,
+    defaultSchema: result.currentData?.defaultSchema ?? null,
   };
 }