This is an automated email from the ASF dual-hosted git repository. beto pushed a commit to branch semantic-layer-ui-semantic-layer in repository https://gitbox.apache.org/repos/asf/superset.git
commit d04a25323e3b2ddcf8478551837731687e61299f Author: Beto Dealmeida <[email protected]> AuthorDate: Tue Feb 17 11:06:37 2026 -0500 CRUD working --- .../features/semanticLayers/SemanticLayerModal.tsx | 88 ++++- superset-frontend/src/pages/DatabaseList/index.tsx | 381 ++++++++++++++++----- superset/commands/semantic_layer/create.py | 5 + superset/commands/semantic_layer/update.py | 5 + superset/semantic_layers/api.py | 169 ++++++++- 5 files changed, 550 insertions(+), 98 deletions(-) diff --git a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx index fbeb984ffa9..37694a9e9a6 100644 --- a/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx +++ b/superset-frontend/src/features/semanticLayers/SemanticLayerModal.tsx @@ -303,6 +303,7 @@ interface SemanticLayerModalProps { onHide: () => void; addDangerToast: (msg: string) => void; addSuccessToast: (msg: string) => void; + semanticLayerUuid?: string; } export default function SemanticLayerModal({ @@ -310,7 +311,9 @@ export default function SemanticLayerModal({ onHide, addDangerToast, addSuccessToast, + semanticLayerUuid, }: SemanticLayerModalProps) { + const isEditMode = !!semanticLayerUuid; const [step, setStep] = useState<Step>('type'); const [name, setName] = useState(''); const [selectedType, setSelectedType] = useState<string | null>(null); @@ -380,9 +383,47 @@ export default function SemanticLayerModal({ [addDangerToast, applySchema], ); + const fetchExistingLayer = useCallback( + async (uuid: string) => { + setLoading(true); + try { + const { json } = await SupersetClient.get({ + endpoint: `/api/v1/semantic_layer/${uuid}`, + }); + const layer = json.result; + setName(layer.name ?? ''); + setSelectedType(layer.type); + setFormData(layer.configuration ?? {}); + setHasErrors(false); + // Fetch base schema (no configuration → no Snowflake connection) to + // show the form immediately. The existing maybeRefreshSchema machinery + // will trigger an enriched fetch in the background once deps are + // satisfied, and DynamicFieldControl will show per-field spinners. + const { json: schemaJson } = await SupersetClient.post({ + endpoint: '/api/v1/semantic_layer/schema/configuration', + jsonPayload: { type: layer.type }, + }); + applySchema(schemaJson.result); + setStep('config'); + } catch { + addDangerToast( + t('An error occurred while fetching the semantic layer'), + ); + } finally { + setLoading(false); + } + }, + [addDangerToast, applySchema], + ); + useEffect(() => { if (show) { - fetchTypes(); + if (isEditMode && semanticLayerUuid) { + fetchTypes(); + fetchExistingLayer(semanticLayerUuid); + } else { + fetchTypes(); + } } else { setStep('type'); setName(''); @@ -399,7 +440,7 @@ export default function SemanticLayerModal({ dynamicDepsRef.current = {}; if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); } - }, [show, fetchTypes]); + }, [show, fetchTypes, isEditMode, semanticLayerUuid, fetchExistingLayer]); const handleStepAdvance = () => { if (selectedType) { @@ -422,14 +463,26 @@ export default function SemanticLayerModal({ const handleCreate = async () => { setSaving(true); try { - await SupersetClient.post({ - endpoint: '/api/v1/semantic_layer/', - jsonPayload: { name, type: selectedType, configuration: formData }, - }); - addSuccessToast(t('Semantic layer created')); + if (isEditMode && semanticLayerUuid) { + await SupersetClient.put({ + endpoint: `/api/v1/semantic_layer/${semanticLayerUuid}`, + jsonPayload: { name, configuration: formData }, + }); + addSuccessToast(t('Semantic layer updated')); + } else { + await SupersetClient.post({ + endpoint: '/api/v1/semantic_layer/', + jsonPayload: { name, type: selectedType, configuration: formData }, + }); + addSuccessToast(t('Semantic layer created')); + } onHide(); } catch { - addDangerToast(t('An error occurred while creating the semantic layer')); + addDangerToast( + isEditMode + ? t('An error occurred while updating the semantic layer') + : t('An error occurred while creating the semantic layer'), + ); } finally { setSaving(false); } @@ -491,8 +544,9 @@ export default function SemanticLayerModal({ const selectedTypeName = types.find(type => type.id === selectedType)?.name ?? ''; - const title = - step === 'type' + const title = isEditMode + ? t('Edit %s', selectedTypeName || t('Semantic Layer')) + : step === 'type' ? t('New Semantic Layer') : t('Configure %s', selectedTypeName); @@ -502,12 +556,12 @@ export default function SemanticLayerModal({ onHide={onHide} onSave={handleSave} title={title} - icon={<Icons.PlusOutlined />} + icon={isEditMode ? <Icons.EditOutlined /> : <Icons.PlusOutlined />} width={step === 'type' ? MODAL_STANDARD_WIDTH : MODAL_MEDIUM_WIDTH} saveDisabled={ step === 'type' ? !selectedType : saving || !name.trim() || hasErrors } - saveText={step === 'type' ? undefined : t('Create')} + saveText={step === 'type' ? undefined : isEditMode ? t('Save') : t('Create')} saveLoading={saving} contentLoading={loading} > @@ -534,10 +588,12 @@ export default function SemanticLayerModal({ </ModalContent> ) : ( <ModalContent> - <BackLink type="button" onClick={handleBack}> - <Icons.CaretLeftOutlined iconSize="s" /> - {t('Back')} - </BackLink> + {!isEditMode && ( + <BackLink type="button" onClick={handleBack}> + <Icons.CaretLeftOutlined iconSize="s" /> + {t('Back')} + </BackLink> + )} <ModalFormField label={t('Name')} required> <Input value={name} diff --git a/superset-frontend/src/pages/DatabaseList/index.tsx b/superset-frontend/src/pages/DatabaseList/index.tsx index c45e3d3f3d2..99cc90a5a6d 100644 --- a/superset-frontend/src/pages/DatabaseList/index.tsx +++ b/superset-frontend/src/pages/DatabaseList/index.tsx @@ -50,6 +50,7 @@ import { ListView, ListViewFilterOperator as FilterOperator, ListViewFilters, + type ListViewFetchDataConfig, } from 'src/components'; import { Typography } from '@superset-ui/core/components/Typography'; import { getUrlParam } from 'src/utils/urlUtils'; @@ -78,6 +79,11 @@ const dbConfigExtraExtension = extensionsRegistry.get( const PAGE_SIZE = 25; +type ConnectionItem = DatabaseObject & { + source_type?: 'database' | 'semantic_layer'; + sl_type?: string; +}; + interface DatabaseDeleteObject extends DatabaseObject { charts: any; dashboards: any; @@ -117,20 +123,105 @@ function DatabaseList({ user, }: DatabaseListProps) { const theme = useTheme(); + const showSemanticLayers = isFeatureEnabled(FeatureFlag.SemanticLayers); + + // Standard database list view resource (used when SL flag is OFF) const { state: { - loading, - resourceCount: databaseCount, - resourceCollection: databases, + loading: dbLoading, + resourceCount: dbCount, + resourceCollection: dbCollection, }, hasPerm, - fetchData, - refreshData, + fetchData: dbFetchData, + refreshData: dbRefreshData, } = useListViewResource<DatabaseObject>( 'database', t('database'), addDangerToast, ); + + // Combined endpoint state (used when SL flag is ON) + const [combinedItems, setCombinedItems] = useState<ConnectionItem[]>([user]); + const [combinedCount, setCombinedCount] = useState(0); + const [combinedLoading, setCombinedLoading] = useState(true); + const [lastFetchConfig, setLastFetchConfig] = + useState<ListViewFetchDataConfig | null>(null); + + const combinedFetchData = useCallback( + (config: ListViewFetchDataConfig) => { + setLastFetchConfig(config); + setCombinedLoading(true); + const { pageIndex, pageSize, sortBy, filters: filterValues } = config; + + const sourceTypeFilter = filterValues.find(f => f.id === 'source_type'); + const otherFilters = filterValues + .filter(f => f.id !== 'source_type') + .filter( + ({ value }) => value !== '' && value !== null && value !== undefined, + ) + .map(({ id, operator: opr, value }) => ({ + col: id, + opr, + value: + value && typeof value === 'object' && 'value' in value + ? value.value + : value, + })); + + const sourceTypeValue = + sourceTypeFilter?.value && typeof sourceTypeFilter.value === 'object' + ? (sourceTypeFilter.value as { value: string }).value + : sourceTypeFilter?.value; + if (sourceTypeValue) { + otherFilters.push({ + col: 'source_type', + opr: 'eq', + value: sourceTypeValue, + }); + } + + const queryParams = rison.encode_uri({ + order_column: sortBy[0].id, + order_direction: sortBy[0].desc ? 'desc' : 'asc', + page: pageIndex, + page_size: pageSize, + ...(otherFilters.length ? { filters: otherFilters } : {}), + }); + + return SupersetClient.get({ + endpoint: `/api/v1/semantic_layer/connections/?q=${queryParams}`, + }) + .then(({ json = {} }) => { + setCombinedItems(json.result); + setCombinedCount(json.count); + }) + .catch(() => { + addDangerToast(t('An error occurred while fetching connections')); + }) + .finally(() => { + setCombinedLoading(false); + }); + }, + [addDangerToast], + ); + + const combinedRefreshData = useCallback(() => { + if (lastFetchConfig) { + return combinedFetchData(lastFetchConfig); + } + return undefined; + }, [lastFetchConfig, combinedFetchData]); + + // Select the right data source based on feature flag + const loading = showSemanticLayers ? combinedLoading : dbLoading; + const databaseCount = showSemanticLayers ? combinedCount : dbCount; + const databases: ConnectionItem[] = showSemanticLayers + ? combinedItems + : dbCollection; + const fetchData = showSemanticLayers ? combinedFetchData : dbFetchData; + const refreshData = showSemanticLayers ? combinedRefreshData : dbRefreshData; + const fullUser = useSelector<any, UserWithPermissionsAndRoles>( state => state.user, ); @@ -159,6 +250,11 @@ function DatabaseList({ useState<boolean>(false); const [semanticLayerModalOpen, setSemanticLayerModalOpen] = useState<boolean>(false); + const [slCurrentlyEditing, setSlCurrentlyEditing] = useState<string | null>( + null, + ); + const [slCurrentlyDeleting, setSlCurrentlyDeleting] = + useState<ConnectionItem | null>(null); const [allowUploads, setAllowUploads] = useState<boolean>(false); const isAdmin = isUserAdmin(fullUser); @@ -457,6 +553,23 @@ function DatabaseList({ const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }]; + function handleSemanticLayerDelete(item: ConnectionItem) { + SupersetClient.delete({ + endpoint: `/api/v1/semantic_layer/${item.uuid}`, + }).then( + () => { + refreshData(); + addSuccessToast(t('Deleted: %s', item.database_name)); + setSlCurrentlyDeleting(null); + }, + createErrorHandler(errMsg => + addDangerToast( + t('There was an issue deleting %s: %s', item.database_name, errMsg), + ), + ), + ); + } + const columns = useMemo( () => [ { @@ -469,7 +582,7 @@ function DatabaseList({ accessor: 'backend', Header: t('Backend'), size: 'xl', - disableSortBy: true, // TODO: api support for sorting by 'backend' + disableSortBy: true, id: 'backend', }, { @@ -483,13 +596,12 @@ function DatabaseList({ <span>{t('AQE')}</span> </Tooltip> ), - Cell: ({ - row: { - original: { allow_run_async: allowRunAsync }, - }, - }: { - row: { original: { allow_run_async: boolean } }; - }) => <BooleanDisplay value={allowRunAsync} />, + Cell: ({ row: { original } }: any) => + original.source_type === 'semantic_layer' ? ( + <span>–</span> + ) : ( + <BooleanDisplay value={original.allow_run_async} /> + ), size: 'sm', id: 'allow_run_async', }, @@ -504,33 +616,36 @@ function DatabaseList({ <span>{t('DML')}</span> </Tooltip> ), - Cell: ({ - row: { - original: { allow_dml: allowDML }, - }, - }: any) => <BooleanDisplay value={allowDML} />, + Cell: ({ row: { original } }: any) => + original.source_type === 'semantic_layer' ? ( + <span>–</span> + ) : ( + <BooleanDisplay value={original.allow_dml} /> + ), size: 'sm', id: 'allow_dml', }, { accessor: 'allow_file_upload', Header: t('File upload'), - Cell: ({ - row: { - original: { allow_file_upload: allowFileUpload }, - }, - }: any) => <BooleanDisplay value={allowFileUpload} />, + Cell: ({ row: { original } }: any) => + original.source_type === 'semantic_layer' ? ( + <span>–</span> + ) : ( + <BooleanDisplay value={original.allow_file_upload} /> + ), size: 'md', id: 'allow_file_upload', }, { accessor: 'expose_in_sqllab', Header: t('Expose in SQL Lab'), - Cell: ({ - row: { - original: { expose_in_sqllab: exposeInSqllab }, - }, - }: any) => <BooleanDisplay value={exposeInSqllab} />, + Cell: ({ row: { original } }: any) => + original.source_type === 'semantic_layer' ? ( + <span>–</span> + ) : ( + <BooleanDisplay value={original.expose_in_sqllab} /> + ), size: 'md', id: 'expose_in_sqllab', }, @@ -550,6 +665,49 @@ function DatabaseList({ }, { Cell: ({ row: { original } }: any) => { + const isSemanticLayer = + original.source_type === 'semantic_layer'; + + if (isSemanticLayer) { + if (!canEdit && !canDelete) return null; + return ( + <Actions className="actions"> + {canDelete && ( + <Tooltip + id="delete-action-tooltip" + title={t('Delete')} + placement="bottom" + > + <span + role="button" + tabIndex={0} + className="action-button" + onClick={() => setSlCurrentlyDeleting(original)} + > + <Icons.DeleteOutlined iconSize="l" /> + </span> + </Tooltip> + )} + {canEdit && ( + <Tooltip + id="edit-action-tooltip" + title={t('Edit')} + placement="bottom" + > + <span + role="button" + tabIndex={0} + className="action-button" + onClick={() => setSlCurrentlyEditing(original.uuid)} + > + <Icons.EditOutlined iconSize="l" /> + </span> + </Tooltip> + )} + </Actions> + ); + } + const handleEdit = () => handleDatabaseEditModal({ database: original, modalOpen: true }); const handleDelete = () => openDatabaseDeleteModal(original); @@ -635,6 +793,12 @@ function DatabaseList({ hidden: !canEdit && !canDelete, disableSortBy: true, }, + { + accessor: 'source_type', + hidden: true, + disableSortBy: true, + id: 'source_type', + }, { accessor: QueryObjectColumns.ChangedBy, hidden: true, @@ -652,8 +816,8 @@ function DatabaseList({ ], ); - const filters: ListViewFilters = useMemo( - () => [ + const filters: ListViewFilters = useMemo(() => { + const baseFilters: ListViewFilters = [ { Header: t('Name'), key: 'search', @@ -661,62 +825,83 @@ function DatabaseList({ input: 'search', operator: FilterOperator.Contains, }, - { - Header: t('Expose in SQL Lab'), - key: 'expose_in_sql_lab', - id: 'expose_in_sqllab', - input: 'select', - operator: FilterOperator.Equals, - unfilteredLabel: t('All'), - selects: [ - { label: t('Yes'), value: true }, - { label: t('No'), value: false }, - ], - }, - { - Header: ( - <Tooltip - id="allow-run-async-filter-header-tooltip" - title={t('Asynchronous query execution')} - placement="top" - > - <span>{t('AQE')}</span> - </Tooltip> - ), - key: 'allow_run_async', - id: 'allow_run_async', + ]; + + if (showSemanticLayers) { + baseFilters.push({ + Header: t('Source'), + key: 'source_type', + id: 'source_type', input: 'select', operator: FilterOperator.Equals, unfilteredLabel: t('All'), selects: [ - { label: t('Yes'), value: true }, - { label: t('No'), value: false }, + { label: t('Database'), value: 'database' }, + { label: t('Semantic Layer'), value: 'semantic_layer' }, ], - }, - { - Header: t('Modified by'), - key: 'changed_by', - id: 'changed_by', - input: 'select', - operator: FilterOperator.RelationOneMany, - unfilteredLabel: t('All'), - fetchSelects: createFetchRelated( - 'database', - 'changed_by', - createErrorHandler(errMsg => - t( - 'An error occurred while fetching dataset datasource values: %s', - errMsg, + }); + } + + if (!showSemanticLayers) { + baseFilters.push( + { + Header: t('Expose in SQL Lab'), + key: 'expose_in_sql_lab', + id: 'expose_in_sqllab', + input: 'select', + operator: FilterOperator.Equals, + unfilteredLabel: t('All'), + selects: [ + { label: t('Yes'), value: true }, + { label: t('No'), value: false }, + ], + }, + { + Header: ( + <Tooltip + id="allow-run-async-filter-header-tooltip" + title={t('Asynchronous query execution')} + placement="top" + > + <span>{t('AQE')}</span> + </Tooltip> + ), + key: 'allow_run_async', + id: 'allow_run_async', + input: 'select', + operator: FilterOperator.Equals, + unfilteredLabel: t('All'), + selects: [ + { label: t('Yes'), value: true }, + { label: t('No'), value: false }, + ], + }, + { + Header: t('Modified by'), + key: 'changed_by', + id: 'changed_by', + input: 'select', + operator: FilterOperator.RelationOneMany, + unfilteredLabel: t('All'), + fetchSelects: createFetchRelated( + 'database', + 'changed_by', + createErrorHandler(errMsg => + t( + 'An error occurred while fetching dataset datasource values: %s', + errMsg, + ), ), + user, ), - user, - ), - paginate: true, - dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH }, - }, - ], - [user], - ); + paginate: true, + dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH }, + }, + ); + } + + return baseFilters; + }, [showSemanticLayers]); return ( <> @@ -761,10 +946,46 @@ function DatabaseList({ /> <SemanticLayerModal show={semanticLayerModalOpen} - onHide={() => setSemanticLayerModalOpen(false)} + onHide={() => { + setSemanticLayerModalOpen(false); + refreshData(); + }} addDangerToast={addDangerToast} addSuccessToast={addSuccessToast} /> + <SemanticLayerModal + show={!!slCurrentlyEditing} + onHide={() => { + setSlCurrentlyEditing(null); + refreshData(); + }} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + semanticLayerUuid={slCurrentlyEditing ?? undefined} + /> + {slCurrentlyDeleting && ( + <DeleteModal + description={ + <p> + {t('Are you sure you want to delete')}{' '} + <b>{slCurrentlyDeleting.database_name}</b>? + </p> + } + onConfirm={() => { + if (slCurrentlyDeleting) { + handleSemanticLayerDelete(slCurrentlyDeleting); + } + }} + onHide={() => setSlCurrentlyDeleting(null)} + open + title={ + <ModalTitleWithIcon + icon={<Icons.DeleteOutlined />} + title={t('Delete Semantic Layer?')} + /> + } + /> + )} {databaseCurrentlyDeleting && ( <DeleteModal description={ diff --git a/superset/commands/semantic_layer/create.py b/superset/commands/semantic_layer/create.py index b0778fe8084..250476d210f 100644 --- a/superset/commands/semantic_layer/create.py +++ b/superset/commands/semantic_layer/create.py @@ -16,6 +16,7 @@ # under the License. from __future__ import annotations +import json import logging from functools import partial from typing import Any @@ -48,6 +49,10 @@ class CreateSemanticLayerCommand(BaseCommand): ) def run(self) -> Model: self.validate() + if isinstance(self._properties.get("configuration"), dict): + self._properties["configuration"] = json.dumps( + self._properties["configuration"] + ) return SemanticLayerDAO.create(attributes=self._properties) def validate(self) -> None: diff --git a/superset/commands/semantic_layer/update.py b/superset/commands/semantic_layer/update.py index 5242406af8c..66c02d568ab 100644 --- a/superset/commands/semantic_layer/update.py +++ b/superset/commands/semantic_layer/update.py @@ -16,6 +16,7 @@ # under the License. from __future__ import annotations +import json import logging from functools import partial from typing import Any @@ -87,6 +88,10 @@ class UpdateSemanticLayerCommand(BaseCommand): def run(self) -> Model: self.validate() assert self._model + if isinstance(self._properties.get("configuration"), dict): + self._properties["configuration"] = json.dumps( + self._properties["configuration"] + ) return SemanticLayerDAO.update(self._model, attributes=self._properties) def validate(self) -> None: diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py index bb94a0e4b91..bed9dc996a5 100644 --- a/superset/semantic_layers/api.py +++ b/superset/semantic_layers/api.py @@ -21,12 +21,13 @@ import logging from typing import Any from flask import make_response, request, Response -from flask_appbuilder.api import expose, protect, safe +from flask_appbuilder.api import expose, protect, rison, safe +from flask_appbuilder.api.schemas import get_list_schema from flask_appbuilder.models.sqla.interface import SQLAInterface from marshmallow import ValidationError from pydantic import ValidationError as PydanticValidationError -from superset import event_logger +from superset import db, event_logger, is_feature_enabled from superset.commands.semantic_layer.create import CreateSemanticLayerCommand from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand from superset.commands.semantic_layer.exceptions import ( @@ -46,6 +47,7 @@ from superset.commands.semantic_layer.update import ( ) from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP from superset.daos.semantic_layer import SemanticLayerDAO +from superset.models.core import Database from superset.semantic_layers.models import SemanticLayer, SemanticView from superset.semantic_layers.registry import registry from superset.semantic_layers.schemas import ( @@ -65,12 +67,17 @@ logger = logging.getLogger(__name__) def _serialize_layer(layer: SemanticLayer) -> dict[str, Any]: + config = layer.configuration + if isinstance(config, str): + config = json.loads(config) return { "uuid": str(layer.uuid), "name": layer.name, "description": layer.description, "type": layer.type, "cache_timeout": layer.cache_timeout, + "configuration": config or {}, + "changed_on_delta_humanized": layer.changed_on_delta_humanized(), } @@ -515,6 +522,164 @@ class SemanticLayerRestApi(BaseSupersetApi): ) return self.response_422(message=str(ex)) + @expose("/connections/", methods=("GET",)) + @protect() + @safe + @statsd_metrics + @rison(get_list_schema) + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}" + ".connections", + log_to_statsd=False, + ) + def connections(self, **kwargs: Any) -> FlaskResponse: + """List databases and semantic layers combined. + --- + get: + summary: List databases and semantic layers combined + parameters: + - in: query + name: q + content: + application/json: + schema: + $ref: '#/components/schemas/get_list_schema' + responses: + 200: + description: Combined list of databases and semantic layers + 401: + $ref: '#/components/responses/401' + 500: + $ref: '#/components/responses/500' + """ + args = kwargs.get("rison", {}) + page = args.get("page", 0) + page_size = args.get("page_size", 25) + order_column = args.get("order_column", "changed_on") + order_direction = args.get("order_direction", "desc") + filters = args.get("filters", []) + + source_type = "all" + name_filter = None + for f in filters: + if f.get("col") == "source_type": + source_type = f.get("value", "all") + elif f.get("col") == "database_name" and f.get("opr") == "ct": + name_filter = f.get("value") + + if not is_feature_enabled("SEMANTIC_LAYERS"): + source_type = "database" + + reverse = order_direction == "desc" + + def _sort_key_changed_on( + item: tuple[str, Database | SemanticLayer], + ) -> Any: + return item[1].changed_on or "" + + def _sort_key_name( + item: tuple[str, Database | SemanticLayer], + ) -> str: + obj = item[1] + raw = ( + obj.database_name # type: ignore[union-attr] + if item[0] == "database" + else obj.name + ) + return raw.lower() + + sort_key_map = { + "changed_on_delta_humanized": _sort_key_changed_on, + "database_name": _sort_key_name, + } + sort_key = sort_key_map.get(order_column, _sort_key_changed_on) + + # Fetch databases (lightweight: only loads ORM objects, no eager joins) + db_items: list[tuple[str, Database]] = [] + if source_type in ("all", "database"): + db_q = db.session.query(Database) + if name_filter: + db_q = db_q.filter( + Database.database_name.ilike(f"%{name_filter}%") + ) + db_items = [("database", obj) for obj in db_q.all()] + + # Fetch semantic layers + sl_items: list[tuple[str, SemanticLayer]] = [] + if source_type in ("all", "semantic_layer"): + sl_q = db.session.query(SemanticLayer) + if name_filter: + sl_q = sl_q.filter( + SemanticLayer.name.ilike(f"%{name_filter}%") + ) + sl_items = [("semantic_layer", obj) for obj in sl_q.all()] + + # Merge, sort, count, paginate + all_items: list[tuple[str, Any]] = db_items + sl_items # type: ignore + all_items.sort(key=sort_key, reverse=reverse) # type: ignore + total_count = len(all_items) + + start = page * page_size + page_items = all_items[start : start + page_size] + + # Serialize + result = [] + for item_type, obj in page_items: + if item_type == "database": + result.append(self._serialize_database(obj)) + else: + result.append(self._serialize_semantic_layer(obj)) + + return self.response(200, count=total_count, result=result) + + @staticmethod + def _serialize_database(obj: Database) -> dict[str, Any]: + changed_by = obj.changed_by + return { + "source_type": "database", + "id": obj.id, + "uuid": str(obj.uuid), + "database_name": obj.database_name, + "backend": obj.backend, + "allow_run_async": obj.allow_run_async, + "allow_dml": obj.allow_dml, + "allow_file_upload": obj.allow_file_upload, + "expose_in_sqllab": obj.expose_in_sqllab, + "changed_on_delta_humanized": obj.changed_on_delta_humanized(), + "changed_by": { + "first_name": changed_by.first_name, + "last_name": changed_by.last_name, + } + if changed_by + else None, + } + + @staticmethod + def _serialize_semantic_layer(obj: SemanticLayer) -> dict[str, Any]: + changed_by = obj.changed_by + sl_type = obj.type + cls = registry.get(sl_type) + type_name = cls.name if cls else sl_type + return { + "source_type": "semantic_layer", + "uuid": str(obj.uuid), + "database_name": obj.name, + "backend": type_name, + "sl_type": sl_type, + "description": obj.description, + "allow_run_async": None, + "allow_dml": None, + "allow_file_upload": None, + "expose_in_sqllab": None, + "changed_on_delta_humanized": obj.changed_on_delta_humanized(), + "changed_by": { + "first_name": changed_by.first_name, + "last_name": changed_by.last_name, + } + if changed_by + else None, + } + @expose("/", methods=("GET",)) @protect() @safe
