diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx index 910f87d46b6..5702364ca24 100644 --- a/superset-frontend/src/pages/DatasetList/index.tsx +++ b/superset-frontend/src/pages/DatasetList/index.tsx @@ -190,13 +190,75 @@ const DatasetList: FunctionComponent = ({ useState(null); const [currentSourceFilter, setCurrentSourceFilter] = useState(''); + /** + * Fetches "Data connection" filter options — a combined list of databases + * and semantic layers. + * + * Semantic layer values are prefixed with "sl:" so that fetchData can tell + * them apart from integer database IDs and route to the correct API filter. + */ + const fetchConnectionOptions = useCallback( + async (filterValue = '', page: number, pageSize: number) => { + const showDatabases = currentSourceFilter !== 'semantic_layer'; + const showSemanticLayers = + isFeatureEnabled(FeatureFlag.SemanticLayers) && + currentSourceFilter !== 'database'; + + const [dbResult, slResult] = await Promise.all([ + showDatabases + ? createFetchRelated( + 'dataset', + 'database', + createErrorHandler(errMsg => + t( + 'An error occurred while fetching %s: %s', + datasetsLabelLower(), + errMsg, + ), + ), + )(filterValue, page, pageSize) + : Promise.resolve({ data: [], totalCount: 0 }), + showSemanticLayers + ? SupersetClient.get({ + endpoint: `/api/v1/semantic_layer/?q=${rison.encode_uri({ + ...(filterValue + ? { filters: [{ col: 'name', opr: 'ct', value: filterValue }] } + : {}), + page: 0, + page_size: 100, + })}`, + }) + .then(({ json = {} }) => ({ + data: (json?.result ?? []).map( + (layer: { uuid: string; name: string }) => ({ + label: layer.name, + // "sl:" prefix distinguishes semantic layers from DB integer IDs + value: `sl:${layer.uuid}`, + }), + ), + totalCount: json?.count ?? 0, + })) + .catch(() => ({ data: [], totalCount: 0 })) + : Promise.resolve({ data: [], totalCount: 0 }), + ]); + + return { + // Semantic layers first, then databases + data: [...slResult.data, ...dbResult.data], + totalCount: slResult.totalCount + dbResult.totalCount, + }; + }, + [currentSourceFilter], + ); + const fetchData = useCallback((config: ListViewFetchDataConfig) => { setLastFetchConfig(config); setLoading(true); const { pageIndex, pageSize, sortBy, filters: filterValues } = config; - // Separate source_type filter from other filters + // Separate source_type and database/connection filters for special handling const sourceTypeFilter = filterValues.find(f => f.id === 'source_type'); + const databaseFilter = filterValues.find(f => f.id === 'database'); // Track source filter for conditional Type filter visibility const sourceVal = @@ -204,8 +266,9 @@ const DatasetList: FunctionComponent = ({ ? (sourceTypeFilter.value as { value: string }).value : ((sourceTypeFilter?.value as string) ?? ''); setCurrentSourceFilter(sourceVal); + const otherFilters = filterValues - .filter(f => f.id !== 'source_type') + .filter(f => f.id !== 'source_type' && f.id !== 'database') .filter( ({ value }) => value !== '' && value !== null && value !== undefined, ) @@ -231,6 +294,30 @@ const DatasetList: FunctionComponent = ({ }); } + // Translate the "Data connection" filter: values prefixed with "sl:" are + // semantic layer UUIDs; plain values are database IDs. + if (databaseFilter?.value !== undefined && databaseFilter.value !== '') { + const raw = + databaseFilter.value && + typeof databaseFilter.value === 'object' && + 'value' in databaseFilter.value + ? (databaseFilter.value as { value: unknown }).value + : databaseFilter.value; + if (typeof raw === 'string' && raw.startsWith('sl:')) { + otherFilters.push({ + col: 'semantic_layer_uuid', + opr: 'eq', + value: raw.slice(3), + }); + } else if (raw !== null && raw !== undefined && raw !== '') { + otherFilters.push({ + col: 'database', + opr: databaseFilter.operator, + value: raw, + }); + } + } + const queryParams = rison.encode_uri({ order_column: sortBy[0].id, order_direction: sortBy[0].desc ? 'desc' : 'asc', @@ -744,7 +831,7 @@ const DatasetList: FunctionComponent = ({ operator: FilterOperator.Equals, unfilteredLabel: t('All'), selects: [ - { label: databaseLabel(), value: 'database' }, + { label: t('Database'), value: 'database' }, { label: t('Semantic Layer'), value: 'semantic_layer' }, ], }, @@ -797,20 +884,10 @@ const DatasetList: FunctionComponent = ({ Header: databaseLabel(), key: 'database', id: 'database', - input: 'select', + input: 'select' as const, operator: FilterOperator.RelationOneMany, unfilteredLabel: 'All', - fetchSelects: createFetchRelated( - 'dataset', - 'database', - createErrorHandler(errMsg => - t( - 'An error occurred while fetching %s: %s', - datasetsLabelLower(), - errMsg, - ), - ), - ), + fetchSelects: fetchConnectionOptions, paginate: true, dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH }, }, diff --git a/superset/commands/datasource/list.py b/superset/commands/datasource/list.py index caec143994d..db98c06c3c6 100644 --- a/superset/commands/datasource/list.py +++ b/superset/commands/datasource/list.py @@ -62,14 +62,32 @@ class GetCombinedDatasourceListCommand(BaseCommand): order_direction = self._args.get("order_direction", "desc") filters = self._args.get("filters", []) - source_type, name_filter, sql_filter, type_filter = self._parse_filters(filters) + ( + source_type, + name_filter, + sql_filter, + type_filter, + database_id, + semantic_layer_uuid, + ) = self._parse_filters(filters) + + # A connection filter implicitly narrows the source type: selecting a + # database ID means "show only datasets", and selecting a semantic layer + # UUID means "show only semantic views". Only apply the implicit + # narrowing when the user hasn't already set an explicit source_type. + if source_type == "all": + if database_id is not None: + source_type = "database" + elif semantic_layer_uuid is not None: + source_type = "semantic_layer" + source_type = self._resolve_source_type(source_type, sql_filter, type_filter) if source_type == "empty": return {"count": 0, "result": []} - ds_q = DatasourceDAO.build_dataset_query(name_filter, sql_filter) - sv_q = DatasourceDAO.build_semantic_view_query(name_filter) + ds_q = DatasourceDAO.build_dataset_query(name_filter, sql_filter, database_id) + sv_q = DatasourceDAO.build_semantic_view_query(name_filter, semantic_layer_uuid) if source_type == "database": combined = ds_q.subquery() @@ -126,10 +144,15 @@ class GetCombinedDatasourceListCommand(BaseCommand): return "database" if not self._can_read_datasets: return "semantic_layer" + # An explicit source_type selection ("database" or "semantic_layer") always + # wins. This prevents e.g. Type="Semantic View" from overriding an explicit + # Source="Database" filter and showing inconsistent results. + if source_type in ("database", "semantic_layer"): + return source_type # sql_filter (physical/virtual toggle) only applies to datasets if sql_filter is not None: return "database" - # Explicit semantic-view type filter + # Explicit semantic-view type filter (only reached when source_type="all") if type_filter == "semantic_view": return "semantic_layer" return source_type @@ -137,20 +160,24 @@ class GetCombinedDatasourceListCommand(BaseCommand): @staticmethod def _parse_filters( filters: list[dict[str, Any]], - ) -> tuple[str, str | None, bool | None, str | None]: + ) -> tuple[str, str | None, bool | None, str | None, int | None, str | None]: """ Translate raw rison filter dicts into typed query parameters. Returns: - source_type: "all" | "database" | "semantic_layer" - name_filter: substring to match against name/table_name - sql_filter: True → physical only, False → virtual only, None → both - type_filter: "semantic_view" when the caller wants only semantic views + source_type: "all" | "database" | "semantic_layer" + name_filter: substring to match against name/table_name + sql_filter: True → physical only, False → virtual only, None → both + type_filter: "semantic_view" when the caller wants only semantic views + database_id: filter datasets to a specific database ID + semantic_layer_uuid: filter semantic views to a specific semantic layer UUID """ source_type = "all" name_filter: str | None = None sql_filter: bool | None = None type_filter: str | None = None + database_id: int | None = None + semantic_layer_uuid: str | None = None for f in filters: col = f.get("col") @@ -165,5 +192,19 @@ class GetCombinedDatasourceListCommand(BaseCommand): type_filter = "semantic_view" else: sql_filter = value + elif col == "database" and value is not None: + try: + database_id = int(value) + except (TypeError, ValueError): + pass + elif col == "semantic_layer_uuid" and value is not None: + semantic_layer_uuid = str(value) - return source_type, name_filter, sql_filter, type_filter + return ( + source_type, + name_filter, + sql_filter, + type_filter, + database_id, + semantic_layer_uuid, + ) diff --git a/superset/daos/datasource.py b/superset/daos/datasource.py index 3d347332ce7..e5e6fdbc647 100644 --- a/superset/daos/datasource.py +++ b/superset/daos/datasource.py @@ -91,6 +91,7 @@ class DatasourceDAO(BaseDAO[Datasource]): def build_dataset_query( name_filter: str | None, sql_filter: bool | None, + database_id: int | None = None, ) -> Select: """Build a SELECT for datasets, applying access and content filters.""" ds_q = select( @@ -116,11 +117,17 @@ class DatasourceDAO(BaseDAO[Datasource]): else: ds_q = ds_q.where(and_(SqlaTable.sql.isnot(None), SqlaTable.sql != "")) + if database_id is not None: + ds_q = ds_q.where(SqlaTable.database_id == database_id) + return ds_q @staticmethod - def build_semantic_view_query(name_filter: str | None) -> Select: - """Build a SELECT for semantic views, applying name filter.""" + def build_semantic_view_query( + name_filter: str | None, + semantic_layer_uuid: str | None = None, + ) -> Select: + """Build a SELECT for semantic views, applying name and layer filters.""" sv_q = select( SemanticView.id.label("item_id"), literal("semantic_layer").label("source_type"), @@ -131,6 +138,9 @@ class DatasourceDAO(BaseDAO[Datasource]): if name_filter: sv_q = sv_q.where(SemanticView.name.ilike(f"%{name_filter}%")) + if semantic_layer_uuid is not None: + sv_q = sv_q.where(SemanticView.semantic_layer_uuid == semantic_layer_uuid) + return sv_q @staticmethod