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 2026/03/03 23:18:48 UTC
(superset) 07/07: CRUD working
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 bf0fb86f84df9206cad06b0733bccf0948b4339b
Author: Beto Dealmeida <ro...@dealmeida.net>
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