This is an automated email from the ASF dual-hosted git repository. beto pushed a commit to branch semantic-layer-ui-semantic-view in repository https://gitbox.apache.org/repos/asf/superset.git
commit 33225aff39b3d2b60d0f839e066806deec203cc6 Author: Beto Dealmeida <[email protected]> AuthorDate: Wed Feb 11 11:19:50 2026 -0500 feat: UI for semantic views --- docs/static/feature-flags.json | 6 + .../components/Label/reusable/DatasetTypeLabel.tsx | 20 +- .../superset-ui-core/src/utils/featureFlags.ts | 1 + .../semanticViews/SemanticViewEditModal.tsx | 120 +++++++++ superset-frontend/src/pages/DatasetList/index.tsx | 223 +++++++++++++++-- superset/config.py | 3 + superset/datasource/api.py | 267 ++++++++++++++++++++- superset/semantic_layers/models.py | 8 + 8 files changed, 620 insertions(+), 28 deletions(-) diff --git a/docs/static/feature-flags.json b/docs/static/feature-flags.json index 227d529c1db..699cad9e324 100644 --- a/docs/static/feature-flags.json +++ b/docs/static/feature-flags.json @@ -69,6 +69,12 @@ "lifecycle": "development", "description": "Expand nested types in Presto into extra columns/arrays. Experimental, doesn't work with all nested types." }, + { + "name": "SEMANTIC_LAYERS", + "default": false, + "lifecycle": "development", + "description": "Enable semantic layers and show semantic views alongside datasets" + }, { "name": "TABLE_V2_TIME_COMPARISON_ENABLED", "default": false, diff --git a/superset-frontend/packages/superset-ui-core/src/components/Label/reusable/DatasetTypeLabel.tsx b/superset-frontend/packages/superset-ui-core/src/components/Label/reusable/DatasetTypeLabel.tsx index d8567d93b2a..f3d19617742 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Label/reusable/DatasetTypeLabel.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Label/reusable/DatasetTypeLabel.tsx @@ -23,7 +23,7 @@ import { Label } from '..'; // Define the prop types for DatasetTypeLabel interface DatasetTypeLabelProps { - datasetType: 'physical' | 'virtual'; // Accepts only 'physical' or 'virtual' + datasetType: 'physical' | 'virtual' | 'semantic_view'; } const SIZE = 's'; // Define the size as a constant @@ -32,6 +32,24 @@ export const DatasetTypeLabel: React.FC<DatasetTypeLabelProps> = ({ datasetType, }) => { const theme = useTheme(); + + if (datasetType === 'semantic_view') { + return ( + <Label + icon={ + <Icons.ApartmentOutlined + iconSize={SIZE} + iconColor={theme.colorInfo} + /> + } + type="info" + style={{ color: theme.colorInfo }} + > + {t('Semantic')} + </Label> + ); + } + const label: string = datasetType === 'physical' ? t('Physical') : t('Virtual'); const icon = diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts index 9770951342b..e117e097599 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts @@ -59,6 +59,7 @@ export enum FeatureFlag { ListviewsDefaultCardView = 'LISTVIEWS_DEFAULT_CARD_VIEW', Matrixify = 'MATRIXIFY', ScheduledQueries = 'SCHEDULED_QUERIES', + SemanticLayers = 'SEMANTIC_LAYERS', SqllabBackendPersistence = 'SQLLAB_BACKEND_PERSISTENCE', SqlValidatorsByEngine = 'SQL_VALIDATORS_BY_ENGINE', SshTunneling = 'SSH_TUNNELING', diff --git a/superset-frontend/src/features/semanticViews/SemanticViewEditModal.tsx b/superset-frontend/src/features/semanticViews/SemanticViewEditModal.tsx new file mode 100644 index 00000000000..ed90d165b9c --- /dev/null +++ b/superset-frontend/src/features/semanticViews/SemanticViewEditModal.tsx @@ -0,0 +1,120 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useState, useEffect } from 'react'; +import { t } from '@apache-superset/core'; +import { styled } from '@apache-superset/core/ui'; +import { SupersetClient } from '@superset-ui/core'; +import { Input, InputNumber } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { + StandardModal, + ModalFormField, + MODAL_STANDARD_WIDTH, +} from 'src/components/Modal'; + +const ModalContent = styled.div` + padding: ${({ theme }) => theme.sizeUnit * 4}px; +`; + +interface SemanticViewEditModalProps { + show: boolean; + onHide: () => void; + onSave: () => void; + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; + semanticView: { + id: number; + table_name: string; + description?: string | null; + cache_timeout?: number | null; + } | null; +} + +export default function SemanticViewEditModal({ + show, + onHide, + onSave, + addDangerToast, + addSuccessToast, + semanticView, +}: SemanticViewEditModalProps) { + const [description, setDescription] = useState<string>(''); + const [cacheTimeout, setCacheTimeout] = useState<number | null>(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (semanticView) { + setDescription(semanticView.description || ''); + setCacheTimeout(semanticView.cache_timeout ?? null); + } + }, [semanticView]); + + const handleSave = async () => { + if (!semanticView) return; + setSaving(true); + try { + await SupersetClient.put({ + endpoint: `/api/v1/semantic_view/${semanticView.id}`, + jsonPayload: { + description: description || null, + cache_timeout: cacheTimeout, + }, + }); + addSuccessToast(t('Semantic view updated')); + onSave(); + onHide(); + } catch { + addDangerToast(t('An error occurred while saving the semantic view')); + } finally { + setSaving(false); + } + }; + + return ( + <StandardModal + show={show} + onHide={onHide} + onSave={handleSave} + title={t('Edit %s', semanticView?.table_name || '')} + icon={<Icons.EditOutlined />} + isEditMode + width={MODAL_STANDARD_WIDTH} + saveLoading={saving} + > + <ModalContent> + <ModalFormField label={t('Description')}> + <Input.TextArea + value={description} + onChange={e => setDescription(e.target.value)} + rows={4} + /> + </ModalFormField> + <ModalFormField label={t('Cache timeout')}> + <InputNumber + value={cacheTimeout} + onChange={value => setCacheTimeout(value as number | null)} + min={0} + placeholder={t('Duration in seconds')} + style={{ width: '100%' }} + /> + </ModalFormField> + </ModalContent> + </StandardModal> + ); +} diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx index 39684dd9ca7..9ee9bb4fd84 100644 --- a/superset-frontend/src/pages/DatasetList/index.tsx +++ b/superset-frontend/src/pages/DatasetList/index.tsx @@ -17,7 +17,12 @@ * under the License. */ import { t } from '@apache-superset/core'; -import { getExtensionsRegistry, SupersetClient } from '@superset-ui/core'; +import { + getExtensionsRegistry, + SupersetClient, + isFeatureEnabled, + FeatureFlag, +} from '@superset-ui/core'; import { styled, useTheme, css } from '@apache-superset/core/ui'; import { FunctionComponent, useState, useMemo, useCallback, Key } from 'react'; import { Link, useHistory } from 'react-router-dom'; @@ -50,6 +55,7 @@ import { ListViewFilterOperator as FilterOperator, type ListViewProps, type ListViewFilters, + type ListViewFetchDataConfig, } from 'src/components'; import { Typography } from '@superset-ui/core/components/Typography'; import handleResourceExport from 'src/utils/export'; @@ -67,6 +73,7 @@ import { CONFIRM_OVERWRITE_MESSAGE, } from 'src/features/datasets/constants'; import DuplicateDatasetModal from 'src/features/datasets/DuplicateDatasetModal'; +import SemanticViewEditModal from 'src/features/semanticViews/SemanticViewEditModal'; import { useSelector } from 'react-redux'; import { QueryObjectColumns } from 'src/views/CRUD/types'; import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils'; @@ -120,13 +127,16 @@ type Dataset = { database: { id: string; database_name: string; - }; + } | null; kind: string; + source_type?: 'database' | 'semantic_layer'; explore_url: string; id: number; owners: Array<Owner>; schema: string; table_name: string; + description?: string | null; + cache_timeout?: number | null; }; interface VirtualDataset extends Dataset { @@ -152,18 +162,90 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({ const history = useHistory(); const theme = useTheme(); const { - state: { - loading, - resourceCount: datasetCount, - resourceCollection: datasets, - bulkSelectEnabled, - }, + state: { bulkSelectEnabled }, hasPerm, - fetchData, toggleBulkSelect, - refreshData, } = useListViewResource<Dataset>('dataset', t('dataset'), addDangerToast); + // Combined endpoint state + const [datasets, setDatasets] = useState<Dataset[]>([]); + const [datasetCount, setDatasetCount] = useState(0); + const [loading, setLoading] = useState(true); + const [lastFetchConfig, setLastFetchConfig] = + useState<ListViewFetchDataConfig | null>(null); + const [currentSourceFilter, setCurrentSourceFilter] = useState<string>(''); + + const fetchData = useCallback((config: ListViewFetchDataConfig) => { + setLastFetchConfig(config); + setLoading(true); + const { pageIndex, pageSize, sortBy, filters: filterValues } = config; + + // Separate source_type filter from other filters + const sourceTypeFilter = filterValues.find(f => f.id === 'source_type'); + + // Track source filter for conditional Type filter visibility + const sourceVal = + sourceTypeFilter?.value && typeof sourceTypeFilter.value === 'object' + ? (sourceTypeFilter.value as { value: string }).value + : ((sourceTypeFilter?.value as string) ?? ''); + setCurrentSourceFilter(sourceVal); + 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, + })); + + // Add source_type filter for the combined endpoint + 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/datasource/?q=${queryParams}`, + }) + .then(({ json = {} }) => { + setDatasets(json.result); + setDatasetCount(json.count); + }) + .catch(() => { + addDangerToast(t('An error occurred while fetching datasets')); + }) + .finally(() => { + setLoading(false); + }); + }, []); + + const refreshData = useCallback(() => { + if (lastFetchConfig) { + return fetchData(lastFetchConfig); + } + return undefined; + }, [lastFetchConfig, fetchData]); + const [datasetCurrentlyDeleting, setDatasetCurrentlyDeleting] = useState< | (Dataset & { charts: any; @@ -178,6 +260,10 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({ const [datasetCurrentlyDuplicating, setDatasetCurrentlyDuplicating] = useState<VirtualDataset | null>(null); + const [svCurrentlyEditing, setSvCurrentlyEditing] = useState<Dataset | null>( + null, + ); + const [importingDataset, showImportModal] = useState<boolean>(false); const [passwordFields, setPasswordFields] = useState<string[]>([]); const [preparingExport, setPreparingExport] = useState<boolean>(false); @@ -372,12 +458,22 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({ id: 'kind', }, { + Cell: ({ + row: { + original: { database }, + }, + }: any) => database?.database_name || '-', Header: t('Database'), accessor: 'database.database_name', size: 'xl', id: 'database.database_name', }, { + Cell: ({ + row: { + original: { schema }, + }, + }: any) => schema || '-', Header: t('Schema'), accessor: 'schema', size: 'lg', @@ -420,9 +516,40 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({ disableSortBy: true, id: 'sql', }, + { + accessor: 'source_type', + hidden: true, + disableSortBy: true, + id: 'source_type', + }, { Cell: ({ row: { original } }: any) => { - // Verify owner or isAdmin + const isSemanticView = original.source_type === 'semantic_layer'; + + // Semantic view: only show edit button + if (isSemanticView) { + if (!canEdit) return null; + return ( + <Actions className="actions"> + <Tooltip + id="edit-action-tooltip" + title={t('Edit')} + placement="bottom" + > + <span + role="button" + tabIndex={0} + className="action-button" + onClick={() => setSvCurrentlyEditing(original)} + > + <Icons.EditOutlined iconSize="l" /> + </span> + </Tooltip> + </Actions> + ); + } + + // Dataset: full set of actions const allowEdit = original.owners.map((o: Owner) => o.id).includes(user.userId) || isUserAdmin(user); @@ -536,6 +663,22 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({ const filterTypes: ListViewFilters = useMemo( () => [ + ...(isFeatureEnabled(FeatureFlag.SemanticLayers) + ? [ + { + Header: t('Source'), + key: 'source_type', + id: 'source_type', + input: 'select' as const, + operator: FilterOperator.Equals, + unfilteredLabel: t('All'), + selects: [ + { label: t('Database'), value: 'database' }, + { label: t('Semantic Layer'), value: 'semantic_layer' }, + ], + }, + ] + : []), { Header: t('Name'), key: 'search', @@ -543,18 +686,42 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({ input: 'search', operator: FilterOperator.Contains, }, - { - Header: t('Type'), - key: 'sql', - id: 'sql', - input: 'select', - operator: FilterOperator.DatasetIsNullOrEmpty, - unfilteredLabel: 'All', - selects: [ - { label: t('Virtual'), value: false }, - { label: t('Physical'), value: true }, - ], - }, + ...(isFeatureEnabled(FeatureFlag.SemanticLayers) + ? [ + { + Header: t('Type'), + key: 'sql', + id: 'sql', + input: 'select' as const, + operator: FilterOperator.DatasetIsNullOrEmpty, + unfilteredLabel: 'All', + selects: [ + ...(currentSourceFilter !== 'semantic_layer' + ? [ + { label: t('Physical'), value: true }, + { label: t('Virtual'), value: false }, + ] + : []), + ...(currentSourceFilter !== 'database' + ? [{ label: t('Semantic View'), value: 'semantic_view' }] + : []), + ], + }, + ] + : [ + { + Header: t('Type'), + key: 'sql', + id: 'sql', + input: 'select' as const, + operator: FilterOperator.DatasetIsNullOrEmpty, + unfilteredLabel: 'All', + selects: [ + { label: t('Physical'), value: true }, + { label: t('Virtual'), value: false }, + ], + }, + ]), { Header: t('Database'), key: 'database', @@ -645,7 +812,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({ dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH }, }, ], - [user], + [user, currentSourceFilter], ); const menuData: SubMenuProps = { @@ -897,6 +1064,14 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({ onHide={closeDatasetDuplicateModal} onDuplicate={handleDatasetDuplicate} /> + <SemanticViewEditModal + show={!!svCurrentlyEditing} + onHide={() => setSvCurrentlyEditing(null)} + onSave={refreshData} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + semanticView={svCurrentlyEditing} + /> <ConfirmStatusChange title={t('Please confirm')} description={t( diff --git a/superset/config.py b/superset/config.py index dfeafb3b261..fe1d5da2fa9 100644 --- a/superset/config.py +++ b/superset/config.py @@ -562,6 +562,9 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = { # in addition to relative timeshifts (e.g., "1 day ago") # @lifecycle: development "DATE_RANGE_TIMESHIFTS_ENABLED": False, + # Enable semantic layers and show semantic views alongside datasets + # @lifecycle: development + "SEMANTIC_LAYERS": False, # Enables advanced data type support # @lifecycle: development "ENABLE_ADVANCED_DATA_TYPES": False, diff --git a/superset/datasource/api.py b/superset/datasource/api.py index da5f7e3ded8..ca8a235192b 100644 --- a/superset/datasource/api.py +++ b/superset/datasource/api.py @@ -15,17 +15,23 @@ # specific language governing permissions and limitations # under the License. import logging +from typing import Any from flask import current_app as app, request -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 sqlalchemy import and_, func, literal, or_, select, union_all -from superset import event_logger -from superset.connectors.sqla.models import BaseDatasource +from superset import db, event_logger, is_feature_enabled, security_manager +from superset.connectors.sqla import models as sqla_models +from superset.connectors.sqla.models import BaseDatasource, SqlaTable from superset.daos.datasource import DatasourceDAO from superset.daos.exceptions import DatasourceNotFound, DatasourceTypeNotSupportedError from superset.exceptions import SupersetSecurityException +from superset.semantic_layers.models import SemanticView from superset.superset_typing import FlaskResponse from superset.utils.core import apply_max_row_limit, DatasourceType, SqlExpressionType +from superset.utils.filters import get_dataset_access_filters from superset.views.base_api import BaseSupersetApi, statsd_metrics logger = logging.getLogger(__name__) @@ -303,3 +309,258 @@ class DatasourceRestApi(BaseSupersetApi): f"Invalid expression type: {expression_type}. " f"Valid types are: column, metric, where, having" ) from None + + @expose("/", methods=("GET",)) + @safe + @statsd_metrics + @rison(get_list_schema) + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}" + ".combined_list", + log_to_statsd=False, + ) + def combined_list(self, **kwargs: Any) -> FlaskResponse: + """List datasets and semantic views combined. + --- + get: + summary: List datasets and semantic views combined + parameters: + - in: query + name: q + content: + application/json: + schema: + $ref: '#/components/schemas/get_list_schema' + responses: + 200: + description: Combined list of datasets and semantic views + 401: + $ref: '#/components/responses/401' + 403: + $ref: '#/components/responses/403' + 500: + $ref: '#/components/responses/500' + """ + if not security_manager.can_access("can_read", "Dataset"): + return self.response(403, message="Access denied") + + 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", []) + + # Extract source_type, name search, sql (type), and type_filter + source_type = "all" + name_filter = None + sql_filter = None # None = no filter, True = physical, False = virtual + type_filter = None # None = no filter, "semantic_view" / True / False + for f in filters: + if f.get("col") == "source_type": + source_type = f.get("value", "all") + elif f.get("col") == "table_name" and f.get("opr") == "ct": + name_filter = f.get("value") + elif f.get("col") == "sql": + val = f.get("value") + if val == "semantic_view": + type_filter = "semantic_view" + else: + sql_filter = val + + # If semantic layers feature flag is off, only show datasets + if not is_feature_enabled("SEMANTIC_LAYERS"): + source_type = "database" + + # Map sort columns + sort_col_map = { + "changed_on_delta_humanized": "changed_on", + "table_name": "table_name", + } + sort_col_name = sort_col_map.get(order_column, "changed_on") + + # Build dataset subquery + ds_q = select( + SqlaTable.id.label("item_id"), + literal("database").label("source_type"), + SqlaTable.changed_on, + SqlaTable.table_name, + ).select_from(SqlaTable.__table__) + + # Apply security filters for datasets + if not security_manager.can_access_all_datasources(): + ds_q = ds_q.join( + sqla_models.Database, + sqla_models.Database.id == SqlaTable.database_id, + ) + ds_q = ds_q.where(get_dataset_access_filters(SqlaTable)) + + # Apply name filter to datasets + if name_filter: + ds_q = ds_q.where(SqlaTable.table_name.ilike(f"%{name_filter}%")) + + # Apply sql (type) filter to datasets + if sql_filter is not None: + if sql_filter: + # Physical: sql is null or empty + ds_q = ds_q.where( + or_(SqlaTable.sql.is_(None), SqlaTable.sql == "") + ) + else: + # Virtual: sql is not null and not empty + ds_q = ds_q.where( + and_(SqlaTable.sql.isnot(None), SqlaTable.sql != "") + ) + # Selecting Physical/Virtual implicitly means "database only" + if source_type == "all": + source_type = "database" + + # Handle type_filter = "semantic_view" + if type_filter == "semantic_view": + source_type = "semantic_layer" + + # Build semantic view subquery + sv_q = select( + SemanticView.id.label("item_id"), + literal("semantic_layer").label("source_type"), + SemanticView.changed_on, + SemanticView.name.label("table_name"), + ).select_from(SemanticView.__table__) + + # Apply name filter to semantic views + if name_filter: + sv_q = sv_q.where(SemanticView.name.ilike(f"%{name_filter}%")) + + # Build combined query based on source_type + if source_type == "database": + combined = ds_q.subquery() + elif source_type == "semantic_layer": + combined = sv_q.subquery() + else: + combined = union_all(ds_q, sv_q).subquery() + + # Count total + count_q = select(func.count()).select_from(combined) + total_count = db.session.execute(count_q).scalar() or 0 + + # Sort and paginate + sort_col = combined.c[sort_col_name] + if order_direction == "desc": + sort_col = sort_col.desc() + else: + sort_col = sort_col.asc() + + paginated_q = ( + select( + combined.c.item_id, + combined.c.source_type, + ) + .order_by(sort_col) + .offset(page * page_size) + .limit(page_size) + ) + rows = db.session.execute(paginated_q).fetchall() + + # Collect IDs by type + dataset_ids = [r.item_id for r in rows if r.source_type == "database"] + sv_ids = [r.item_id for r in rows if r.source_type == "semantic_layer"] + + # Fetch full ORM objects + datasets_map: dict[int, SqlaTable] = {} + if dataset_ids: + ds_objs = ( + db.session.query(SqlaTable) + .filter(SqlaTable.id.in_(dataset_ids)) + .all() + ) + datasets_map = {obj.id: obj for obj in ds_objs} + + sv_map: dict[int, SemanticView] = {} + if sv_ids: + sv_objs = ( + db.session.query(SemanticView) + .filter(SemanticView.id.in_(sv_ids)) + .all() + ) + sv_map = {obj.id: obj for obj in sv_objs} + + # Serialize in UNION order + result = [] + for row in rows: + if row.source_type == "database": + obj = datasets_map.get(row.item_id) + if obj: + result.append(self._serialize_dataset(obj)) + else: + obj = sv_map.get(row.item_id) + if obj: + result.append(self._serialize_semantic_view(obj)) + + return self.response(200, count=total_count, result=result) + + @staticmethod + def _serialize_dataset(obj: SqlaTable) -> dict[str, Any]: + changed_by = obj.changed_by + return { + "id": obj.id, + "uuid": str(obj.uuid), + "table_name": obj.table_name, + "kind": obj.kind, + "source_type": "database", + "description": obj.description, + "explore_url": obj.explore_url, + "database": { + "id": obj.database_id, + "database_name": obj.database.database_name, + } + if obj.database + else None, + "schema": obj.schema, + "sql": obj.sql, + "extra": obj.extra, + "owners": [ + { + "id": o.id, + "first_name": o.first_name, + "last_name": o.last_name, + } + for o in obj.owners + ], + "changed_by_name": obj.changed_by_name, + "changed_by": { + "first_name": changed_by.first_name, + "last_name": changed_by.last_name, + } + if changed_by + else None, + "changed_on_delta_humanized": obj.changed_on_delta_humanized(), + "changed_on_utc": obj.changed_on_utc(), + } + + @staticmethod + def _serialize_semantic_view(obj: SemanticView) -> dict[str, Any]: + changed_by = obj.changed_by + return { + "id": obj.id, + "uuid": str(obj.uuid), + "table_name": obj.name, + "kind": "semantic_view", + "source_type": "semantic_layer", + "description": obj.description, + "cache_timeout": obj.cache_timeout, + "explore_url": obj.explore_url, + "database": None, + "schema": None, + "sql": None, + "extra": None, + "owners": [], + "changed_by_name": obj.changed_by_name, + "changed_by": { + "first_name": changed_by.first_name, + "last_name": changed_by.last_name, + } + if changed_by + else None, + "changed_on_delta_humanized": obj.changed_on_delta_humanized(), + "changed_on_utc": obj.changed_on_utc(), + } diff --git a/superset/semantic_layers/models.py b/superset/semantic_layers/models.py index 80c0d88d400..95b225e9592 100644 --- a/superset/semantic_layers/models.py +++ b/superset/semantic_layers/models.py @@ -205,6 +205,14 @@ class SemanticView(AuditMixinNullable, Model): def get_query_str(self, query_obj: QueryObjectDict) -> str: return "Not implemented for semantic layers" + @property + def table_name(self) -> str: + return self.name + + @property + def kind(self) -> str: + return "semantic_view" + @property def uid(self) -> str: return self.implementation.uid()
