Compare commits

...

28 Commits

Author SHA1 Message Date
Beto Dealmeida
6eeb107b27 Small fixes 2026-04-23 14:05:55 -04:00
Beto Dealmeida
e9a1513c39 Sort metrics/columns 2026-04-23 14:05:54 -04:00
Beto Dealmeida
cb979b01e2 Disable adhoc 2026-04-23 14:05:54 -04:00
Beto Dealmeida
34f062c0a4 Small fix 2026-04-23 14:05:54 -04:00
Beto Dealmeida
727c61aa71 Disable adhoc SQL 2026-04-23 14:05:54 -04:00
Beto Dealmeida
cb35bdf643 Dynamic columns/metrics 2026-04-23 14:05:54 -04:00
Beto Dealmeida
b0a3661611 Explorable protocol 2026-04-23 14:05:54 -04:00
Beto Dealmeida
76dec98f33 feat: dynamic columns 2026-04-23 14:05:54 -04:00
Beto Dealmeida
bde6bb1962 Fix edit dataset 2026-04-23 14:05:54 -04:00
Beto Dealmeida
dc1fafce95 Small fixes 2026-04-23 14:05:54 -04:00
Beto Dealmeida
4b6cb09ed1 Fix lint 2026-04-23 14:05:54 -04:00
Beto Dealmeida
21b9ce3562 Fix filters 2026-04-23 14:05:54 -04:00
Beto Dealmeida
b96dd6cf99 Working on filters 2026-04-23 14:05:54 -04:00
Beto Dealmeida
03f2530b9d Working on filters 2026-04-23 14:05:54 -04:00
Beto Dealmeida
8b9a0eae33 Fix header name 2026-04-23 14:05:54 -04:00
Beto Dealmeida
7c9ce279a9 Fix data connection name 2026-04-23 14:05:54 -04:00
Beto Dealmeida
cac8656988 Small fixes 2026-04-23 14:05:54 -04:00
Beto Dealmeida
99893f75c4 Fix lint 2026-04-23 14:05:54 -04:00
Beto Dealmeida
72ad2acb86 Revert some aggressive renames 2026-04-23 14:05:54 -04:00
Beto Dealmeida
71f04979e9 More fixes 2026-04-23 14:05:54 -04:00
Beto Dealmeida
b7a7d83ea5 chore: rename database/database when using semantic layers 2026-04-23 14:05:54 -04:00
Beto Dealmeida
3f0d302b56 Small fixes 2026-04-23 14:05:54 -04:00
Beto Dealmeida
c7f4c1b818 Bulk delete 2026-04-23 14:05:53 -04:00
Beto Dealmeida
51c6708caa Address comments 2026-04-23 14:05:53 -04:00
Beto Dealmeida
8a9764a4b5 Improve design 2026-04-23 14:05:53 -04:00
Beto Dealmeida
e7cb3c5a1e Fix lint/tests 2026-04-23 14:05:53 -04:00
Beto Dealmeida
a86424bca3 Fix imports 2026-04-23 14:05:53 -04:00
Beto Dealmeida
6ca5464d27 feat: CRUD for adding/deleting semantic views 2026-04-23 14:05:53 -04:00
62 changed files with 3482 additions and 491 deletions

View File

@@ -105,7 +105,13 @@ class CeleryConfig:
CELERY_CONFIG = CeleryConfig
FEATURE_FLAGS = {"ALERT_REPORTS": True, "DATASET_FOLDERS": True}
FEATURE_FLAGS = {
"ALERT_REPORTS": True,
"DATASET_FOLDERS": True,
"ENABLE_EXTENSIONS": True,
"SEMANTIC_LAYERS": True,
}
EXTENSIONS_PATH = "/app/docker/extensions"
ALERT_REPORTS_NOTIFICATION_DRY_RUN = True
WEBDRIVER_BASEURL = f"http://superset_app{os.environ.get('SUPERSET_APP_ROOT', '/')}/" # When using docker compose baseurl should be http://superset_nginx{ENV{BASEPATH}}/ # noqa: E501
# The base URL for the email report hyperlinks.

View File

@@ -47,6 +47,11 @@ class SemanticView(ABC):
features: frozenset[SemanticViewFeature]
# Implementations must expose a display name for the view.
# Declared here as a type annotation (not abstract) so that existing
# implementations are not required to add a formal @abstractmethod.
name: str
@abstractmethod
def uid(self) -> str:
"""

View File

@@ -4076,6 +4076,18 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@googleapis/sheets": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-13.0.1.tgz",
"integrity": "sha512-XTYObncN5Rqexc0uITZIN9OWTEyE/ZR2S6c7wAniqHe2oGXW9gcHR9f9hQwPMHFUTHjH7Jkj8SLdt0O0u37y2A==",
"license": "Apache-2.0",
"dependencies": {
"googleapis-common": "^8.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/@great-expectations/jsonforms-antd-renderers": {
"version": "2.2.11",
"resolved": "https://registry.npmjs.org/@great-expectations/jsonforms-antd-renderers/-/jsonforms-antd-renderers-2.2.11.tgz",
@@ -4096,18 +4108,6 @@
"react": "^17 || ^18"
}
},
"node_modules/@googleapis/sheets": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-13.0.1.tgz",
"integrity": "sha512-XTYObncN5Rqexc0uITZIN9OWTEyE/ZR2S6c7wAniqHe2oGXW9gcHR9f9hQwPMHFUTHjH7Jkj8SLdt0O0u37y2A==",
"license": "Apache-2.0",
"dependencies": {
"googleapis-common": "^8.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/@hapi/address": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz",

View File

@@ -41,6 +41,13 @@ export interface Datasource {
id: number;
name: string;
type: DatasourceType;
/**
* The parent resource that owns this datasource.
* For SQL-based datasets this is the database; for semantic views it is the
* semantic layer. Use this field instead of the legacy `database` field when
* you only need the display name.
*/
parent?: { name: string };
columns: Column[];
metrics: Metric[];
description?: string;

View File

@@ -361,7 +361,9 @@ class Chart extends PureComponent<ChartProps, {}> {
width,
} = this.props;
const databaseName = datasource?.database?.name as string | undefined;
const databaseName =
datasource?.parent?.name ??
(datasource?.database?.name as string | undefined);
const isLoading = chartStatus === 'loading';
// Suppress spinner during auto-refresh to avoid visual flicker

View File

@@ -53,6 +53,7 @@ import { Dataset } from '../types';
import TableControls from './DrillDetailTableControls';
import { getDrillPayload } from './utils';
import { ResultsPage } from './types';
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
const PAGE_SIZE = 50;
@@ -303,7 +304,7 @@ export default function DrillDetailPane({
tableContent = <Loading />;
} else if (resultsPage?.total === 0) {
// Render empty state if no results are returned for page
const title = t('No rows were returned for this dataset');
const title = t('No rows were returned for this %s', datasetLabelLower());
tableContent = <EmptyState image="document.svg" title={title} />;
} else {
// Render table if at least one page has successfully loaded

View File

@@ -52,6 +52,10 @@ import type {
DatabaseObject,
} from './types';
import { StyledFormLabel } from './styles';
import {
databaseLabel,
databasesLabelLower,
} from 'src/utils/semanticLayerLabels';
const DatabaseSelectorWrapper = styled.div<{ horizontal?: boolean }>`
${({ theme, horizontal }) =>
@@ -431,7 +435,11 @@ export function DatabaseSelector({
function renderDatabaseSelect() {
if (sqlLabMode) {
return renderSelectRow(
t('Select database or type to search databases'),
t(
'Select %s or type to search %s',
databaseLabel().toLowerCase(),
databasesLabelLower(),
),
null,
null,
{
@@ -448,16 +456,24 @@ export function DatabaseSelector({
return (
<div>
{renderSelectRow(
t('Database'),
databaseLabel(),
<AsyncSelect
ariaLabel={t('Select database or type to search databases')}
ariaLabel={t(
'Select %s or type to search %s',
databaseLabel().toLowerCase(),
databasesLabelLower(),
)}
optionFilterProps={['database_name', 'value']}
data-test="select-database"
lazyLoading={false}
notFoundContent={emptyState}
onChange={changeDatabase}
value={currentDb}
placeholder={t('Select database or type to search databases')}
placeholder={t(
'Select %s or type to search %s',
databaseLabel().toLowerCase(),
databasesLabelLower(),
)}
disabled={!isDatabaseSelectEnabled || readOnly}
options={loadDatabases}
sortComparator={sortComparator}

View File

@@ -53,6 +53,7 @@ import {
import withToasts from 'src/components/MessageToasts/withToasts';
import { InputRef } from 'antd';
import type { Datasource, ChangeDatasourceModalProps } from '../types';
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
const CONFIRM_WARNING_MESSAGE = t(
'Warning! Changing the dataset may break the chart if the metadata does not exist.',
@@ -109,7 +110,11 @@ const ChangeDatasourceModal: FunctionComponent<ChangeDatasourceModalProps> = ({
const {
state: { loading, resourceCollection, resourceCount },
fetchData,
} = useListViewResource<Dataset>('dataset', t('dataset'), addDangerToast);
} = useListViewResource<Dataset>(
'dataset',
datasetLabelLower(),
addDangerToast,
);
const selectDatasource = useCallback((datasource: Datasource) => {
setConfirmChange(true);
@@ -187,7 +192,7 @@ const ChangeDatasourceModal: FunctionComponent<ChangeDatasourceModalProps> = ({
);
});
onHide();
addSuccessToast(t('Successfully changed dataset!'));
addSuccessToast(t('Successfully changed %s!', datasetLabelLower()));
};
const handlerCancelConfirm = () => {
@@ -253,7 +258,7 @@ const ChangeDatasourceModal: FunctionComponent<ChangeDatasourceModalProps> = ({
onHide={onHide}
responsive
name="Swap dataset"
title={t('Swap dataset')}
title={t('Swap %s', datasetLabelLower())}
width={confirmChange ? '432px' : ''}
height={confirmChange ? 'auto' : '540px'}
hideFooter={!confirmChange}

View File

@@ -20,6 +20,7 @@ import { t } from '@apache-superset/core/translation';
import type { ErrorMessageComponentProps } from './types';
import { ErrorAlert } from './ErrorAlert';
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
export function DatasetNotFoundErrorMessage({
error,
@@ -29,7 +30,7 @@ export function DatasetNotFoundErrorMessage({
const { level, message } = error;
return (
<ErrorAlert
errorType={t('Missing dataset')}
errorType={t('Missing %s', datasetLabelLower())}
message={subtitle}
description={message}
type={level}

View File

@@ -60,6 +60,12 @@ function UIFilters(
filter.current?.clearFilter?.();
});
},
clearFilterById: (id: string) => {
const index = filters.findIndex(f => f.id === id);
if (index >= 0) {
filterRefs[index]?.current?.clearFilter?.();
}
},
}));
return (

View File

@@ -19,7 +19,7 @@
import { t } from '@apache-superset/core/translation';
import { Alert } from '@apache-superset/core/components';
import { styled } from '@apache-superset/core/theme';
import { useCallback, useEffect, useRef, useState, ReactNode } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState, ReactNode } from 'react';
import cx from 'classnames';
import TableCollection from '@superset-ui/core/components/TableCollection';
import BulkTagModal from 'src/features/tags/BulkTagModal';
@@ -264,6 +264,11 @@ export interface ListViewProps<T extends object = any> {
columnsForWrapText?: string[];
enableBulkTag?: boolean;
bulkTagResourceName?: string;
/** Optional ref exposed to callers for programmatic filter control. */
filtersRef?: React.RefObject<{
clearFilters: () => void;
clearFilterById: (id: string) => void;
}>;
}
export function ListView<T extends object = any>({
@@ -290,6 +295,7 @@ export function ListView<T extends object = any>({
columnsForWrapText,
enableBulkTag = false,
bulkTagResourceName,
filtersRef,
addSuccessToast,
addDangerToast,
}: ListViewProps<T>) {
@@ -337,7 +343,20 @@ export function ListView<T extends object = any>({
});
}
const filterControlsRef = useRef<{ clearFilters: () => void }>(null);
const filterControlsRef = useRef<{
clearFilters: () => void;
clearFilterById: (id: string) => void;
}>(null);
// Wire the optional external filtersRef to our internal filterControlsRef.
// useLayoutEffect fires synchronously after DOM mutations, guaranteeing the
// ref is populated before the first paint and after every update.
useLayoutEffect(() => {
if (filtersRef) {
(filtersRef as React.MutableRefObject<typeof filterControlsRef.current>).current =
filterControlsRef.current;
}
});
const handleClearFilterControls = useCallback(() => {
if (query.filters) {

View File

@@ -36,6 +36,7 @@ import { Tooltip, ImageLoader } from '@superset-ui/core/components';
import { GenericLink, usePluginContext } from 'src/components';
import { assetUrl } from 'src/utils/assetUrl';
import { Theme } from '@emotion/react';
import { datasetLabel } from 'src/utils/semanticLayerLabels';
const FALLBACK_THUMBNAIL_URL = assetUrl(
'/static/assets/images/chart-card-fallback.svg',
@@ -283,7 +284,7 @@ const AddSliceCard: FC<{
>
<MetadataItem label={t('Viz type')} value={vizName} />
<MetadataItem
label={t('Dataset')}
label={datasetLabel()}
value={
datasourceUrl ? (
<GenericLink to={datasourceUrl}>

View File

@@ -55,6 +55,7 @@ import type { ConnectDragSource } from 'react-dnd';
import AddSliceCard from './AddSliceCard';
import AddSliceDragPreview from './dnd/AddSliceDragPreview';
import { DragDroppable } from './dnd/DragDroppable';
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
export type SliceAdderProps = {
theme: Theme;
@@ -88,7 +89,7 @@ const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
const KEYS_TO_SORT = {
slice_name: t('name'),
viz_type: t('viz type'),
datasource_name: t('dataset'),
datasource_name: datasetLabelLower(),
changed_on: t('recent'),
};

View File

@@ -51,6 +51,10 @@ import { addDangerToast } from 'src/components/MessageToasts/actions';
import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
import { dispatchChartCustomizationHoverAction } from './utils';
import { mergeExtraFormData } from '../../utils';
import {
datasetLabel as getDatasetLabel,
datasetLabelLower,
} from 'src/utils/semanticLayerLabels';
interface ColumnApiResponse {
column_name?: string;
@@ -262,7 +266,7 @@ const GroupByFilterCardContent: FC<{
</Row>
<Row>
<RowLabel>{t('Dataset')}</RowLabel>
<RowLabel>{getDatasetLabel()}</RowLabel>
<RowValue>
{typeof datasetLabel === 'string' ? datasetLabel : 'Dataset'}
</RowValue>
@@ -475,7 +479,13 @@ const GroupByFilterCard: FC<GroupByFilterCardProps> = ({
} catch (error) {
setColumnOptions([]);
dispatch(
addDangerToast(t('Failed to load columns for dataset %s', datasetId)),
addDangerToast(
t(
'Failed to load columns for %s %s',
datasetLabelLower(),
datasetId,
),
),
);
} finally {
setLoading(false);

View File

@@ -30,6 +30,11 @@ import {
Dataset,
DatasetSelectLabel,
} from 'src/features/datasets/DatasetSelectLabel';
import {
datasetLabel,
datasetLabelLower,
datasetsLabelLower,
} from 'src/utils/semanticLayerLabels';
interface DatasetSelectProps {
onChange: (value: { label: string | ReactNode; value: number }) => void;
@@ -101,13 +106,13 @@ const DatasetSelect = ({
return (
<AsyncSelect
ariaLabel={t('Dataset')}
ariaLabel={datasetLabel()}
value={value}
options={loadDatasetOptionsCallback}
onChange={onChange}
optionFilterProps={['table_name']}
notFoundContent={t('No compatible datasets found')}
placeholder={t('Select a dataset')}
notFoundContent={t('No compatible %s found', datasetsLabelLower())}
placeholder={t('Select a %s', datasetLabelLower())}
/>
);
};

View File

@@ -115,6 +115,7 @@ import {
INPUT_WIDTH,
} from './constants';
import DependencyList from './DependencyList';
import { datasetLabel } from 'src/utils/semanticLayerLabels';
const FORM_ITEM_WIDTH = 260;
@@ -972,7 +973,7 @@ const FiltersConfigForm = (
<StyledFormItem
expanded={expanded}
name={['filters', filterId, 'dataset']}
label={<StyledLabel>{t('Dataset')}</StyledLabel>}
label={<StyledLabel>{datasetLabel()}</StyledLabel>}
initialValue={
datasetDetails
? {
@@ -992,7 +993,7 @@ const FiltersConfigForm = (
rules={[
{
required: !isRemoved,
message: t('Dataset is required'),
message: t('%s is required', datasetLabel()),
},
]}
{...getFiltersConfigModalTestId('datasource-input')}
@@ -1018,7 +1019,7 @@ const FiltersConfigForm = (
) : (
<StyledFormItem
expanded={expanded}
label={<StyledLabel>{t('Dataset')}</StyledLabel>}
label={<StyledLabel>{datasetLabel()}</StyledLabel>}
>
<Loading position="inline-centered" />
</StyledFormItem>

View File

@@ -153,6 +153,79 @@ export function setForceQuery(force: boolean) {
};
}
export const SET_COMPATIBILITY = 'SET_COMPATIBILITY';
export function setCompatibility(payload: {
compatibleMetrics: string[] | null;
compatibleDimensions: string[] | null;
compatibilityLoading: boolean;
}) {
return { type: SET_COMPATIBILITY, ...payload };
}
/**
* Fetch compatible metrics and dimensions for the current selection.
*
* Only fires for semantic views — SQL datasets always have full compatibility
* so we short-circuit to `null` (no filtering) for everything else.
*
* Covers both real-time selection changes (M3) and saved-chart loading (M4):
* call this thunk on mount as well as whenever the metric / dimension
* selection changes in Explore.
*/
export function fetchCompatibility(
datasourceType: string,
datasourceId: number,
selectedMetrics: string[],
selectedDimensions: string[],
) {
return async (dispatch: Dispatch) => {
if (datasourceType !== 'semantic_view') {
dispatch(
setCompatibility({
compatibleMetrics: null,
compatibleDimensions: null,
compatibilityLoading: false,
}),
);
return;
}
dispatch(
setCompatibility({
compatibleMetrics: null,
compatibleDimensions: null,
compatibilityLoading: true,
}),
);
try {
const { json } = await SupersetClient.post({
endpoint: `/api/v1/datasource/${datasourceType}/${datasourceId}/compatible`,
jsonPayload: {
selected_metrics: selectedMetrics,
selected_dimensions: selectedDimensions,
},
});
dispatch(
setCompatibility({
compatibleMetrics: json.result.compatible_metrics,
compatibleDimensions: json.result.compatible_dimensions,
compatibilityLoading: false,
}),
);
} catch {
// On error fall back to no filtering so the user is never blocked.
dispatch(
setCompatibility({
compatibleMetrics: null,
compatibleDimensions: null,
compatibilityLoading: false,
}),
);
}
};
}
export const SET_STASH_FORM_DATA = 'SET_STASH_FORM_DATA';
export function setStashFormData(
isHidden: boolean,
@@ -195,6 +268,7 @@ export const exploreActions = {
sliceUpdated,
setForceQuery,
syncDatasourceMetadata,
fetchCompatibility,
};
export type ExploreActions = typeof exploreActions;

View File

@@ -19,6 +19,7 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { t } from '@apache-superset/core/translation';
import { ensureIsArray } from '@superset-ui/core';
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
import { styled } from '@apache-superset/core/theme';
import {
TableView,
@@ -135,7 +136,10 @@ export const SamplesPane = ({
}
if (data.length === 0) {
const title = t('No samples were returned for this dataset');
const title = t(
'No samples were returned for this %s',
datasetLabelLower(),
);
return <EmptyState image="document.svg" title={title} />;
}

View File

@@ -16,8 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { RefObject } from 'react';
import { RefObject, useMemo } from 'react';
import { useDrag } from 'react-dnd';
import { useSelector } from 'react-redux';
import { Metric } from '@superset-ui/core';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { ColumnMeta } from '@superset-ui/chart-controls';
@@ -27,6 +28,7 @@ import {
StyledMetricOption,
} from 'src/explore/components/optionRenderers';
import { Icons } from '@superset-ui/core/components/Icons';
import { ExplorePageState } from 'src/explore/types';
import { DatasourcePanelDndItem } from '../types';
@@ -70,11 +72,40 @@ export default function DatasourcePanelDragOption(
) {
const { labelRef, showTooltip, type, value } = props;
const theme = useTheme();
// Read compatibility lists from Redux.
// `null` means no filtering is active (SQL datasets, or no selection yet).
const compatibleMetrics = useSelector<
ExplorePageState,
string[] | null | undefined
>(state => state.explore.compatibleMetrics);
const compatibleDimensions = useSelector<
ExplorePageState,
string[] | null | undefined
>(state => state.explore.compatibleDimensions);
// An item is compatible when the list is null (no filter) or when its
// name explicitly appears in the list returned by the backend.
const isCompatible = useMemo(() => {
if (type === DndItemType.Metric) {
if (!compatibleMetrics) return true;
return compatibleMetrics.includes((value as Metric).metric_name);
}
if (type === DndItemType.Column) {
if (!compatibleDimensions) return true;
return compatibleDimensions.includes(
(value as ColumnMeta).column_name,
);
}
return true;
}, [type, value, compatibleMetrics, compatibleDimensions]);
const [{ isDragging }, drag] = useDrag({
item: {
value: props.value,
type: props.type,
},
canDrag: isCompatible,
collect: monitor => ({
isDragging: monitor.isDragging(),
}),
@@ -87,7 +118,14 @@ export default function DatasourcePanelDragOption(
};
return (
<DatasourceItemContainer data-test="DatasourcePanelDragOption" ref={drag}>
<DatasourceItemContainer
data-test="DatasourcePanelDragOption"
ref={drag}
style={{
opacity: isCompatible ? 1 : 0.35,
cursor: isCompatible ? 'grab' : 'not-allowed',
}}
>
{type === DndItemType.Column ? (
<StyledColumnOption column={value as ColumnMeta} {...optionProps} />
) : (

View File

@@ -122,7 +122,7 @@ const sortColumns = (slice: DatasourcePanelColumn[]) =>
if (col2?.is_dttm && !col1?.is_dttm) {
return 1;
}
return 0;
return (col1?.column_name ?? '').localeCompare(col2?.column_name ?? '');
})
.sort((a, b) => (b?.is_certified ?? 0) - (a?.is_certified ?? 0));
@@ -191,7 +191,9 @@ export default function DataSourcePanel({
const filteredMetrics = useMemo(() => {
if (!searchKeyword) {
return allowedMetrics ?? [];
return [...(allowedMetrics ?? [])].sort((a, b) =>
(a?.metric_name ?? '').localeCompare(b?.metric_name ?? ''),
);
}
return matchSorter(allowedMetrics, searchKeyword, {
keys: [

View File

@@ -36,6 +36,7 @@ import {
JsonObject,
MatrixifyFormData,
DatasourceType,
ensureIsArray,
} from '@superset-ui/core';
import {
ControlStateMapping,
@@ -412,6 +413,49 @@ function ExploreViewContainer(props: ExploreViewContainerProps) {
[originalTitle, theme?.brandAppName, theme?.brandLogoAlt],
);
// M3 + M4: fire compatibility check on mount and whenever the metric /
// dimension selection changes. Only semantic views use the endpoint;
// SQL datasets short-circuit to null inside fetchCompatibility.
const selectedMetrics = useMemo(
() =>
ensureIsArray(props.form_data.metrics).filter(
(m): m is string => typeof m === 'string',
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[JSON.stringify(props.form_data.metrics)],
);
const selectedDimensions = useMemo(
() =>
[
...ensureIsArray(props.form_data.groupby),
...ensureIsArray(props.form_data.columns),
...(typeof props.form_data.x_axis === 'string'
? [props.form_data.x_axis]
: []),
].filter((d): d is string => typeof d === 'string'),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
JSON.stringify(props.form_data.groupby),
JSON.stringify(props.form_data.columns),
props.form_data.x_axis,
],
);
useEffect(() => {
props.actions.fetchCompatibility(
props.datasource.type,
props.datasource.id as number,
selectedMetrics,
selectedDimensions,
);
// props.datasource.id covers the saved-chart-loading case (M4)
}, [
props.datasource.id,
props.datasource.type,
selectedMetrics,
selectedDimensions,
]);
const addHistory = useCallback(
async ({ isReplace = false, title } = {}) => {
const formData = props.dashboardId

View File

@@ -40,11 +40,13 @@ import {
DatasourceModal,
ErrorAlert,
} from 'src/components';
import SemanticViewEditModal from 'src/features/semanticViews/SemanticViewEditModal';
import { Menu } from '@superset-ui/core/components/Menu';
import { Icons } from '@superset-ui/core/components/Icons';
import WarningIconWithTooltip from '@superset-ui/core/components/WarningIconWithTooltip';
import { URL_PARAMS } from 'src/constants';
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
import {
userHasPermission,
isUserAdmin,
@@ -68,6 +70,7 @@ interface ExtendedDatasource extends Datasource {
}>;
extra?: string;
health_check_message?: string;
cache_timeout?: number | null;
database?: {
id: number;
database_name: string;
@@ -375,7 +378,7 @@ class DatasourceControl extends PureComponent<
const canAccessSqlLab = userHasPermission(user, 'SQL Lab', 'menu_access');
const editText = t('Edit dataset');
const editText = t('Edit %s', datasetLabelLower());
const requestedQuery = {
datasourceKey: `${datasource.id}__${datasource.type}`,
sql: datasource.sql,
@@ -387,7 +390,9 @@ class DatasourceControl extends PureComponent<
label: !allowEdit ? (
<Tooltip
title={t(
'You must be a dataset owner in order to edit. Please reach out to a dataset owner to request modifications or edit access.',
'You must be a %s owner in order to edit. Please reach out to a %s owner to request modifications or edit access.',
datasetLabelLower(),
datasetLabelLower(),
)}
>
{editText}
@@ -402,7 +407,7 @@ class DatasourceControl extends PureComponent<
defaultDatasourceMenuItems.push({
key: CHANGE_DATASET,
label: t('Swap dataset'),
label: t('Swap %s', datasetLabelLower()),
});
if (!isMissingDatasource && canAccessSqlLab) {
@@ -481,7 +486,7 @@ class DatasourceControl extends PureComponent<
queryDatasourceMenuItems.push({
key: SAVE_AS_DATASET,
label: <span>{t('Save as dataset')}</span>,
label: <span>{t('Save as %s', datasetLabelLower())}</span>,
});
const queryDatasourceMenu = (
@@ -495,7 +500,7 @@ class DatasourceControl extends PureComponent<
const titleText =
isMissingDatasource && !datasource.name
? t('Missing dataset')
? t('Missing %s', datasetLabelLower())
: getDatasourceTitle(datasource);
const tooltip = titleText;
@@ -561,14 +566,15 @@ class DatasourceControl extends PureComponent<
) : (
<ErrorAlert
type="warning"
message={t('Missing dataset')}
message={t('Missing %s', datasetLabelLower())}
descriptionPre={false}
descriptionDetailsCollapsed={false}
descriptionDetails={
<>
<p>
{t(
'The dataset linked to this chart may have been deleted.',
'The %s linked to this chart may have been deleted.',
datasetLabelLower(),
)}
</p>
<p>
@@ -578,7 +584,7 @@ class DatasourceControl extends PureComponent<
this.handleMenuItemClick({ key: CHANGE_DATASET })
}
>
{t('Swap dataset')}
{t('Swap %s', datasetLabelLower())}
</Button>
</p>
</>
@@ -587,14 +593,31 @@ class DatasourceControl extends PureComponent<
)}
</div>
)}
{showEditDatasourceModal && (
<DatasourceModal
datasource={datasource}
show={showEditDatasourceModal}
onDatasourceSave={this.onDatasourceSave}
onHide={this.toggleEditDatasourceModal}
/>
)}
{showEditDatasourceModal &&
(datasource.type === DatasourceType.SemanticView ? (
<SemanticViewEditModal
show={showEditDatasourceModal}
onHide={this.toggleEditDatasourceModal}
onSave={() => {
if (this.props.onDatasourceSave) {
this.props.onDatasourceSave(datasource);
}
}}
semanticView={{
id: datasource.id,
table_name: datasource.name,
description: datasource.description,
cache_timeout: datasource.cache_timeout,
}}
/>
) : (
<DatasourceModal
datasource={datasource}
show={showEditDatasourceModal}
onDatasourceSave={this.onDatasourceSave}
onHide={this.toggleEditDatasourceModal}
/>
))}
{showChangeDatasourceModal && (
<ChangeDatasourceModal
onDatasourceSave={this.onDatasourceSave}

View File

@@ -142,6 +142,10 @@ const ColumnSelectPopover = ({
const datasourceType = useSelector<ExplorePageState, string | undefined>(
state => state.explore.datasource.type,
);
const compatibleDimensions = useSelector<
ExplorePageState,
string[] | null | undefined
>(state => state.explore.compatibleDimensions);
const [initialLabel] = useState(label);
const [initialAdhocColumn, initialCalculatedColumn, initialSimpleColumn] =
getInitialColumnValues(editedColumn);
@@ -167,21 +171,22 @@ const ColumnSelectPopover = ({
const sqlEditorRef = useRef<editors.EditorHandle>(null);
const [calculatedColumns, simpleColumns] = useMemo(
() =>
columns?.reduce(
(acc: [ColumnMeta[], ColumnMeta[]], column: ColumnMeta) => {
if (column.expression) {
acc[0].push(column);
} else {
acc[1].push(column);
}
return acc;
},
[[], []],
),
[columns],
);
const [calculatedColumns, simpleColumns] = useMemo(() => {
const [calc, simple] = (columns ?? []).reduce(
(acc: [ColumnMeta[], ColumnMeta[]], column: ColumnMeta) => {
if (column.expression) {
acc[0].push(column);
} else {
acc[1].push(column);
}
return acc;
},
[[], []],
);
const alpha = (a: ColumnMeta, b: ColumnMeta) =>
(a.column_name ?? '').localeCompare(b.column_name ?? '');
return [calc.sort(alpha), simple.sort(alpha)];
}, [columns]);
// Filter metrics that are already selected in the chart
const availableMetrics = useMemo(() => {
@@ -551,6 +556,11 @@ const ColumnSelectPopover = ({
key: `column-${simpleColumn.column_name}`,
column_name: simpleColumn.column_name,
verbose_name: simpleColumn.verbose_name ?? '',
disabled:
compatibleDimensions != null &&
!compatibleDimensions.includes(
simpleColumn.column_name,
),
})),
...availableMetrics.map(metric => ({
value: metric.metric_name,
@@ -565,6 +575,9 @@ const ColumnSelectPopover = ({
key: `metric-${metric.metric_name}`,
metric_name: metric.metric_name,
verbose_name: metric.verbose_name ?? '',
disabled:
compatibleDimensions != null &&
!compatibleDimensions.includes(metric.metric_name),
})),
]}
optionFilterProps={[

View File

@@ -23,6 +23,7 @@ import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilt
import { OptionSortType } from 'src/explore/types';
import { useGetTimeRangeLabel } from 'src/explore/components/controls/FilterControl/utils';
import OptionWrapper from './OptionWrapper';
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
export interface DndAdhocFilterOptionProps {
adhocFilter: AdhocFilter;
@@ -68,7 +69,10 @@ export default function DndAdhocFilterOption({
isExtra={adhocFilter.isExtra}
datasourceWarningMessage={
adhocFilter.datasourceWarning
? t('This filter might be incompatible with current dataset')
? t(
'This filter might be incompatible with current %s',
datasetLabelLower(),
)
: undefined
}
/>

View File

@@ -38,6 +38,7 @@ import AdhocMetric from 'src/explore/components/controls/MetricControl/AdhocMetr
import MetricDefinitionValue from 'src/explore/components/controls/MetricControl/MetricDefinitionValue';
import ColumnSelectPopoverTrigger from './ColumnSelectPopoverTrigger';
import { DndControlProps } from './types';
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
const AGGREGATED_DECK_GL_CHART_TYPES = [
'deck_screengrid',
@@ -129,6 +130,16 @@ function DndColumnMetricSelect(props: DndColumnMetricSelectProps) {
formData,
} = props;
// Semantic views do not support arbitrary SQL expressions as dimensions.
// Merge 'sqlExpression' into disabledTabs so the Custom SQL tab is hidden.
const effectiveDisabledTabs = useMemo(
() =>
datasource?.type === 'semantic_view'
? new Set([...(disabledTabs ?? []), 'sqlExpression'])
: disabledTabs,
[datasource?.type, disabledTabs],
);
const [newColumnPopoverVisible, setNewColumnPopoverVisible] = useState(false);
const combinedOptionsMap = useMemo(() => {
@@ -303,7 +314,7 @@ function DndColumnMetricSelect(props: DndColumnMetricSelectProps) {
}}
editedColumn={column}
isTemporal={isTemporal}
disabledTabs={disabledTabs}
disabledTabs={effectiveDisabledTabs}
>
<OptionWrapper
key={`column-${idx}`}
@@ -326,7 +337,10 @@ function DndColumnMetricSelect(props: DndColumnMetricSelectProps) {
typeof item === 'object' &&
'error_text' in item &&
item.error_text)
? t('This metric might be incompatible with current dataset')
? t(
'This metric might be incompatible with current %s',
datasetLabelLower(),
)
: undefined;
return (
@@ -440,7 +454,7 @@ function DndColumnMetricSelect(props: DndColumnMetricSelectProps) {
togglePopover={toggleColumnPopover}
closePopover={closeColumnPopover}
isTemporal={false}
disabledTabs={disabledTabs}
disabledTabs={effectiveDisabledTabs}
metrics={savedMetrics}
selectedMetrics={selectedMetrics}
>

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { t } from '@apache-superset/core/translation';
import { AdhocColumn, QueryFormColumn, isAdhocColumn } from '@superset-ui/core';
import { tn } from '@apache-superset/core/translation';
@@ -27,8 +28,10 @@ import OptionWrapper from 'src/explore/components/controls/DndColumnSelectContro
import { OptionSelector } from 'src/explore/components/controls/DndColumnSelectControl/utils';
import { DatasourcePanelDndItem } from 'src/explore/components/DatasourcePanel/types';
import { DndItemType } from 'src/explore/components/DndItemType';
import { ExplorePageState } from 'src/explore/types';
import ColumnSelectPopoverTrigger from './ColumnSelectPopoverTrigger';
import { DndControlProps } from './types';
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
export type DndColumnSelectProps = DndControlProps<QueryFormColumn> & {
options: ColumnMeta[];
@@ -49,6 +52,19 @@ function DndColumnSelect(props: DndColumnSelectProps) {
isTemporal,
disabledTabs,
} = props;
// Semantic views do not support arbitrary SQL expressions as dimensions.
const datasourceType = useSelector<ExplorePageState, string | undefined>(
state => state.explore.datasource?.type,
);
const effectiveDisabledTabs = useMemo(
() =>
datasourceType === 'semantic_view'
? new Set([...(disabledTabs ?? []), 'sqlExpression'])
: disabledTabs,
[datasourceType, disabledTabs],
);
const [newColumnPopoverVisible, setNewColumnPopoverVisible] = useState(false);
const optionSelector = useMemo(() => {
@@ -103,7 +119,10 @@ function DndColumnSelect(props: DndColumnSelectProps) {
optionSelector.values.map((column, idx) => {
const datasourceWarningMessage =
isAdhocColumn(column) && column.datasourceWarning
? t('This column might be incompatible with current dataset')
? t(
'This column might be incompatible with current %s',
datasetLabelLower(),
)
: undefined;
const withCaret = isAdhocColumn(column) || !column.error_text;
@@ -121,7 +140,7 @@ function DndColumnSelect(props: DndColumnSelectProps) {
}}
editedColumn={column}
isTemporal={isTemporal}
disabledTabs={disabledTabs}
disabledTabs={effectiveDisabledTabs}
>
<OptionWrapper
key={idx}
@@ -205,7 +224,7 @@ function DndColumnSelect(props: DndColumnSelectProps) {
closePopover={closePopover}
visible={newColumnPopoverVisible}
isTemporal={isTemporal}
disabledTabs={disabledTabs}
disabledTabs={effectiveDisabledTabs}
>
<div />
</ColumnSelectPopoverTrigger>

View File

@@ -41,6 +41,7 @@ import { DndItemType } from 'src/explore/components/DndItemType';
import DndSelectLabel from 'src/explore/components/controls/DndColumnSelectControl/DndSelectLabel';
import { savedMetricType } from 'src/explore/components/controls/MetricControl/types';
import { AGGREGATES } from 'src/explore/constants';
import { datasetLabelLower } from 'src/utils/semanticLayerLabels';
const EMPTY_OBJECT = {};
const DND_ACCEPTED_TYPES = [DndItemType.Column, DndItemType.Metric];
@@ -77,7 +78,10 @@ const coerceMetrics = (
) {
return {
metric_name: metric,
error_text: t('This metric might be incompatible with current dataset'),
error_text: t(
'This metric might be incompatible with current %s',
datasetLabelLower(),
),
uuid: nanoid(),
};
}
@@ -128,6 +132,26 @@ const DndMetricSelect = (props: any) => {
return extra;
}, [datasource?.extra]);
// Semantic views do not support arbitrary SQL expressions as metrics.
const disallowAdhocMetrics =
extra.disallow_adhoc_metrics || datasource?.type === 'semantic_view';
// AdhocMetricEditPopover reads `datasource.extra.disallow_adhoc_metrics`
// directly, so we need to inject the flag there too — not just in canDrop.
const datasourceForPopover = useMemo(() => {
if (!disallowAdhocMetrics || !datasource) return datasource;
let parsedExtra: Record<string, unknown> = {};
if (datasource.extra) {
try {
parsedExtra = JSON.parse(datasource.extra as string);
} catch {} // eslint-disable-line no-empty
}
return {
...datasource,
extra: JSON.stringify({ ...parsedExtra, disallow_adhoc_metrics: true }),
};
}, [disallowAdhocMetrics, datasource]);
const savedMetricSet = useMemo(
() =>
new Set(
@@ -180,7 +204,7 @@ const DndMetricSelect = (props: any) => {
const canDrop = useCallback(
(item: DatasourcePanelDndItem) => {
if (
extra.disallow_adhoc_metrics &&
disallowAdhocMetrics &&
(item.type !== DndItemType.Metric ||
!savedMetricSet.has(item.value.metric_name))
) {
@@ -289,14 +313,17 @@ const DndMetricSelect = (props: any) => {
columns={props.columns}
savedMetrics={props.savedMetrics}
savedMetricsOptions={getSavedMetricOptionsForMetric(index)}
datasource={props.datasource}
datasource={datasourceForPopover}
onMoveLabel={moveLabel}
onDropLabel={handleDropLabel}
type={`${DndItemType.AdhocMetricOption}_${props.name}_${props.label}`}
multi={multi}
datasourceWarningMessage={
option instanceof AdhocMetric && option.datasourceWarning
? t('This metric might be incompatible with current dataset')
? t(
'This metric might be incompatible with current %s',
datasetLabelLower(),
)
: undefined
}
/>
@@ -395,7 +422,7 @@ const DndMetricSelect = (props: any) => {
columns={props.columns}
savedMetricsOptions={newSavedMetricOptions}
savedMetric={EMPTY_OBJECT as savedMetricType}
datasource={props.datasource}
datasource={datasourceForPopover}
isControlledComponent
visible={newMetricPopoverVisible}
togglePopover={togglePopover}

View File

@@ -415,21 +415,25 @@ export default class AdhocFilterEditPopover extends Component<
</ErrorBoundary>
),
},
{
key: ExpressionTypes.Sql,
label: t('Custom SQL'),
children: (
<ErrorBoundary>
<AdhocFilterEditPopoverSqlTabContent
adhocFilter={this.state.adhocFilter}
onChange={this.onAdhocFilterChange}
options={this.props.options}
height={this.state.height}
datasource={datasource}
/>
</ErrorBoundary>
),
},
...(datasource?.type === 'semantic_view'
? []
: [
{
key: ExpressionTypes.Sql,
label: t('Custom SQL'),
children: (
<ErrorBoundary>
<AdhocFilterEditPopoverSqlTabContent
adhocFilter={this.state.adhocFilter}
onChange={this.onAdhocFilterChange}
options={this.props.options}
height={this.state.height}
datasource={datasource}
/>
</ErrorBoundary>
),
},
]),
]}
/>
{hasDeckSlices && (

View File

@@ -18,6 +18,7 @@
*/
/* eslint-disable camelcase */
import { PureComponent, createRef } from 'react';
import { useSelector } from 'react-redux';
import { isDefined, ensureIsArray, DatasourceType } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import type { editors } from '@apache-superset/core';
@@ -94,6 +95,8 @@ interface AdhocMetricEditPopoverProps {
datasource?: DatasourceInfo;
isNewMetric?: boolean;
isLabelModified?: boolean;
/** Names of metrics the user may select; null means no filtering. */
compatibleMetrics?: string[] | null;
}
interface AdhocMetricEditPopoverState {
@@ -123,7 +126,7 @@ const StyledSelect = styled(Select)`
export const SAVED_TAB_KEY = 'SAVED';
export default class AdhocMetricEditPopover extends PureComponent<
class AdhocMetricEditPopover extends PureComponent<
AdhocMetricEditPopoverProps,
AdhocMetricEditPopoverState
> {
@@ -438,15 +441,24 @@ export default class AdhocMetricEditPopover extends PureComponent<
ensureIsArray(savedMetricsOptions).length > 0 ? (
<FormItem label={t('Saved metric')}>
<StyledSelect
options={ensureIsArray(savedMetricsOptions).map(
savedMetric => ({
options={[...ensureIsArray(savedMetricsOptions)]
.sort((a, b) =>
(a.metric_name ?? '').localeCompare(
b.metric_name ?? '',
),
)
.map(savedMetric => ({
value: savedMetric.metric_name,
label: this.renderMetricOption(savedMetric),
key: savedMetric.id,
metric_name: savedMetric.metric_name,
verbose_name: savedMetric.verbose_name ?? '',
}),
)}
disabled:
this.props.compatibleMetrics != null &&
!this.props.compatibleMetrics.includes(
savedMetric.metric_name,
),
}))}
optionFilterProps={['metric_name', 'verbose_name']}
{...savedSelectProps}
/>
@@ -596,3 +608,20 @@ export default class AdhocMetricEditPopover extends PureComponent<
}
// @ts-expect-error - defaultProps for backward compatibility
AdhocMetricEditPopover.defaultProps = defaultProps;
// ---------------------------------------------------------------------------
// Thin functional wrapper that injects compatibility data from Redux.
// AdhocMetricEditPopover is a class component and cannot use hooks directly.
// ---------------------------------------------------------------------------
function AdhocMetricEditPopoverWithRedux(props: AdhocMetricEditPopoverProps) {
const compatibleMetrics = useSelector(
(state: any) =>
state.explore?.compatibleMetrics as string[] | null | undefined,
);
return (
<AdhocMetricEditPopover {...props} compatibleMetrics={compatibleMetrics} />
);
}
export { AdhocMetricEditPopover };
export default AdhocMetricEditPopoverWithRedux;

View File

@@ -64,6 +64,7 @@ import {
validateNonEmpty,
} from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { datasetLabel } from 'src/utils/semanticLayerLabels';
import { formatSelectOptions } from 'src/explore/exploreUtils';
import { TIME_FILTER_LABELS } from './constants';
import { StyledColumnOption } from './components/optionRenderers';
@@ -214,7 +215,7 @@ export const controls = {
datasource: {
type: 'DatasourceControl',
label: t('Dataset'),
label: datasetLabel(),
default: null,
description: null,
mapStateToProps: ({ datasource }: ControlState) => ({

View File

@@ -65,6 +65,9 @@ export interface ExploreState {
metadata?: {
owners?: string[] | null;
};
compatibleMetrics?: string[] | null;
compatibleDimensions?: string[] | null;
compatibilityLoading?: boolean;
saveAction?: SaveActionType | null;
}
@@ -165,6 +168,13 @@ interface SetForceQueryAction {
force: boolean;
}
interface SetCompatibilityAction {
type: typeof actions.SET_COMPATIBILITY;
compatibleMetrics: string[] | null;
compatibleDimensions: string[] | null;
compatibilityLoading: boolean;
}
type ExploreAction =
| DynamicPluginControlsReadyAction
| ToggleFaveStarAction
@@ -183,6 +193,7 @@ type ExploreAction =
| SetStashFormDataAction
| SliceUpdatedAction
| SetForceQueryAction
| SetCompatibilityAction
| HydrateExplore;
// Extended control state for dynamic form controls - uses Record for flexibility
@@ -621,6 +632,15 @@ export default function exploreReducer(
force: typedAction.force,
};
},
[actions.SET_COMPATIBILITY]() {
const typedAction = action as SetCompatibilityAction;
return {
...state,
compatibleMetrics: typedAction.compatibleMetrics,
compatibleDimensions: typedAction.compatibleDimensions,
compatibilityLoading: typedAction.compatibilityLoading,
};
},
[HYDRATE_EXPLORE]() {
const typedAction = action as HydrateExplore;
return {

View File

@@ -71,6 +71,8 @@ export type OptionSortType = Partial<
export type Datasource = Dataset & {
database?: DatabaseObject;
/** The parent resource that owns this datasource (database or semantic layer). */
parent?: { name: string };
datasource?: string;
catalog?: string | null;
schema?: string;
@@ -128,6 +130,9 @@ export interface ExplorePageState {
standalone: boolean;
force: boolean;
common: JsonObject;
compatibleMetrics?: string[] | null;
compatibleDimensions?: string[] | null;
compatibilityLoading?: boolean;
};
sliceEntities?: JsonObject; // propagated from Dashboard view
}

View File

@@ -34,6 +34,7 @@ import {
MenuObjectProps,
MenuData,
} from 'src/types/bootstrapTypes';
import { datasetsLabel } from 'src/utils/semanticLayerLabels';
import RightMenu from './RightMenu';
import { NAVBAR_MENU_POPUP_OFFSET } from './commonMenuData';
@@ -221,7 +222,7 @@ export function Menu({
setActiveTabs(['Charts']);
break;
case path.startsWith(Paths.Datasets):
setActiveTabs(['Datasets']);
setActiveTabs([datasetsLabel()]);
break;
case path.startsWith(Paths.SqlLab) || path.startsWith(Paths.SavedQueries):
setActiveTabs(['SQL']);
@@ -399,6 +400,12 @@ export default function MenuWrapper({ data, ...rest }: MenuProps) {
Manage: true,
};
// Remap labels that depend on feature flags so they stay in sync with
// the active-tab key used in the Menu component above.
const labelOverrides: Record<string, () => string> = {
Datasets: datasetsLabel,
};
// Cycle through menu.menu to build out cleanedMenu and settings
const cleanedMenu: MenuObjectProps[] = [];
const settings: MenuObjectProps[] = [];
@@ -410,6 +417,10 @@ export default function MenuWrapper({ data, ...rest }: MenuProps) {
const children: (MenuObjectProps | string)[] = [];
const newItem = {
...item,
// Apply any label override for this item (keyed by FAB internal name).
...(item.name && labelOverrides[item.name]
? { label: labelOverrides[item.name]() }
: {}),
};
// Filter childs

View File

@@ -21,24 +21,9 @@ import { t } from '@apache-superset/core/translation';
import { SupersetClient, getClientErrorObject } from '@superset-ui/core';
import { Input, Select, Button } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { JsonForms, withJsonFormsControlProps } from '@jsonforms/react';
import type {
JsonSchema,
UISchemaElement,
ControlProps,
} from '@jsonforms/core';
import {
rankWith,
and,
isStringControl,
formatIs,
schemaMatches,
} from '@jsonforms/core';
import {
rendererRegistryEntries,
cellRegistryEntries,
TextControl,
} from '@great-expectations/jsonforms-antd-renderers';
import { JsonForms } from '@jsonforms/react';
import type { JsonSchema, UISchemaElement } from '@jsonforms/core';
import { cellRegistryEntries } from '@great-expectations/jsonforms-antd-renderers';
import type { ErrorObject } from 'ajv';
import {
StandardModal,
@@ -46,229 +31,19 @@ import {
MODAL_STANDARD_WIDTH,
MODAL_MEDIUM_WIDTH,
} from 'src/components/Modal';
/**
* Custom renderer that renders `Input.Password` for fields with
* `format: "password"` in the JSON Schema (e.g. Pydantic `SecretStr`).
*/
function PasswordControl(props: ControlProps) {
const uischema = {
...props.uischema,
options: { ...props.uischema.options, type: 'password' },
};
return TextControl({ ...props, uischema });
}
const PasswordRenderer = withJsonFormsControlProps(PasswordControl);
const passwordEntry = {
tester: rankWith(3, and(isStringControl, formatIs('password'))),
renderer: PasswordRenderer,
};
/**
* Renderer for `const` properties (e.g. Pydantic discriminator fields).
* Renders nothing visually but ensures the const value is set in form data,
* so discriminated unions resolve correctly on the backend.
*/
function ConstControl({ data, handleChange, path, schema }: ControlProps) {
const constValue = (schema as Record<string, unknown>).const;
useEffect(() => {
if (constValue !== undefined && data !== constValue) {
handleChange(path, constValue);
}
}, [constValue, data, handleChange, path]);
return null;
}
const ConstRenderer = withJsonFormsControlProps(ConstControl);
const constEntry = {
tester: rankWith(
10,
schemaMatches(s => s !== undefined && 'const' in s),
),
renderer: ConstRenderer,
};
/**
* Checks whether all dependency values are filled (non-empty).
* Handles nested objects (like auth) by checking they have at least one key.
*/
function areDependenciesSatisfied(
dependencies: string[],
data: Record<string, unknown>,
): boolean {
return dependencies.every(dep => {
const value = data[dep];
if (value === null || value === undefined || value === '') return false;
if (typeof value === 'object' && Object.keys(value).length === 0)
return false;
return true;
});
}
/**
* Renderer for fields marked `x-dynamic` in the JSON Schema.
* Shows a loading spinner inside the input while the schema is being
* refreshed with dynamic values from the backend.
*/
function DynamicFieldControl(props: ControlProps) {
const { refreshingSchema, formData: cfgData } = props.config ?? {};
const deps = (props.schema as Record<string, unknown>)?.['x-dependsOn'];
const refreshing =
refreshingSchema &&
Array.isArray(deps) &&
areDependenciesSatisfied(
deps as string[],
(cfgData as Record<string, unknown>) ?? {},
);
if (!refreshing) {
return TextControl(props);
}
const uischema = {
...props.uischema,
options: {
...props.uischema.options,
placeholderText: t('Loading...'),
inputProps: { suffix: <Icons.LoadingOutlined iconSize="s" /> },
},
};
return TextControl({ ...props, uischema, enabled: false });
}
const DynamicFieldRenderer = withJsonFormsControlProps(DynamicFieldControl);
const dynamicFieldEntry = {
tester: rankWith(
3,
and(
isStringControl,
schemaMatches(
s => (s as Record<string, unknown>)?.['x-dynamic'] === true,
),
),
),
renderer: DynamicFieldRenderer,
};
const renderers = [
...rendererRegistryEntries,
passwordEntry,
constEntry,
dynamicFieldEntry,
];
import {
renderers,
sanitizeSchema,
buildUiSchema,
getDynamicDependencies,
areDependenciesSatisfied,
serializeDependencyValues,
SCHEMA_REFRESH_DEBOUNCE_MS,
} from './jsonFormsHelpers';
type Step = 'type' | 'config';
type ValidationMode = 'ValidateAndHide' | 'ValidateAndShow';
const SCHEMA_REFRESH_DEBOUNCE_MS = 500;
/**
* Removes empty `enum` arrays from schema properties. The JSON Schema spec
* requires `enum` to have at least one item, and AJV rejects empty arrays.
* Fields with empty enums are rendered as plain text inputs instead.
*/
function sanitizeSchema(schema: JsonSchema): JsonSchema {
if (!schema.properties) return schema;
const properties: Record<string, JsonSchema> = {};
for (const [key, prop] of Object.entries(schema.properties)) {
if (
typeof prop === 'object' &&
prop !== null &&
'enum' in prop &&
Array.isArray(prop.enum) &&
prop.enum.length === 0
) {
const { enum: _empty, ...rest } = prop;
properties[key] = rest;
} else {
properties[key] = prop as JsonSchema;
}
}
return { ...schema, properties } as JsonSchema;
}
/**
* Builds a JSON Forms UI schema from a JSON Schema, using the first
* `examples` entry as placeholder text for each string property.
*/
function buildUiSchema(schema: JsonSchema): UISchemaElement | undefined {
if (!schema.properties) return undefined;
// Use explicit property order from backend if available,
// otherwise fall back to the JSON object key order
const propertyOrder: string[] =
((schema as Record<string, unknown>)['x-propertyOrder'] as string[]) ??
Object.keys(schema.properties);
const elements = propertyOrder
.filter(key => key in (schema.properties ?? {}))
.map(key => {
const prop = schema.properties![key];
const control: Record<string, unknown> = {
type: 'Control',
scope: `#/properties/${key}`,
};
if (typeof prop === 'object' && prop !== null) {
const options: Record<string, unknown> = {};
if (
'examples' in prop &&
Array.isArray(prop.examples) &&
prop.examples.length > 0
) {
options.placeholderText = String(prop.examples[0]);
}
if ('description' in prop && typeof prop.description === 'string') {
options.tooltip = prop.description;
}
if (Object.keys(options).length > 0) {
control.options = options;
}
}
return control;
});
return { type: 'VerticalLayout', elements } as UISchemaElement;
}
/**
* Extracts dynamic field dependency mappings from the schema.
* Returns a map of field name → list of dependency field names.
*/
function getDynamicDependencies(schema: JsonSchema): Record<string, string[]> {
const deps: Record<string, string[]> = {};
if (!schema.properties) return deps;
for (const [key, prop] of Object.entries(schema.properties)) {
if (
typeof prop === 'object' &&
prop !== null &&
'x-dynamic' in prop &&
'x-dependsOn' in prop &&
Array.isArray((prop as Record<string, unknown>)['x-dependsOn'])
) {
deps[key] = (prop as Record<string, unknown>)['x-dependsOn'] as string[];
}
}
return deps;
}
/**
* Serializes the dependency values for a set of fields into a stable string
* for comparison, so we only re-fetch when dependency values actually change.
*/
function serializeDependencyValues(
dynamicDeps: Record<string, string[]>,
data: Record<string, unknown>,
): string {
const allDepKeys = new Set<string>();
for (const deps of Object.values(dynamicDeps)) {
for (const dep of deps) {
allDepKeys.add(dep);
}
}
const snapshot: Record<string, unknown> = {};
for (const key of [...allDepKeys].sort()) {
snapshot[key] = data[key];
}
return JSON.stringify(snapshot);
}
interface SemanticLayerType {
id: string;
name: string;
@@ -382,13 +157,13 @@ export default function SemanticLayerModal({
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.
// In edit mode, fetch the enriched schema using the full saved
// configuration so that dynamic dropdowns (account, project,
// environment) show their human-readable labels immediately rather
// than flashing raw IDs while the background refresh completes.
const { json: schemaJson } = await SupersetClient.post({
endpoint: '/api/v1/semantic_layer/schema/configuration',
jsonPayload: { type: layer.type },
jsonPayload: { type: layer.type, configuration: layer.configuration },
});
applySchema(schemaJson.result);
setStep('config');
@@ -499,7 +274,7 @@ export default function SemanticLayerModal({
// Check if any dynamic field has all dependencies satisfied
const hasSatisfiedDeps = Object.values(dynamicDeps).some(deps =>
areDependenciesSatisfied(deps, data),
areDependenciesSatisfied(deps, data, configSchema ?? undefined),
);
if (!hasSatisfiedDeps) return;
@@ -513,7 +288,7 @@ export default function SemanticLayerModal({
fetchConfigSchema(selectedType, data);
}, SCHEMA_REFRESH_DEBOUNCE_MS);
},
[selectedType, fetchConfigSchema],
[selectedType, fetchConfigSchema, configSchema],
);
const handleFormChange = useCallback(
@@ -598,16 +373,25 @@ export default function SemanticLayerModal({
/>
</ModalFormField>
{configSchema && (
<JsonForms
schema={configSchema}
uischema={uiSchema}
data={formData}
renderers={renderers}
cells={cellRegistryEntries}
config={{ refreshingSchema, formData }}
validationMode={validationMode}
onChange={handleFormChange}
/>
// Wrap in a form with autocomplete="off" so browsers do not
// autofill credential fields (service token, account, etc.).
// eslint-disable-next-line jsx-a11y/no-redundant-roles
<form
role="presentation"
autoComplete="off"
onSubmit={e => e.preventDefault()}
>
<JsonForms
schema={configSchema}
uischema={uiSchema}
data={formData}
renderers={renderers}
cells={cellRegistryEntries}
config={{ refreshingSchema, formData }}
validationMode={validationMode}
onChange={handleFormChange}
/>
</form>
)}
</>
)}

View File

@@ -0,0 +1,386 @@
/**
* 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 { useEffect } from 'react';
import { t } from '@apache-superset/core/translation';
import { Spin, Select, Form } from 'antd';
import { withJsonFormsControlProps } from '@jsonforms/react';
import type {
JsonSchema,
UISchemaElement,
ControlProps,
} from '@jsonforms/core';
import {
rankWith,
and,
isStringControl,
formatIs,
schemaMatches,
} from '@jsonforms/core';
import {
rendererRegistryEntries,
TextControl,
} from '@great-expectations/jsonforms-antd-renderers';
export const SCHEMA_REFRESH_DEBOUNCE_MS = 500;
/**
* Custom renderer that renders `Input.Password` for fields with
* `format: "password"` in the JSON Schema (e.g. Pydantic `SecretStr`).
*/
function PasswordControl(props: ControlProps) {
const uischema = {
...props.uischema,
options: {
...props.uischema.options,
type: 'password',
inputProps: {
...((props.uischema.options?.inputProps as Record<string, unknown>) ??
{}),
// Prevent browsers from autofilling stored login passwords into
// service-token fields. 'new-password' is respected even when
// 'off' is ignored (Chrome ≥ 34).
autoComplete: 'new-password',
},
},
};
return TextControl({ ...props, uischema });
}
const PasswordRenderer = withJsonFormsControlProps(PasswordControl);
const passwordEntry = {
tester: rankWith(3, and(isStringControl, formatIs('password'))),
renderer: PasswordRenderer,
};
/**
* Renderer for `const` properties (e.g. Pydantic discriminator fields).
* Renders nothing visually but ensures the const value is set in form data,
* so discriminated unions resolve correctly on the backend.
*/
function ConstControl({ data, handleChange, path, schema }: ControlProps) {
const constValue = (schema as Record<string, unknown>).const;
useEffect(() => {
if (constValue !== undefined && data !== constValue) {
handleChange(path, constValue);
}
}, [constValue, data, handleChange, path]);
return null;
}
const ConstRenderer = withJsonFormsControlProps(ConstControl);
const constEntry = {
tester: rankWith(
10,
schemaMatches(
s =>
s !== undefined &&
'const' in s &&
!(s as Record<string, unknown>).readOnly,
),
),
renderer: ConstRenderer,
};
/**
* Renderer for read-only fields (e.g. a fixed database that the admin locked).
* Renders a disabled input showing the current value. Also ensures the default
* value is injected into form data (like ConstControl does for hidden fields).
*/
function ReadOnlyControl({
data,
handleChange,
path,
schema,
...rest
}: ControlProps) {
const defaultValue =
(schema as Record<string, unknown>).const ??
(schema as Record<string, unknown>).default;
useEffect(() => {
if (defaultValue !== undefined && data !== defaultValue) {
handleChange(path, defaultValue);
}
}, [defaultValue, data, handleChange, path]);
return TextControl({
...rest,
data,
handleChange,
path,
schema,
enabled: false,
});
}
const ReadOnlyRenderer = withJsonFormsControlProps(ReadOnlyControl);
const readOnlyEntry = {
tester: rankWith(
11,
schemaMatches(
s => s !== undefined && (s as Record<string, unknown>).readOnly === true,
),
),
renderer: ReadOnlyRenderer,
};
/**
* Checks whether all dependency values are filled (non-empty).
* Handles nested objects (like auth) by checking they have at least one key.
*
* Fields that have a `default` in the schema are considered satisfied even
* when the user has not explicitly touched them yet — JsonForms does not
* write default values into `data` until a field is interacted with, so
* without this fallback a field like `admin_host` (which ships with a
* sensible default) would permanently block the refresh.
*/
export function areDependenciesSatisfied(
dependencies: string[],
data: Record<string, unknown>,
schema?: JsonSchema,
): boolean {
return dependencies.every(dep => {
const value = data[dep];
if (value !== null && value !== undefined && value !== '') {
if (typeof value === 'object' && Object.keys(value).length === 0)
return false;
return true;
}
// Fall back to the schema default when the field hasn't been touched yet.
const defaultValue = schema?.properties?.[dep]?.default;
return (
defaultValue !== null && defaultValue !== undefined && defaultValue !== ''
);
});
}
/**
* Renderer for fields marked `x-dynamic` in the JSON Schema.
* Shows a loading spinner inside the input while the schema is being
* refreshed with dynamic values from the backend.
*/
function DynamicFieldControl(props: ControlProps) {
const { refreshingSchema, formData: cfgData } = props.config ?? {};
const deps = (props.schema as Record<string, unknown>)?.['x-dependsOn'];
const refreshing =
refreshingSchema &&
Array.isArray(deps) &&
areDependenciesSatisfied(
deps as string[],
(cfgData as Record<string, unknown>) ?? {},
props.rootSchema,
);
if (!refreshing) {
return TextControl(props);
}
const uischema = {
...props.uischema,
options: {
...props.uischema.options,
placeholderText: t('Loading...'),
inputProps: { suffix: <Spin size="small" /> },
},
};
return TextControl({ ...props, uischema, enabled: false });
}
const DynamicFieldRenderer = withJsonFormsControlProps(DynamicFieldControl);
const dynamicFieldEntry = {
tester: rankWith(
3,
and(
isStringControl,
schemaMatches(
s => (s as Record<string, unknown>)?.['x-dynamic'] === true,
),
),
),
renderer: DynamicFieldRenderer,
};
/**
* Renderer for fields that carry an ``x-enumNames`` array alongside their
* ``enum`` values. Renders as an Antd Select showing human-readable labels
* (from ``x-enumNames``) while storing the underlying enum values in form
* data. Used for MetricFlow's integer-ID fields (account, project,
* environment) where the backend provides both IDs and display names.
*/
function EnumNamesControl(props: ControlProps) {
const { refreshingSchema } = props.config ?? {};
const schema = props.schema as Record<string, unknown>;
const enumValues = (schema.enum as unknown[]) ?? [];
const enumNames =
(schema['x-enumNames'] as string[]) ?? enumValues.map(String);
const options = enumValues.map((value, index) => ({
value,
label: enumNames[index] ?? String(value),
}));
const tooltip = (props.uischema?.options as Record<string, unknown>)
?.tooltip as string | undefined;
return (
<Form.Item label={props.label} tooltip={tooltip}>
<Select
value={props.data ?? null}
onChange={value => props.handleChange(props.path, value)}
options={options}
style={{ width: '100%' }}
disabled={!props.enabled}
allowClear
loading={!!refreshingSchema}
placeholder={
(props.uischema?.options as Record<string, unknown>)
?.placeholderText as string | undefined
}
/>
</Form.Item>
);
}
const EnumNamesRenderer = withJsonFormsControlProps(EnumNamesControl);
const enumNamesEntry = {
// Rank 5: higher than the default string renderer (23) so this fires
// whenever x-enumNames is present, regardless of the underlying type.
tester: rankWith(
5,
schemaMatches(s => {
const names = (s as Record<string, unknown>)['x-enumNames'];
return Array.isArray(names) && (names as unknown[]).length > 0;
}),
),
renderer: EnumNamesRenderer,
};
export const renderers = [
...rendererRegistryEntries,
passwordEntry,
constEntry,
readOnlyEntry,
enumNamesEntry,
dynamicFieldEntry,
];
/**
* Removes empty `enum` arrays from schema properties. The JSON Schema spec
* requires `enum` to have at least one item, and AJV rejects empty arrays.
* Fields with empty enums are rendered as plain text inputs instead.
*/
export function sanitizeSchema(schema: JsonSchema): JsonSchema {
if (!schema.properties) return schema;
const properties: Record<string, JsonSchema> = {};
for (const [key, prop] of Object.entries(schema.properties)) {
if (
typeof prop === 'object' &&
prop !== null &&
'enum' in prop &&
Array.isArray(prop.enum) &&
prop.enum.length === 0
) {
const { enum: _empty, ...rest } = prop;
properties[key] = rest;
} else {
properties[key] = prop as JsonSchema;
}
}
return { ...schema, properties } as JsonSchema;
}
/**
* Builds a JSON Forms UI schema from a JSON Schema, using the first
* `examples` entry as placeholder text for each string property.
*/
export function buildUiSchema(schema: JsonSchema): UISchemaElement | undefined {
if (!schema.properties) return undefined;
// Use explicit property order from backend if available,
// otherwise fall back to the JSON object key order
const propertyOrder: string[] =
((schema as Record<string, unknown>)['x-propertyOrder'] as string[]) ??
Object.keys(schema.properties);
const elements = propertyOrder
.filter(key => key in (schema.properties ?? {}))
.map(key => {
const prop = schema.properties![key];
const control: Record<string, unknown> = {
type: 'Control',
scope: `#/properties/${key}`,
};
if (typeof prop === 'object' && prop !== null) {
const options: Record<string, unknown> = {};
if (
'examples' in prop &&
Array.isArray(prop.examples) &&
prop.examples.length > 0
) {
options.placeholderText = String(prop.examples[0]);
}
if ('description' in prop && typeof prop.description === 'string') {
options.tooltip = prop.description;
}
if (Object.keys(options).length > 0) {
control.options = options;
}
}
return control;
});
return { type: 'VerticalLayout', elements } as UISchemaElement;
}
/**
* Extracts dynamic field dependency mappings from the schema.
* Returns a map of field name -> list of dependency field names.
*/
export function getDynamicDependencies(
schema: JsonSchema,
): Record<string, string[]> {
const deps: Record<string, string[]> = {};
if (!schema.properties) return deps;
for (const [key, prop] of Object.entries(schema.properties)) {
if (
typeof prop === 'object' &&
prop !== null &&
'x-dynamic' in prop &&
'x-dependsOn' in prop &&
Array.isArray((prop as Record<string, unknown>)['x-dependsOn'])
) {
deps[key] = (prop as Record<string, unknown>)['x-dependsOn'] as string[];
}
}
return deps;
}
/**
* Serializes the dependency values for a set of fields into a stable string
* for comparison, so we only re-fetch when dependency values actually change.
*/
export function serializeDependencyValues(
dynamicDeps: Record<string, string[]>,
data: Record<string, unknown>,
): string {
const allDepKeys = new Set<string>();
for (const deps of Object.values(dynamicDeps)) {
for (const dep of deps) {
allDepKeys.add(dep);
}
}
const snapshot: Record<string, unknown> = {};
for (const key of [...allDepKeys].sort()) {
snapshot[key] = data[key];
}
return JSON.stringify(snapshot);
}

View File

@@ -0,0 +1,512 @@
/**
* 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, useCallback, useRef } from 'react';
import { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
import { SupersetClient } from '@superset-ui/core';
import { Spin } from 'antd';
import { Select } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { JsonForms } from '@jsonforms/react';
import type { JsonSchema, UISchemaElement } from '@jsonforms/core';
import { cellRegistryEntries } from '@great-expectations/jsonforms-antd-renderers';
import type { ErrorObject } from 'ajv';
import {
StandardModal,
ModalFormField,
MODAL_STANDARD_WIDTH,
} from 'src/components/Modal';
import {
renderers,
sanitizeSchema,
buildUiSchema,
getDynamicDependencies,
areDependenciesSatisfied,
serializeDependencyValues,
SCHEMA_REFRESH_DEBOUNCE_MS,
} from 'src/features/semanticLayers/jsonFormsHelpers';
interface SemanticLayerOption {
uuid: string;
name: string;
}
interface AvailableView {
name: string;
already_added: boolean;
}
const ModalContent = styled.div`
padding: ${({ theme }) => theme.sizeUnit * 4}px;
`;
const LoadingContainer = styled.div`
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.sizeUnit * 4}px;
`;
const SectionLabel = styled.div`
color: ${({ theme }) => theme.colorText};
font-size: ${({ theme }) => theme.fontSize}px;
margin-top: ${({ theme }) => theme.sizeUnit}px;
margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px;
`;
const VerticalFormFields = styled.div`
margin-bottom: ${({ theme }) => theme.sizeUnit * 4}px;
/* The antd renderer's VerticalLayout creates its own <Form> —
force flex-column so gap controls spacing between fields */
&& form {
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 4}px;
}
/* Reset antd default margins so gap controls all spacing */
&& .ant-form-item {
margin-bottom: 0;
}
/* Override ant-form-item-horizontal: stack label above control */
&& .ant-form-item-row {
flex-direction: column;
align-items: stretch;
}
&& .ant-form-item-label {
text-align: left;
max-width: 100%;
flex: none;
padding-bottom: ${({ theme }) => theme.sizeUnit}px;
}
&& .ant-form-item-control {
max-width: 100%;
flex: auto;
}
&& .ant-form-item-label > label {
color: ${({ theme }) => theme.colorText};
font-size: ${({ theme }) => theme.fontSize}px;
}
`;
interface AddSemanticViewModalProps {
show: boolean;
onHide: () => void;
onSuccess: () => void;
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
}
export default function AddSemanticViewModal({
show,
onHide,
onSuccess,
addDangerToast,
addSuccessToast,
}: AddSemanticViewModalProps) {
// --- Layer ---
const [layers, setLayers] = useState<SemanticLayerOption[]>([]);
const [selectedLayerUuid, setSelectedLayerUuid] = useState<string | null>(
null,
);
const [loadingLayers, setLoadingLayers] = useState(false);
// --- Runtime config ---
const [runtimeSchema, setRuntimeSchema] = useState<JsonSchema | null>(null);
const [runtimeUiSchema, setRuntimeUiSchema] = useState<
UISchemaElement | undefined
>();
const [runtimeData, setRuntimeData] = useState<Record<string, unknown>>({});
const [loadingRuntime, setLoadingRuntime] = useState(false);
const [refreshingSchema, setRefreshingSchema] = useState(false);
const errorsRef = useRef<ErrorObject[]>([]);
const dynamicDepsRef = useRef<Record<string, string[]>>({});
const lastDepSnapshotRef = useRef('');
const schemaTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// --- Views ---
const [availableViews, setAvailableViews] = useState<AvailableView[]>([]);
const [selectedViewNames, setSelectedViewNames] = useState<string[]>([]);
const [loadingViews, setLoadingViews] = useState(false);
const viewsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastViewsKeyRef = useRef('');
// --- Misc ---
const [saving, setSaving] = useState(false);
const fetchGenRef = useRef(0);
// =========================================================================
// Fetch helpers
// =========================================================================
const fetchLayers = async () => {
setLoadingLayers(true);
try {
const { json } = await SupersetClient.get({
endpoint: '/api/v1/semantic_layer/',
});
setLayers(
(json.result ?? []).map((l: { uuid: string; name: string }) => ({
uuid: l.uuid,
name: l.name,
})),
);
} catch {
addDangerToast(t('An error occurred while fetching semantic layers'));
} finally {
setLoadingLayers(false);
}
};
const fetchViews = useCallback(
async (uuid: string, rData: Record<string, unknown>, gen: number) => {
setLoadingViews(true);
setAvailableViews([]);
setSelectedViewNames([]);
try {
const { json } = await SupersetClient.post({
endpoint: `/api/v1/semantic_layer/${uuid}/views`,
jsonPayload: { runtime_data: rData },
});
if (gen !== fetchGenRef.current) return;
setAvailableViews(json.result ?? []);
} catch {
if (gen !== fetchGenRef.current) return;
addDangerToast(t('An error occurred while fetching available views'));
} finally {
if (gen === fetchGenRef.current) setLoadingViews(false);
}
},
[addDangerToast],
);
const applyRuntimeSchema = useCallback((rawSchema: JsonSchema) => {
const schema = sanitizeSchema(rawSchema);
setRuntimeSchema(schema);
setRuntimeUiSchema(buildUiSchema(schema));
dynamicDepsRef.current = getDynamicDependencies(rawSchema);
}, []);
const scheduleFetchViews = useCallback(
(uuid: string, data: Record<string, unknown>) => {
const key = JSON.stringify(data);
if (key === lastViewsKeyRef.current) return;
lastViewsKeyRef.current = key;
if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current);
viewsTimerRef.current = setTimeout(() => {
fetchViews(uuid, data, fetchGenRef.current);
}, SCHEMA_REFRESH_DEBOUNCE_MS);
},
[fetchViews],
);
// =========================================================================
// Layer change — fetch runtime schema, clear downstream state
// =========================================================================
const handleLayerChange = useCallback(
async (uuid: string) => {
fetchGenRef.current += 1;
const gen = fetchGenRef.current;
setSelectedLayerUuid(uuid);
if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current);
setRuntimeSchema(null);
setRuntimeUiSchema(undefined);
setRuntimeData({});
errorsRef.current = [];
dynamicDepsRef.current = {};
lastDepSnapshotRef.current = '';
setAvailableViews([]);
setSelectedViewNames([]);
lastViewsKeyRef.current = '';
setLoadingRuntime(true);
try {
const { json } = await SupersetClient.post({
endpoint: `/api/v1/semantic_layer/${uuid}/schema/runtime`,
jsonPayload: {},
});
if (gen !== fetchGenRef.current) return;
const schema = json.result;
if (
!schema?.properties ||
Object.keys(schema.properties).length === 0
) {
// No runtime config needed — fetch views right away
fetchViews(uuid, {}, gen);
} else {
applyRuntimeSchema(schema);
}
} catch {
if (gen !== fetchGenRef.current) return;
addDangerToast(
t('An error occurred while fetching the runtime schema'),
);
} finally {
if (gen === fetchGenRef.current) setLoadingRuntime(false);
}
},
[applyRuntimeSchema, fetchViews, addDangerToast],
);
// =========================================================================
// Runtime form change — refresh dynamic fields or auto-fetch views
// =========================================================================
const handleRuntimeFormChange = useCallback(
({
data,
errors,
}: {
data: Record<string, unknown>;
errors?: ErrorObject[];
}) => {
setRuntimeData(data);
errorsRef.current = errors ?? [];
if (!selectedLayerUuid) return;
const gen = fetchGenRef.current;
// Dynamic deps changed → refresh schema (e.g. database → schema)
const dynamicDeps = dynamicDepsRef.current;
if (Object.keys(dynamicDeps).length > 0) {
const hasSatisfiedDeps = Object.values(dynamicDeps).some(deps =>
areDependenciesSatisfied(deps, data),
);
if (hasSatisfiedDeps) {
const snapshot = serializeDependencyValues(dynamicDeps, data);
if (snapshot !== lastDepSnapshotRef.current) {
lastDepSnapshotRef.current = snapshot;
// Config is changing — clear views
setAvailableViews([]);
setSelectedViewNames([]);
lastViewsKeyRef.current = '';
if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
const uuid = selectedLayerUuid;
schemaTimerRef.current = setTimeout(async () => {
setRefreshingSchema(true);
try {
const { json } = await SupersetClient.post({
endpoint: `/api/v1/semantic_layer/${uuid}/schema/runtime`,
jsonPayload: { runtime_data: data },
});
if (gen !== fetchGenRef.current) return;
applyRuntimeSchema(json.result);
} catch {
// Silent fail on refresh — form still works
} finally {
if (gen === fetchGenRef.current) setRefreshingSchema(false);
}
}, SCHEMA_REFRESH_DEBOUNCE_MS);
return;
}
}
}
// No schema refresh needed — fetch views if form is valid
if (errorsRef.current.length === 0) {
scheduleFetchViews(selectedLayerUuid, data);
}
},
[selectedLayerUuid, applyRuntimeSchema, scheduleFetchViews],
);
// After a schema refresh settles, JSON Forms re-validates and fires
// onChange → handleRuntimeFormChange handles view fetching. As a fallback
// (in case onChange doesn't fire), try once refreshingSchema flips false.
const prevRefreshingRef = useRef(false);
useEffect(() => {
if (prevRefreshingRef.current && !refreshingSchema && selectedLayerUuid) {
const timer = setTimeout(() => {
if (errorsRef.current.length === 0) {
scheduleFetchViews(selectedLayerUuid, runtimeData);
}
}, 100);
prevRefreshingRef.current = false;
return () => clearTimeout(timer);
}
prevRefreshingRef.current = refreshingSchema;
return undefined;
}, [refreshingSchema, selectedLayerUuid, runtimeData, scheduleFetchViews]);
// =========================================================================
// Modal open / close
// =========================================================================
useEffect(() => {
if (show) {
fetchLayers();
} else {
fetchGenRef.current += 1;
if (schemaTimerRef.current) clearTimeout(schemaTimerRef.current);
if (viewsTimerRef.current) clearTimeout(viewsTimerRef.current);
setLayers([]);
setSelectedLayerUuid(null);
setLoadingLayers(false);
setRuntimeSchema(null);
setRuntimeUiSchema(undefined);
setRuntimeData({});
setLoadingRuntime(false);
setRefreshingSchema(false);
errorsRef.current = [];
dynamicDepsRef.current = {};
lastDepSnapshotRef.current = '';
setAvailableViews([]);
setSelectedViewNames([]);
setLoadingViews(false);
setSaving(false);
lastViewsKeyRef.current = '';
}
}, [show]); // eslint-disable-line react-hooks/exhaustive-deps
// =========================================================================
// Save
// =========================================================================
const newViewCount = availableViews.filter(
v => selectedViewNames.includes(v.name) && !v.already_added,
).length;
const handleSave = async () => {
if (!selectedLayerUuid || newViewCount === 0) return;
setSaving(true);
try {
const viewsToCreate = availableViews
.filter(v => selectedViewNames.includes(v.name) && !v.already_added)
.map(v => ({
name: v.name,
semantic_layer_uuid: selectedLayerUuid,
configuration: runtimeData,
}));
await SupersetClient.post({
endpoint: '/api/v1/semantic_view/',
jsonPayload: { views: viewsToCreate },
});
addSuccessToast(t('%s semantic view(s) added', viewsToCreate.length));
onSuccess();
onHide();
} catch {
addDangerToast(t('An error occurred while adding semantic views'));
} finally {
setSaving(false);
}
};
// =========================================================================
// Render
// =========================================================================
const hasRuntimeFields =
runtimeSchema?.properties &&
Object.keys(runtimeSchema.properties).length > 0;
const viewsDisabled =
loadingViews || (!loadingViews && availableViews.length === 0);
return (
<StandardModal
show={show}
onHide={onHide}
onSave={handleSave}
title={t('Add Semantic View')}
icon={<Icons.PlusOutlined />}
width={MODAL_STANDARD_WIDTH}
saveDisabled={newViewCount === 0 || saving}
saveText={newViewCount > 0 ? t('Add %s view(s)', newViewCount) : t('Add')}
saveLoading={saving}
>
<ModalContent>
{/* Semantic Layer */}
<ModalFormField label={t('Semantic Layer')}>
<Select
ariaLabel={t('Semantic layer')}
placeholder={t('Select a semantic layer')}
loading={loadingLayers}
value={selectedLayerUuid}
onChange={value => handleLayerChange(value as string)}
options={layers.map(l => ({
value: l.uuid,
label: l.name,
}))}
getPopupContainer={() => document.body}
/>
</ModalFormField>
{/* Loading runtime schema */}
{loadingRuntime && (
<LoadingContainer>
<Spin size="small" />
</LoadingContainer>
)}
{/* Source location (runtime config fields) */}
{hasRuntimeFields && !loadingRuntime && (
<>
<SectionLabel>{t('Source location')}</SectionLabel>
<VerticalFormFields>
<JsonForms
schema={runtimeSchema!}
uischema={runtimeUiSchema}
data={runtimeData}
renderers={renderers}
cells={cellRegistryEntries}
config={{ refreshingSchema, formData: runtimeData }}
validationMode="ValidateAndHide"
onChange={handleRuntimeFormChange}
/>
</VerticalFormFields>
</>
)}
{/* Semantic Views — always visible once a layer is selected */}
{selectedLayerUuid && !loadingRuntime && (
<ModalFormField label={t('Semantic Views')}>
<Select
ariaLabel={t('Semantic views')}
placeholder={t('Select semantic views')}
mode="multiple"
loading={loadingViews}
disabled={viewsDisabled}
value={selectedViewNames}
onChange={values => setSelectedViewNames(values as string[])}
options={availableViews
.sort((a, b) => a.name.localeCompare(b.name))
.map(v => ({
value: v.name,
label: v.already_added
? `${v.name} (${t('already added')})`
: v.name,
disabled: v.already_added,
}))}
getPopupContainer={() => document.body}
/>
</ModalFormField>
)}
</ModalContent>
</StandardModal>
);
}

View File

@@ -33,8 +33,8 @@ interface SemanticViewEditModalProps {
show: boolean;
onHide: () => void;
onSave: () => void;
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
addDangerToast?: (msg: string) => void;
addSuccessToast?: (msg: string) => void;
semanticView: {
id: number;
table_name: string;
@@ -47,8 +47,8 @@ export default function SemanticViewEditModal({
show,
onHide,
onSave,
addDangerToast,
addSuccessToast,
addDangerToast = () => {},
addSuccessToast = () => {},
semanticView,
}: SemanticViewEditModalProps) {
const [description, setDescription] = useState<string>('');

View File

@@ -44,6 +44,7 @@ import {
DatasetSelectLabel,
} from 'src/features/datasets/DatasetSelectLabel';
import { Icons } from '@superset-ui/core/components/Icons';
import { datasetLabel, datasetLabelLower } from 'src/utils/semanticLayerLabels';
export interface ChartCreationProps extends RouteComponentProps {
user: UserWithPermissionsAndRoles;
@@ -332,18 +333,22 @@ export class ChartCreation extends PureComponent<
<h3>{t('Create a new chart')}</h3>
<Steps direction="vertical" size="small">
<Steps.Step
title={<StyledStepTitle>{t('Choose a dataset')}</StyledStepTitle>}
title={
<StyledStepTitle>
{t('Choose a %s', datasetLabelLower())}
</StyledStepTitle>
}
status={this.state.datasource?.value ? 'finish' : 'process'}
description={
<StyledStepDescription className="dataset">
<AsyncSelect
autoFocus
ariaLabel={t('Dataset')}
ariaLabel={datasetLabel()}
name="select-datasource"
onChange={this.changeDatasource}
options={this.loadDatasources}
optionFilterProps={['id', 'table_name']}
placeholder={t('Choose a dataset')}
placeholder={t('Choose a %s', datasetLabelLower())}
showSearch
value={this.state.datasource}
/>
@@ -370,7 +375,10 @@ export class ChartCreation extends PureComponent<
<div className="footer">
{isButtonDisabled && (
<span>
{t('Please select both a Dataset and a Chart type to proceed')}
{t(
'Please select both a %s and a Chart type to proceed',
datasetLabel(),
)}
</span>
)}
<Button

View File

@@ -83,6 +83,7 @@ import { findPermission } from 'src/utils/findPermission';
import { QueryObjectColumns } from 'src/views/CRUD/types';
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
import { Tag } from 'src/components/Tag';
import { datasetLabel } from 'src/utils/semanticLayerLabels';
const FlexRowContainer = styled.div`
align-items: center;
@@ -430,7 +431,7 @@ function ChartList(props: ChartListProps) {
</Tooltip>
);
},
Header: t('Dataset'),
Header: datasetLabel(),
accessor: 'datasource_id',
disableSortBy: true,
size: 'xl',
@@ -658,7 +659,7 @@ function ChartList(props: ChartListProps) {
}),
},
{
Header: t('Dataset'),
Header: datasetLabel(),
key: 'dataset',
id: 'datasource_id',
input: 'select',

View File

@@ -70,6 +70,11 @@ import { QueryObjectColumns } from 'src/views/CRUD/types';
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
import type Owner from 'src/types/Owner';
import {
databaseLabel,
databaseLabelLower,
databasesLabel,
} from 'src/utils/semanticLayerLabels';
const extensionsRegistry = getExtensionsRegistry();
const DatabaseDeleteRelatedExtension = extensionsRegistry.get(
@@ -141,7 +146,7 @@ function DatabaseList({
refreshData: dbRefreshData,
} = useListViewResource<DatabaseObject>(
'database',
t('database'),
databaseLabelLower(),
addDangerToast,
);
@@ -427,7 +432,7 @@ function DatabaseList({
const menuData: SubMenuProps = {
activeChild: 'Databases',
dropDownLinks: filteredDropDown,
name: t('Databases'),
name: databasesLabel(),
};
if (canCreate) {
@@ -482,7 +487,7 @@ function DatabaseList({
{
'data-test': 'btn-create-database',
icon: <Icons.PlusOutlined iconSize="m" />,
name: t('Database'),
name: databaseLabel(),
buttonStyle: 'primary',
onClick: openDatabaseModal,
},
@@ -503,7 +508,9 @@ function DatabaseList({
});
} catch {
setPreparingExport(false);
addDangerToast(t('There was an issue exporting the database'));
addDangerToast(
t('There was an issue exporting the %s', databaseLabelLower()),
);
}
},
[addDangerToast, setPreparingExport],
@@ -783,7 +790,7 @@ function DatabaseList({
>
<Tooltip
id="delete-action-tooltip"
title={t('Delete database')}
title={t('Delete %s', databaseLabelLower())}
placement="bottom"
>
<Icons.DeleteOutlined iconSize="l" />
@@ -893,7 +900,8 @@ function DatabaseList({
'changed_by',
createErrorHandler(errMsg =>
t(
'An error occurred while fetching dataset datasource values: %s',
'An error occurred while fetching %s values: %s',
databaseLabelLower(),
errMsg,
),
),
@@ -996,7 +1004,7 @@ function DatabaseList({
description={
<>
<p>
{t('The database')}{' '}
{t('The %s', databaseLabelLower())}{' '}
<b>{databaseCurrentlyDeleting.database_name}</b>{' '}
{t(
'is linked to %s charts that appear on %s dashboards and users have %s SQL Lab tabs using this database open. Are you sure you want to continue? Deleting the database will break those objects.',
@@ -1104,7 +1112,7 @@ function DatabaseList({
title={
<ModalTitleWithIcon
icon={<Icons.DeleteOutlined />}
title={t('Delete Database?')}
title={t('Delete %s?', databaseLabel())}
/>
}
/>

View File

@@ -24,7 +24,14 @@ import {
FeatureFlag,
} from '@superset-ui/core';
import { styled, useTheme, css } from '@apache-superset/core/theme';
import { FunctionComponent, useState, useMemo, useCallback, Key } from 'react';
import {
FunctionComponent,
useState,
useMemo,
useCallback,
useRef,
Key,
} from 'react';
import type { CellProps } from 'react-table';
import { Link, useHistory } from 'react-router-dom';
import rison from 'rison';
@@ -38,9 +45,11 @@ import { OWNER_OPTION_FILTER_PROPS } from 'src/features/owners/OwnerSelectLabel'
import { ColumnObject } from 'src/features/datasets/types';
import { useListViewResource } from 'src/views/CRUD/hooks';
import {
Button,
ConfirmStatusChange,
CertifiedBadge,
DeleteModal,
Dropdown,
Tooltip,
InfoTooltip,
DatasetTypeLabel,
@@ -77,6 +86,14 @@ import {
import DuplicateDatasetModal from 'src/features/datasets/DuplicateDatasetModal';
import type DatasetType from 'src/types/Dataset';
import SemanticViewEditModal from 'src/features/semanticViews/SemanticViewEditModal';
import AddSemanticViewModal from 'src/features/semanticViews/AddSemanticViewModal';
import {
datasetLabel,
datasetLabelLower,
datasetsLabel,
datasetsLabelLower,
databaseLabel,
} from 'src/utils/semanticLayerLabels';
import { useSelector } from 'react-redux';
import { QueryObjectColumns } from 'src/views/CRUD/types';
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
@@ -172,7 +189,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
state: { bulkSelectEnabled },
hasPerm,
toggleBulkSelect,
} = useListViewResource<Dataset>('dataset', t('dataset'), addDangerToast);
} = useListViewResource<Dataset>(
'dataset',
datasetLabelLower(),
addDangerToast,
);
// Combined endpoint state
const [datasets, setDatasets] = useState<Dataset[]>([]);
@@ -180,6 +201,155 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
const [loading, setLoading] = useState(true);
const [lastFetchConfig, setLastFetchConfig] =
useState<ListViewFetchDataConfig | null>(null);
const [currentSourceFilter, setCurrentSourceFilter] = useState<string>('');
// Track the current type and connection filter values so cascade-clear logic
// can inspect them when a different filter changes.
const currentTypeFilter = useRef<unknown>(undefined);
const currentConnectionFilter = useRef<unknown>(undefined);
// Ref wired to ListView's filter controls for programmatic per-filter clearing.
const filtersRef = useRef<{
clearFilters: () => void;
clearFilterById: (id: string) => void;
}>(null);
/**
* Cascade-clear incompatible filters when one filter changes.
*
* Rules:
* - Selecting a DB connection → clear "Semantic View" type
* - Selecting a SL connection → clear "Physical" / "Virtual" type
* - Selecting Physical/Virtual type → clear any SL connection
* - Selecting Semantic View type → clear any DB connection
* - Selecting Source=Database → clear SL connection + Semantic View type
* - Selecting Source=Semantic Layer → clear DB connection + Physical/Virtual type
*/
const cascadeClear = useCallback(
(changed: 'source' | 'type' | 'connection', newValue: unknown) => {
if (!isFeatureEnabled(FeatureFlag.SemanticLayers)) return;
const isSlConnection = (v: unknown) =>
typeof v === 'string' && v.startsWith('sl:');
const isDbConnection = (v: unknown) =>
v !== undefined && v !== null && v !== '' && !isSlConnection(v);
const isSemanticViewType = (v: unknown) => v === 'semantic_view';
const isPhysicalVirtualType = (v: unknown) => v === true || v === false;
if (changed === 'connection') {
if (
isSlConnection(newValue) &&
isPhysicalVirtualType(currentTypeFilter.current)
) {
filtersRef.current?.clearFilterById('sql');
}
if (
isDbConnection(newValue) &&
isSemanticViewType(currentTypeFilter.current)
) {
filtersRef.current?.clearFilterById('sql');
}
}
if (changed === 'type') {
if (
isSemanticViewType(newValue) &&
isDbConnection(currentConnectionFilter.current)
) {
filtersRef.current?.clearFilterById('database');
}
if (
isPhysicalVirtualType(newValue) &&
isSlConnection(currentConnectionFilter.current)
) {
filtersRef.current?.clearFilterById('database');
}
}
if (changed === 'source') {
const src = newValue as string;
if (src === 'database') {
if (isSemanticViewType(currentTypeFilter.current)) {
filtersRef.current?.clearFilterById('sql');
}
if (isSlConnection(currentConnectionFilter.current)) {
filtersRef.current?.clearFilterById('database');
}
}
if (src === 'semantic_layer') {
if (isPhysicalVirtualType(currentTypeFilter.current)) {
filtersRef.current?.clearFilterById('sql');
}
if (isDbConnection(currentConnectionFilter.current)) {
filtersRef.current?.clearFilterById('database');
}
}
}
},
[],
);
/**
* 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) => {
@@ -187,11 +357,19 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
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 =
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(f => f.id !== 'source_type' && f.id !== 'database')
.filter(
({ value }) => value !== '' && value !== null && value !== undefined,
)
@@ -204,26 +382,50 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
: 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 as string | undefined);
if (sourceTypeValue) {
otherFilters.push({
col: 'source_type',
opr: 'eq',
value: sourceTypeValue,
});
}
// Add source_type filter for the combined endpoint
const sourceTypeValue =
sourceTypeFilter?.value && typeof sourceTypeFilter.value === 'object'
? (sourceTypeFilter.value as { value: string }).value
: (sourceTypeFilter?.value as string | undefined);
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 } : {}),
});
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 } : {}),
});
// 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 as string | number,
});
}
}
return SupersetClient.get({
endpoint: `/api/v1/datasource/?q=${queryParams}`,
@@ -233,7 +435,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
setDatasetCount(json.count);
})
.catch(() => {
addDangerToast(t('An error occurred while fetching datasets'));
addDangerToast(
t('An error occurred while fetching %s', datasetsLabelLower()),
);
})
.finally(() => {
setLoading(false);
@@ -267,6 +471,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
null,
);
const [svCurrentlyDeleting, setSvCurrentlyDeleting] =
useState<Dataset | null>(null);
const [showAddSemanticViewModal, setShowAddSemanticViewModal] =
useState(false);
const [importingDataset, showImportModal] = useState<boolean>(false);
const [passwordFields, setPasswordFields] = useState<string[]>([]);
const [preparingExport, setPreparingExport] = useState<boolean>(false);
@@ -314,7 +523,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
const handleDatasetImport = () => {
showImportModal(false);
refreshData();
addSuccessToast(t('Dataset imported'));
addSuccessToast(t('%s imported', datasetLabel()));
};
const canEdit = hasPerm('can_write');
@@ -352,7 +561,10 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
})
.catch(() => {
addDangerToast(
t('An error occurred while fetching dataset related data'),
t(
'An error occurred while fetching %s related data',
datasetLabelLower(),
),
);
});
},
@@ -396,12 +608,40 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
});
} catch {
setPreparingExport(false);
addDangerToast(t('There was an issue exporting the selected datasets'));
addDangerToast(
t(
'There was an issue exporting the selected %s',
datasetsLabelLower(),
),
);
}
},
[addDangerToast, setPreparingExport],
);
const handleSemanticViewDelete = (sv: Dataset) => {
setSvCurrentlyDeleting(sv);
};
const handleSemanticViewDeleteConfirm = () => {
if (!svCurrentlyDeleting) return;
const { id, table_name: tableName } = svCurrentlyDeleting;
SupersetClient.delete({
endpoint: `/api/v1/semantic_view/${id}`,
}).then(
() => {
setSvCurrentlyDeleting(null);
refreshData();
addSuccessToast(t('Deleted: %s', tableName));
},
createErrorHandler(errMsg =>
addDangerToast(
t('There was an issue deleting %s: %s', tableName, errMsg),
),
),
);
};
const columns = useMemo(
() => [
{
@@ -486,7 +726,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
original: { database },
},
}: CellProps<Dataset>) => database?.database_name || '-',
Header: t('Database'),
Header: databaseLabel(),
accessor: 'database.database_name',
size: 'xl',
id: 'database.database_name',
@@ -551,25 +791,43 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
Cell: ({ row: { original } }: CellProps<Dataset>) => {
const isSemanticView = original.kind === 'semantic_view';
// Semantic view: only show edit button
// Semantic view: show edit and delete buttons
if (isSemanticView) {
if (!canEdit) return null;
if (!canEdit && !canDelete) 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)}
{canDelete && (
<Tooltip
id="delete-action-tooltip"
title={t('Delete')}
placement="bottom"
>
<Icons.EditOutlined iconSize="l" />
</span>
</Tooltip>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={() => handleSemanticViewDelete(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={() => setSvCurrentlyEditing(original)}
>
<Icons.EditOutlined iconSize="l" />
</span>
</Tooltip>
)}
</Actions>
);
}
@@ -706,6 +964,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
{ label: t('Database'), value: 'database' },
{ label: t('Semantic Layer'), value: 'semantic_layer' },
],
onFilterUpdate: (option: any) => {
cascadeClear('source', option?.value);
},
},
]
: []),
@@ -736,6 +997,10 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
? [{ label: t('Semantic View'), value: 'semantic_view' }]
: []),
],
onFilterUpdate: (option: any) => {
currentTypeFilter.current = option?.value;
cascadeClear('type', option?.value);
},
},
]
: [
@@ -753,21 +1018,19 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
},
]),
{
Header: t('Database'),
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 datasets: %s', errMsg),
),
),
fetchSelects: fetchConnectionOptions,
paginate: true,
dropdownStyle: { minWidth: WIDER_DROPDOWN_WIDTH },
onFilterUpdate: (option: any) => {
currentConnectionFilter.current = option?.value;
cascadeClear('connection', option?.value);
},
},
{
Header: t('Schema'),
@@ -797,7 +1060,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
'dataset',
createErrorHandler(errMsg =>
t(
'An error occurred while fetching dataset owner values: %s',
'An error occurred while fetching %s owner values: %s',
datasetLabelLower(),
errMsg,
),
),
@@ -832,7 +1096,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
'changed_by',
createErrorHandler(errMsg =>
t(
'An error occurred while fetching dataset datasource values: %s',
'An error occurred while fetching %s values: %s',
datasetLabelLower(),
errMsg,
),
),
@@ -847,7 +1112,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
const menuData: SubMenuProps = {
activeChild: 'Datasets',
name: t('Datasets'),
name: datasetsLabel(),
};
const buttonArr: Array<ButtonProps> = [];
@@ -857,7 +1122,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
name: (
<Tooltip
id="import-tooltip"
title={t('Import datasets')}
title={t('Import %s', datasetsLabelLower())}
placement="bottomRight"
>
<Icons.DownloadOutlined
@@ -881,14 +1146,58 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
}
if (canCreate) {
buttonArr.push({
icon: <Icons.PlusOutlined iconSize="m" />,
name: t('Dataset'),
onClick: () => {
history.push('/dataset/add/');
},
buttonStyle: 'primary',
});
if (isFeatureEnabled(FeatureFlag.SemanticLayers)) {
buttonArr.push({
name: t('New'),
buttonStyle: 'primary',
component: (
<Dropdown
css={css`
margin-left: ${theme.sizeUnit * 2}px;
`}
menu={{
items: [
{
key: 'dataset',
label: t('Dataset'),
onClick: () => history.push('/dataset/add/'),
},
{
key: 'semantic-view',
label: t('Semantic View'),
onClick: () => setShowAddSemanticViewModal(true),
},
],
}}
trigger={['click']}
>
<Button
data-test="btn-create-new"
buttonStyle="primary"
icon={<Icons.PlusOutlined iconSize="m" />}
>
{t('New')}
<Icons.DownOutlined
iconSize="s"
css={css`
margin-left: ${theme.sizeUnit * 1.5}px;
margin-right: -${theme.sizeUnit * 2}px;
`}
/>
</Button>
</Dropdown>
),
});
} else {
buttonArr.push({
icon: <Icons.PlusOutlined iconSize="m" />,
name: datasetLabel(),
onClick: () => {
history.push('/dataset/add/');
},
buttonStyle: 'primary',
});
}
}
menuData.buttons = buttonArr;
@@ -923,26 +1232,54 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
};
const handleBulkDatasetDelete = (datasetsToDelete: Dataset[]) => {
SupersetClient.delete({
endpoint: `/api/v1/dataset/?q=${rison.encode(
datasetsToDelete.map(({ id }) => id),
)}`,
}).then(
({ json = {} }) => {
refreshData();
addSuccessToast(json.message);
},
createErrorHandler(errMsg =>
addDangerToast(
t('There was an issue deleting the selected datasets: %s', errMsg),
),
),
const datasets = datasetsToDelete.filter(
d => d.source_type !== 'semantic_layer',
);
const semanticViews = datasetsToDelete.filter(
d => d.source_type === 'semantic_layer',
);
const promises: Promise<unknown>[] = [];
if (datasets.length) {
promises.push(
SupersetClient.delete({
endpoint: `/api/v1/dataset/?q=${rison.encode(
datasets.map(({ id }) => id),
)}`,
}),
);
}
if (semanticViews.length) {
promises.push(
SupersetClient.delete({
endpoint: `/api/v1/semantic_view/?q=${rison.encode(
semanticViews.map(({ id }) => id),
)}`,
}),
);
}
Promise.allSettled(promises).then(results => {
const failures = results.filter(r => r.status === 'rejected');
// Always refresh so the list reflects whatever actually got deleted.
refreshData();
if (failures.length === 0) {
addSuccessToast(t('Deleted %s item(s)', datasetsToDelete.length));
} else {
addDangerToast(
t('There was an issue deleting the selected %s', datasetsLabelLower()),
);
}
});
};
const handleDatasetDuplicate = (newDatasetName: string) => {
if (datasetCurrentlyDuplicating === null) {
addDangerToast(t('There was an issue duplicating the dataset.'));
addDangerToast(
t('There was an issue duplicating the %s.', datasetLabelLower()),
);
}
SupersetClient.post({
@@ -958,7 +1295,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
},
createErrorHandler(errMsg =>
addDangerToast(
t('There was an issue duplicating the selected datasets: %s', errMsg),
t(
'There was an issue duplicating the selected %s: %s',
datasetsLabelLower(),
errMsg,
),
),
),
);
@@ -972,7 +1313,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
description={
<>
<p>
{t('The dataset')}
{t('The %s', datasetLabelLower())}
<b> {datasetCurrentlyDeleting.table_name} </b>
{t(
'is linked to %s charts that appear on %s dashboards. Are you sure you want to continue? Deleting the dataset will break those objects.',
@@ -1078,7 +1419,19 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
}}
onHide={closeDatasetDeleteModal}
open
title={t('Delete Dataset?')}
title={t('Delete %s?', datasetLabel())}
/>
)}
{svCurrentlyDeleting && (
<DeleteModal
description={t(
'Are you sure you want to delete %s?',
svCurrentlyDeleting.table_name,
)}
onConfirm={handleSemanticViewDeleteConfirm}
onHide={() => setSvCurrentlyDeleting(null)}
open
title={t('Delete Semantic View?')}
/>
)}
{datasetCurrentlyEditing && (
@@ -1102,10 +1455,18 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
addSuccessToast={addSuccessToast}
semanticView={svCurrentlyEditing}
/>
<AddSemanticViewModal
show={showAddSemanticViewModal}
onHide={() => setShowAddSemanticViewModal(false)}
onSuccess={refreshData}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
/>
<ConfirmStatusChange
title={t('Please confirm')}
description={t(
'Are you sure you want to delete the selected datasets?',
'Are you sure you want to delete the selected %s?',
datasetsLabelLower(),
)}
onConfirm={handleBulkDatasetDelete}
>
@@ -1136,6 +1497,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
pageSize={PAGE_SIZE}
fetchData={fetchData}
filters={filterTypes}
filtersRef={filtersRef}
loading={loading}
initialSort={initialSort}
bulkActions={bulkActions}
@@ -1188,7 +1550,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
<ImportModelsModal
resourceName="dataset"
resourceLabel={t('dataset')}
resourceLabel={datasetLabelLower()}
passwordsNeededMessage={PASSWORDS_NEEDED_MESSAGE}
confirmOverwriteMessage={CONFIRM_OVERWRITE_MESSAGE}
addDangerToast={addDangerToast}

View File

@@ -0,0 +1,63 @@
/**
* 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 { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
/**
* When the SEMANTIC_LAYERS feature flag is enabled the UI broadens
* "dataset" → "datasource" and "database" → "data connection" so
* that semantic views and semantic layers feel like first-class
* citizens alongside traditional datasets and database connections.
*/
function sl<T>(legacy: T, semantic: T): T {
return isFeatureEnabled(FeatureFlag.SemanticLayers) ? semantic : legacy;
}
// ---------------------------------------------------------------------------
// "dataset" family
// ---------------------------------------------------------------------------
/** Capitalized singular: "Dataset" / "Datasource" */
export const datasetLabel = () => sl(t('Dataset'), t('Datasource'));
/** Lower-case singular: "dataset" / "datasource" */
export const datasetLabelLower = () => sl(t('dataset'), t('datasource'));
/** Capitalized plural: "Datasets" / "Datasources" */
export const datasetsLabel = () => sl(t('Datasets'), t('Datasources'));
/** Lower-case plural: "datasets" / "datasources" */
export const datasetsLabelLower = () => sl(t('datasets'), t('datasources'));
// ---------------------------------------------------------------------------
// "database" family
// ---------------------------------------------------------------------------
/** Capitalized singular: "Database" / "Data connection" */
export const databaseLabel = () => sl(t('Database'), t('Data connection'));
/** Lower-case singular: "database" / "data connection" */
export const databaseLabelLower = () => sl(t('database'), t('data connection'));
/** Capitalized plural: "Databases" / "Data connections" */
export const databasesLabel = () => sl(t('Databases'), t('Data connections'));
/** Lower-case plural: "databases" / "data connections" */
export const databasesLabelLower = () =>
sl(t('databases'), t('data connections'));

View File

@@ -62,11 +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)
ds_q = DatasourceDAO.build_dataset_query(name_filter, sql_filter)
sv_q = DatasourceDAO.build_semantic_view_query(name_filter)
if source_type == "empty":
return {"count": 0, "result": []}
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()
@@ -108,15 +129,30 @@ class GetCombinedDatasourceListCommand(BaseCommand):
sql_filter: bool | None,
type_filter: str | None,
) -> str:
"""Narrow source_type based on access flags, sql filter, and type filter."""
"""Narrow source_type based on access flags, sql filter, and type filter.
Returns one of: "database", "semantic_layer", "all", or "empty".
"empty" signals that the caller should short-circuit and return no results
(used when the user explicitly requests semantic views but lacks access).
"""
if not self._can_read_semantic_views:
# If the user explicitly asked for semantic views but cannot read them,
# return "empty" so the caller yields zero results rather than silently
# falling back to the full dataset list.
if source_type == "semantic_layer" or type_filter == "semantic_view":
return "empty"
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
@@ -124,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")
@@ -153,5 +193,19 @@ class GetCombinedDatasourceListCommand(BaseCommand):
type_filter = "semantic_view"
elif opr == "dataset_is_null_or_empty" and isinstance(value, bool):
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,
)

View File

@@ -27,8 +27,10 @@ from superset.commands.base import BaseCommand
from superset.commands.semantic_layer.exceptions import (
SemanticLayerCreateFailedError,
SemanticLayerInvalidError,
SemanticLayerNotFoundError,
SemanticViewCreateFailedError,
)
from superset.daos.semantic_layer import SemanticLayerDAO
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
from superset.semantic_layers.registry import registry
from superset.utils import json
from superset.utils.decorators import on_error, transaction
@@ -67,3 +69,36 @@ class CreateSemanticLayerCommand(BaseCommand):
# Validate configuration against the plugin
cls = registry[sl_type]
cls.from_configuration(self._properties["configuration"])
class CreateSemanticViewCommand(BaseCommand):
def __init__(self, data: dict[str, Any]):
self._properties = data.copy()
@transaction(
on_error=partial(
on_error,
catches=(SQLAlchemyError, ValueError),
reraise=SemanticViewCreateFailedError,
)
)
def run(self) -> Model:
self.validate()
if isinstance(self._properties.get("configuration"), dict):
self._properties["configuration"] = json.dumps(
self._properties["configuration"]
)
return SemanticViewDAO.create(attributes=self._properties)
def validate(self) -> None:
layer_uuid: str = self._properties.get("semantic_layer_uuid", "")
if not SemanticLayerDAO.find_by_uuid(layer_uuid):
raise SemanticLayerNotFoundError()
name: str = self._properties.get("name", "")
configuration: dict[str, Any] = self._properties.get("configuration") or {}
if not SemanticViewDAO.validate_uniqueness(name, layer_uuid, configuration):
raise ValueError(
f"Semantic view '{name}' already exists for this layer"
" and configuration"
)

View File

@@ -21,13 +21,18 @@ from functools import partial
from sqlalchemy.exc import SQLAlchemyError
from superset import security_manager
from superset.commands.base import BaseCommand
from superset.commands.semantic_layer.exceptions import (
SemanticLayerDeleteFailedError,
SemanticLayerNotFoundError,
SemanticViewDeleteFailedError,
SemanticViewForbiddenError,
SemanticViewNotFoundError,
)
from superset.daos.semantic_layer import SemanticLayerDAO
from superset.semantic_layers.models import SemanticLayer
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
from superset.exceptions import SupersetSecurityException
from superset.semantic_layers.models import SemanticLayer, SemanticView
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
@@ -54,3 +59,57 @@ class DeleteSemanticLayerCommand(BaseCommand):
self._model = SemanticLayerDAO.find_by_uuid(self._uuid)
if not self._model:
raise SemanticLayerNotFoundError()
class DeleteSemanticViewCommand(BaseCommand):
def __init__(self, pk: int):
self._pk = pk
self._model: SemanticView | None = None
@transaction(
on_error=partial(
on_error,
catches=(SQLAlchemyError,),
reraise=SemanticViewDeleteFailedError,
)
)
def run(self) -> None:
self.validate()
assert self._model
SemanticViewDAO.delete([self._model])
def validate(self) -> None:
self._model = SemanticViewDAO.find_by_id(self._pk, id_column="id")
if not self._model:
raise SemanticViewNotFoundError()
try:
security_manager.raise_for_ownership(self._model)
except SupersetSecurityException as ex:
raise SemanticViewForbiddenError() from ex
class BulkDeleteSemanticViewCommand(BaseCommand):
def __init__(self, model_ids: list[int]):
self._model_ids = model_ids
self._models: list[SemanticView] = []
@transaction(
on_error=partial(
on_error,
catches=(SQLAlchemyError,),
reraise=SemanticViewDeleteFailedError,
)
)
def run(self) -> None:
self.validate()
SemanticViewDAO.delete(self._models)
def validate(self) -> None:
self._models = SemanticViewDAO.find_by_ids(self._model_ids, id_column="id")
if len(self._models) != len(self._model_ids):
raise SemanticViewNotFoundError()
for model in self._models:
try:
security_manager.raise_for_ownership(model)
except SupersetSecurityException as ex:
raise SemanticViewForbiddenError() from ex

View File

@@ -66,3 +66,11 @@ class SemanticLayerUpdateFailedError(UpdateFailedError):
class SemanticLayerDeleteFailedError(DeleteFailedError):
message = _("Semantic layer could not be deleted.")
class SemanticViewCreateFailedError(CreateFailedError):
message = _("Semantic view could not be created.")
class SemanticViewDeleteFailedError(DeleteFailedError):
message = _("Semantic view could not be deleted.")

View File

@@ -270,6 +270,26 @@ class BaseDatasource(
# Check if all requested columns are drillable
return set(column_names).issubset(drillable_columns)
def get_compatible_metrics(
self,
selected_metrics: list[str],
selected_dimensions: list[str],
) -> list[str]:
"""
SQL datasets have no compatibility constraints — return all metrics.
"""
return [m.metric_name for m in self.metrics]
def get_compatible_dimensions(
self,
selected_metrics: list[str],
selected_dimensions: list[str],
) -> list[str]:
"""
SQL datasets have no compatibility constraints — return all columns.
"""
return [c.column_name for c in self.columns]
def get_time_grains(self) -> list[TimeGrainDict]:
"""
Get available time granularities from the database.
@@ -449,6 +469,7 @@ class BaseDatasource(
"column_formats": self.column_formats,
"description": self.description,
"database": self.database.data, # pylint: disable=no-member
"parent": {"name": self.database.data["name"]}, # pylint: disable=no-member
"default_endpoint": self.default_endpoint,
"filter_select": self.filter_select_enabled, # TODO deprecate
"filter_select_enabled": self.filter_select_enabled,

View File

@@ -95,6 +95,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(
@@ -121,11 +122,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"),
@@ -137,6 +144,9 @@ class DatasourceDAO(BaseDAO[Datasource]):
escaped = _escape_ilike_fragment(name_filter)
sv_q = sv_q.where(SemanticView.name.ilike(f"%{escaped}%", escape="\\"))
if semantic_layer_uuid is not None:
sv_q = sv_q.where(SemanticView.semantic_layer_uuid == semantic_layer_uuid)
return sv_q
@staticmethod

View File

@@ -22,22 +22,25 @@ from __future__ import annotations
from typing import Any
from sqlalchemy.exc import StatementError
from superset_core.semantic_layers.daos import (
AbstractSemanticLayerDAO,
AbstractSemanticViewDAO,
)
from superset.daos.base import BaseDAO
from superset.extensions import db
from superset.semantic_layers.models import SemanticLayer, SemanticView
from superset.utils import json
class SemanticLayerDAO(AbstractSemanticLayerDAO):
class SemanticLayerDAO(BaseDAO[SemanticLayer], AbstractSemanticLayerDAO):
"""
Data Access Object for SemanticLayer model.
"""
# SemanticLayer uses uuid as the primary key
id_column_name = "uuid"
model_cls = SemanticLayer
@staticmethod
@@ -112,7 +115,7 @@ class SemanticLayerDAO(AbstractSemanticLayerDAO):
)
class SemanticViewDAO(AbstractSemanticViewDAO):
class SemanticViewDAO(BaseDAO[SemanticView], AbstractSemanticViewDAO):
"""Data Access Object for SemanticView model."""
model_cls = SemanticView

View File

@@ -14,6 +14,7 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import hashlib
import logging
from typing import Any
@@ -27,7 +28,9 @@ from superset.connectors.sqla.models import BaseDatasource
from superset.daos.datasource import DatasourceDAO
from superset.daos.exceptions import DatasourceNotFound, DatasourceTypeNotSupportedError
from superset.exceptions import SupersetSecurityException
from superset.extensions import cache_manager
from superset.superset_typing import FlaskResponse
from superset.utils import json
from superset.utils.core import apply_max_row_limit, DatasourceType, SqlExpressionType
from superset.views.base_api import BaseSupersetApi, statsd_metrics
@@ -307,6 +310,129 @@ class DatasourceRestApi(BaseSupersetApi):
f"Valid types are: column, metric, where, having"
) from None
@expose(
"/<datasource_type>/<int:datasource_id>/compatible",
methods=("POST",),
)
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
f".compatible",
log_to_statsd=False,
)
def compatible(
self, datasource_type: str, datasource_id: int
) -> FlaskResponse:
"""Return metrics and dimensions compatible with the current selection.
---
post:
summary: Get compatible metrics and dimensions
parameters:
- in: path
schema:
type: string
name: datasource_type
- in: path
schema:
type: integer
name: datasource_id
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
selected_metrics:
type: array
items:
type: string
selected_dimensions:
type: array
items:
type: string
responses:
200:
description: Compatible metrics and dimensions
content:
application/json:
schema:
type: object
properties:
result:
type: object
properties:
compatible_metrics:
type: array
items:
type: string
compatible_dimensions:
type: array
items:
type: string
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
"""
try:
datasource = DatasourceDAO.get_datasource(
DatasourceType(datasource_type), datasource_id
)
datasource.raise_for_access()
except ValueError:
return self.response(
400, message=f"Invalid datasource type: {datasource_type}"
)
except DatasourceTypeNotSupportedError as ex:
return self.response(400, message=ex.message)
except DatasourceNotFound as ex:
return self.response(404, message=ex.message)
except SupersetSecurityException as ex:
return self.response(403, message=ex.message)
body = request.get_json(silent=True) or {}
selected_metrics = body.get("selected_metrics", [])
selected_dimensions = body.get("selected_dimensions", [])
# Build a stable cache key from the datasource identity and the
# (sorted) selection so that order differences don't cause cache misses.
cache_key = "compatible:" + hashlib.md5(
json.dumps(
{
"uid": datasource.uid,
"m": sorted(selected_metrics),
"d": sorted(selected_dimensions),
},
sort_keys=True,
).encode()
).hexdigest()
if (cached := cache_manager.data_cache.get(cache_key)) is not None:
return self.response(200, result=cached)
result = {
"compatible_metrics": datasource.get_compatible_metrics(
selected_metrics, selected_dimensions
),
"compatible_dimensions": datasource.get_compatible_dimensions(
selected_metrics, selected_dimensions
),
}
timeout = datasource.cache_timeout or app.config.get(
"CACHE_DEFAULT_TIMEOUT", 300
)
cache_manager.data_cache.set(cache_key, result, timeout=timeout)
return self.response(200, result=result)
@expose("/", methods=("GET",))
@protect()
@safe

View File

@@ -507,3 +507,43 @@ class Explorable(Protocol):
:return: Language identifier string, or None if not applicable
"""
# =========================================================================
# Compatibility
# =========================================================================
def get_compatible_metrics(
self,
selected_metrics: list[str],
selected_dimensions: list[str],
) -> list[str]:
"""
Return the names of metrics that can be queried alongside the current
selection of metrics and dimensions.
SQL datasets always return every metric name unchanged — there is no
concept of incompatibility at the SQL layer. Semantic views delegate
to ``SemanticView.get_compatible_metrics`` so the semantic layer can
enforce its own join / grain constraints.
:param selected_metrics: Metric names already chosen by the user.
:param selected_dimensions: Dimension names already chosen by the user.
:return: Names of metrics the user is still allowed to add.
"""
def get_compatible_dimensions(
self,
selected_metrics: list[str],
selected_dimensions: list[str],
) -> list[str]:
"""
Return the names of dimensions that can be queried alongside the
current selection of metrics and dimensions.
SQL datasets always return every column name unchanged. Semantic
views delegate to ``SemanticView.get_compatible_dimensions``.
:param selected_metrics: Metric names already chosen by the user.
:param selected_dimensions: Dimension names already chosen by the user.
:return: Names of dimensions the user is still allowed to add.
"""

View File

@@ -66,6 +66,7 @@ from superset.superset_typing import FlaskResponse
from superset.utils.core import is_test, pessimistic_connection_handling
from superset.utils.decorators import transaction
from superset.utils.log import DBEventLogger, get_event_logger_from_cfg_value
from superset.utils.semantic_layer_labels import database_connections_menu_label
if TYPE_CHECKING:
from superset.app import SupersetApp
@@ -308,7 +309,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
appbuilder.add_view(
DatabaseView,
"Databases",
label=_("Database Connections"),
label=database_connections_menu_label(),
icon="fa-database",
category="Data",
category_label=_("Data"),

View File

@@ -23,19 +23,29 @@ from flask import make_response, request, Response
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 flask_babel import lazy_gettext as t, ngettext
from marshmallow import ValidationError
from pydantic import ValidationError as PydanticValidationError
from sqlalchemy.orm import load_only
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.create import (
CreateSemanticLayerCommand,
CreateSemanticViewCommand,
)
from superset.commands.semantic_layer.delete import (
BulkDeleteSemanticViewCommand,
DeleteSemanticLayerCommand,
DeleteSemanticViewCommand,
)
from superset.commands.semantic_layer.exceptions import (
SemanticLayerCreateFailedError,
SemanticLayerDeleteFailedError,
SemanticLayerInvalidError,
SemanticLayerNotFoundError,
SemanticLayerUpdateFailedError,
SemanticViewCreateFailedError,
SemanticViewDeleteFailedError,
SemanticViewForbiddenError,
SemanticViewInvalidError,
SemanticViewNotFoundError,
@@ -47,12 +57,14 @@ 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.datasets.schemas import get_delete_ids_schema
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 (
SemanticLayerPostSchema,
SemanticLayerPutSchema,
SemanticViewPostSchema,
SemanticViewPutSchema,
)
from superset.superset_typing import FlaskResponse
@@ -162,10 +174,95 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
allow_browser_login = True
class_permission_name = "SemanticView"
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
include_route_methods = {"put"}
include_route_methods = {"put", "post", "delete", "bulk_delete"}
# SemanticViewRestApi exposes only write endpoints, but can_read must be
# declared explicitly so that FAB registers the permission. It is used by
# DatasourceRestApi.combined_list to gate access to semantic views in the
# combined datasource list.
base_permissions = ["can_read", "can_write"]
edit_model_schema = SemanticViewPutSchema()
@expose("/", methods=("POST",))
@protect()
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post",
log_to_statsd=False,
)
@requires_json
def post(self) -> Response:
"""Bulk create semantic views.
---
post:
summary: Create semantic views
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
views:
type: array
items:
type: object
properties:
name:
type: string
semantic_layer_uuid:
type: string
configuration:
type: object
description:
type: string
cache_timeout:
type: integer
responses:
201:
description: Semantic views created
400:
$ref: '#/components/responses/400'
401:
$ref: '#/components/responses/401'
422:
$ref: '#/components/responses/422'
"""
body = request.json or {}
views_data = body.get("views", [])
if not views_data:
return self.response_400(message="No views provided")
schema = SemanticViewPostSchema()
created = []
errors = []
for view_data in views_data:
try:
item = schema.load(view_data)
except ValidationError as error:
errors.append({"name": view_data.get("name"), "error": error.messages})
continue
try:
new_model = CreateSemanticViewCommand(item).run()
created.append({"uuid": str(new_model.uuid), "name": new_model.name})
except SemanticLayerNotFoundError:
errors.append(
{"name": view_data.get("name"), "error": "Semantic layer not found"}
)
except SemanticViewCreateFailedError as ex:
logger.error(
"Error creating semantic view: %s",
str(ex),
exc_info=True,
)
errors.append({"name": view_data.get("name"), "error": str(ex)})
result: dict[str, Any] = {"created": created}
if errors:
result["errors"] = errors
status = 201 if created else 422
return self.response(status, result=result)
@expose("/<pk>", methods=("PUT",))
@protect()
@statsd_metrics
@@ -239,6 +336,110 @@ class SemanticViewRestApi(BaseSupersetModelRestApi):
response = self.response_422(message=str(ex))
return response
@expose("/<pk>", methods=("DELETE",))
@protect()
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.delete",
log_to_statsd=False,
)
def delete(self, pk: int) -> Response:
"""Delete a semantic view.
---
delete:
summary: Delete a semantic view
parameters:
- in: path
schema:
type: integer
name: pk
responses:
200:
description: Semantic view deleted
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
422:
$ref: '#/components/responses/422'
"""
try:
DeleteSemanticViewCommand(pk).run()
return self.response(200, message="OK")
except SemanticViewNotFoundError:
return self.response_404()
except SemanticViewForbiddenError:
return self.response_403()
except SemanticViewDeleteFailedError as ex:
logger.error(
"Error deleting semantic view: %s",
str(ex),
exc_info=True,
)
return self.response_422(message=str(ex))
@expose("/", methods=("DELETE",))
@protect()
@statsd_metrics
@rison(get_delete_ids_schema)
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.bulk_delete",
log_to_statsd=False,
)
def bulk_delete(self, **kwargs: Any) -> Response:
"""Bulk delete semantic views.
---
delete:
summary: Bulk delete semantic views
parameters:
- in: query
name: q
content:
application/json:
schema:
$ref: '#/components/schemas/get_delete_ids_schema'
responses:
200:
description: Semantic views deleted
content:
application/json:
schema:
type: object
properties:
message:
type: string
401:
$ref: '#/components/responses/401'
403:
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
422:
$ref: '#/components/responses/422'
"""
item_ids: list[int] = kwargs["rison"]
try:
BulkDeleteSemanticViewCommand(item_ids).run()
return self.response(
200,
message=ngettext(
"Deleted %(num)d semantic view",
"Deleted %(num)d semantic views",
num=len(item_ids),
),
)
except SemanticViewNotFoundError:
return self.response_404()
except SemanticViewForbiddenError:
return self.response_403()
except SemanticViewDeleteFailedError as ex:
logger.error(
"Error bulk deleting semantic views: %s",
str(ex),
exc_info=True,
)
return self.response_422(message=str(ex))
class SemanticLayerRestApi(BaseSupersetApi):
resource_name = "semantic_layer"
@@ -386,6 +587,77 @@ class SemanticLayerRestApi(BaseSupersetApi):
return self.response(200, result=schema)
@expose("/<uuid>/views", methods=("POST",))
@protect()
@safe
@statsd_metrics
def views(self, uuid: str) -> FlaskResponse:
"""List available views from a semantic layer.
---
post:
summary: List available views from a semantic layer
parameters:
- in: path
schema:
type: string
name: uuid
requestBody:
content:
application/json:
schema:
type: object
properties:
runtime_data:
type: object
responses:
200:
description: Available views
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
"""
layer = SemanticLayerDAO.find_by_uuid(uuid)
if not layer:
return self.response_404()
body = request.get_json(silent=True) or {}
runtime_data = body.get("runtime_data", {})
try:
views = layer.implementation.get_semantic_views(runtime_data)
except Exception as ex: # pylint: disable=broad-except
logger.error(
"Error fetching semantic views for layer %s: %s",
uuid,
str(ex),
exc_info=True,
)
return self.response_400(
message=t(
"Unable to fetch semantic views. Check the layer configuration."
)
)
# Check which views already exist with the same runtime config
existing = SemanticLayerDAO.get_semantic_views(str(layer.uuid))
existing_keys: set[tuple[str, str]] = set()
for v in existing:
config = v.configuration
if isinstance(config, str):
config = json.loads(config)
existing_keys.add((v.name, json.dumps(config or {}, sort_keys=True)))
runtime_key = json.dumps(runtime_data or {}, sort_keys=True)
result = [
{
"name": v.name,
"already_added": (v.name, runtime_key) in existing_keys,
}
for v in sorted(views, key=lambda v: v.name)
]
return self.response(200, result=result)
@expose("/", methods=("POST",))
@protect()
@safe

View File

@@ -291,11 +291,7 @@ def map_query_object(query_object: ValidatedQueryObject) -> list[SemanticQuery]:
metrics = [all_metrics[metric] for metric in (query_object.metrics or [])]
grain = (
_convert_time_grain(query_object.extras["time_grain_sqla"])
if "time_grain_sqla" in query_object.extras
else None
)
grain = _convert_time_grain(query_object.extras.get("time_grain_sqla"))
dimensions = [
dimension
for dimension in semantic_view.dimensions
@@ -740,13 +736,17 @@ def _get_group_limit_filters(
return filters if filters else None
def _convert_time_grain(time_grain: str) -> Grain | None:
def _convert_time_grain(time_grain: str | None) -> Grain | None:
"""
Convert a time grain string (ISO 8601 duration) to a Grain instance.
Returns None when ``time_grain`` is None or empty (no grain selected).
"""
if not time_grain:
return None
try:
return Grains.get(time_grain)
except (ValueError, isodate.ISO8601Error):
except (TypeError, ValueError, isodate.ISO8601Error):
return None

View File

@@ -305,6 +305,7 @@ class SemanticView(AuditMixinNullable, Model):
for metric in self.implementation.get_metrics()
],
"database": {},
"parent": {"name": self.semantic_layer.name},
# UI features
"verbose_map": {},
"order_by_choices": [],
@@ -403,6 +404,49 @@ class SemanticView(AuditMixinNullable, Model):
def is_rls_supported(self) -> bool:
return False
def raise_for_access(self) -> None:
"""No-op: semantic view access control is not yet implemented."""
@property
def query_language(self) -> str | None:
return None
def get_compatible_metrics(
self,
selected_metrics: list[str],
selected_dimensions: list[str],
) -> list[str]:
"""
Return metric names compatible with the current selection.
Translates string names to semantic-layer objects, delegates to the
view implementation, and translates the result back to names.
"""
metric_map = {m.name: m for m in self.implementation.get_metrics()}
dim_map = {d.name: d for d in self.implementation.get_dimensions()}
sel_metrics = {metric_map[n] for n in selected_metrics if n in metric_map}
sel_dims = {dim_map[n] for n in selected_dimensions if n in dim_map}
compatible = self.implementation.get_compatible_metrics(
sel_metrics, sel_dims
)
return [m.name for m in compatible]
def get_compatible_dimensions(
self,
selected_metrics: list[str],
selected_dimensions: list[str],
) -> list[str]:
"""
Return dimension names compatible with the current selection.
Translates string names to semantic-layer objects, delegates to the
view implementation, and translates the result back to names.
"""
metric_map = {m.name: m for m in self.implementation.get_metrics()}
dim_map = {d.name: d for d in self.implementation.get_dimensions()}
sel_metrics = {metric_map[n] for n in selected_metrics if n in metric_map}
sel_dims = {dim_map[n] for n in selected_dimensions if n in dim_map}
compatible = self.implementation.get_compatible_dimensions(
sel_metrics, sel_dims
)
return [d.name for d in compatible]

View File

@@ -35,3 +35,11 @@ class SemanticLayerPutSchema(Schema):
description = fields.String(allow_none=True)
configuration = fields.Dict()
cache_timeout = fields.Integer(allow_none=True)
class SemanticViewPostSchema(Schema):
name = fields.String(required=True)
semantic_layer_uuid = fields.String(required=True)
configuration = fields.Dict(load_default=dict)
description = fields.String(allow_none=True)
cache_timeout = fields.Integer(allow_none=True)

View File

@@ -299,6 +299,7 @@ class ExplorableData(TypedDict, total=False):
column_formats: dict[str, str | None]
description: str | None
database: dict[str, Any]
parent: dict[str, Any]
default_endpoint: str | None
filter_select: bool
filter_select_enabled: bool

View File

@@ -0,0 +1,105 @@
# 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.
"""
Label helpers for SEMANTIC_LAYERS feature flag.
When the SEMANTIC_LAYERS feature flag is enabled the UI broadens
"dataset""datasource" and "database""data connection" so
that semantic views and semantic layers feel like first-class
citizens alongside traditional datasets and database connections.
Mirror of superset-frontend/src/utils/semanticLayerLabels.ts.
"""
from __future__ import annotations
from flask_babel import lazy_gettext as _
def _sl(legacy: str, semantic: str) -> str:
"""Return *semantic* when SEMANTIC_LAYERS is enabled, else *legacy*."""
# Imported lazily to avoid a circular import at module load time
# (superset.utils.semantic_layer_labels is imported by superset.initialization,
# which is itself imported during superset package initialization).
from superset import feature_flag_manager # pylint: disable=import-outside-toplevel
return (
semantic
if feature_flag_manager.is_feature_enabled("SEMANTIC_LAYERS")
else legacy
)
# ---------------------------------------------------------------------------
# "dataset" family
# ---------------------------------------------------------------------------
def dataset_label() -> str:
"""Capitalized singular: "Dataset" / "Datasource" """
return _sl(_("Dataset"), _("Datasource"))
def dataset_label_lower() -> str:
"""Lower-case singular: "dataset" / "datasource" """
return _sl(_("dataset"), _("datasource"))
def datasets_label() -> str:
"""Capitalized plural: "Datasets" / "Datasources" """
return _sl(_("Datasets"), _("Datasources"))
def datasets_label_lower() -> str:
"""Lower-case plural: "datasets" / "datasources" """
return _sl(_("datasets"), _("datasources"))
# ---------------------------------------------------------------------------
# "database" family
# ---------------------------------------------------------------------------
def database_label() -> str:
"""Capitalized singular: "Database" / "Data connection" """
return _sl(_("Database"), _("Data connection"))
def database_label_lower() -> str:
"""Lower-case singular: "database" / "data connection" """
return _sl(_("database"), _("data connection"))
def databases_label() -> str:
"""Capitalized plural: "Databases" / "Data connections" """
return _sl(_("Databases"), _("Data connections"))
def databases_label_lower() -> str:
"""Lower-case plural: "databases" / "data connections" """
return _sl(_("databases"), _("data connections"))
# ---------------------------------------------------------------------------
# Menu label (includes the word "Connections")
# ---------------------------------------------------------------------------
def database_connections_menu_label() -> str:
"""Menu entry label: "Database Connections" / "Data Connections" """
return _sl(_("Database Connections"), _("Data Connections"))

View File

@@ -147,3 +147,84 @@ def test_create_semantic_layer_copies_data(mocker: MockerFixture) -> None:
"type": "snowflake",
"configuration": {"account": "test"},
}
def test_create_semantic_view_success(mocker: MockerFixture) -> None:
"""Test successful creation of a semantic view."""
mock_layer = MagicMock()
dao_layer = mocker.patch(
"superset.commands.semantic_layer.create.SemanticLayerDAO",
)
dao_layer.find_by_uuid.return_value = mock_layer
dao_view = mocker.patch(
"superset.commands.semantic_layer.create.SemanticViewDAO",
)
dao_view.validate_uniqueness.return_value = True
mock_model = MagicMock()
mock_model.uuid = "new-uuid"
mock_model.name = "orders"
dao_view.create.return_value = mock_model
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
result = CreateSemanticViewCommand(
{
"name": "orders",
"semantic_layer_uuid": "layer-uuid",
"configuration": {"db": "prod"},
}
).run()
assert result == mock_model
dao_view.validate_uniqueness.assert_called_once_with(
"orders", "layer-uuid", {"db": "prod"}
)
def test_create_semantic_view_layer_not_found(mocker: MockerFixture) -> None:
"""Test CreateSemanticViewCommand raises when layer not found."""
dao_layer = mocker.patch(
"superset.commands.semantic_layer.create.SemanticLayerDAO",
)
dao_layer.find_by_uuid.return_value = None
mocker.patch(
"superset.commands.semantic_layer.create.SemanticViewDAO",
)
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
from superset.commands.semantic_layer.exceptions import (
SemanticLayerNotFoundError,
)
with pytest.raises(SemanticLayerNotFoundError):
CreateSemanticViewCommand({"name": "v", "semantic_layer_uuid": "missing"}).run()
def test_create_semantic_view_duplicate(mocker: MockerFixture) -> None:
"""Test CreateSemanticViewCommand raises on duplicate view."""
mock_layer = MagicMock()
dao_layer = mocker.patch(
"superset.commands.semantic_layer.create.SemanticLayerDAO",
)
dao_layer.find_by_uuid.return_value = mock_layer
dao_view = mocker.patch(
"superset.commands.semantic_layer.create.SemanticViewDAO",
)
dao_view.validate_uniqueness.return_value = False
from superset.commands.semantic_layer.create import CreateSemanticViewCommand
from superset.commands.semantic_layer.exceptions import (
SemanticViewCreateFailedError,
)
with pytest.raises(SemanticViewCreateFailedError):
CreateSemanticViewCommand(
{
"name": "orders",
"semantic_layer_uuid": "layer-uuid",
"configuration": {"db": "prod"},
}
).run()

View File

@@ -48,3 +48,117 @@ def test_delete_semantic_layer_not_found(mocker: MockerFixture) -> None:
with pytest.raises(SemanticLayerNotFoundError):
DeleteSemanticLayerCommand("missing-uuid").run()
def test_delete_semantic_view_success(mocker: MockerFixture) -> None:
"""Test successful deletion of a semantic view."""
mock_model = MagicMock()
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
)
dao.find_by_id.return_value = mock_model
# Admin is owner of everything — no exception raised
mocker.patch(
"superset.commands.semantic_layer.delete.security_manager"
).raise_for_ownership.return_value = None
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
DeleteSemanticViewCommand(42).run()
dao.find_by_id.assert_called_once_with(42, id_column="id")
dao.delete.assert_called_once_with([mock_model])
def test_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
"""Test that SemanticViewForbiddenError is raised for non-owners."""
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
from superset.exceptions import SupersetSecurityException
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
)
dao.find_by_id.return_value = MagicMock()
mocker.patch(
"superset.security_manager.raise_for_ownership",
side_effect=SupersetSecurityException(MagicMock()),
)
with pytest.raises(SemanticViewForbiddenError):
DeleteSemanticViewCommand(42).run()
def test_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
"""Test that SemanticViewNotFoundError is raised when view is missing."""
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
)
dao.find_by_id.return_value = None
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
from superset.commands.semantic_layer.exceptions import (
SemanticViewNotFoundError,
)
with pytest.raises(SemanticViewNotFoundError):
DeleteSemanticViewCommand(999).run()
def test_bulk_delete_semantic_view_success(mocker: MockerFixture) -> None:
"""Test successful bulk deletion of semantic views."""
mock_models = [MagicMock(), MagicMock()]
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
)
dao.find_by_ids.return_value = mock_models
mocker.patch(
"superset.commands.semantic_layer.delete.security_manager"
).raise_for_ownership.return_value = None
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
BulkDeleteSemanticViewCommand([1, 2]).run()
dao.find_by_ids.assert_called_once_with([1, 2], id_column="id")
dao.delete.assert_called_once_with(mock_models)
def test_bulk_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
"""Test that SemanticViewForbiddenError is raised for non-owners."""
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
from superset.commands.semantic_layer.exceptions import SemanticViewForbiddenError
from superset.exceptions import SupersetSecurityException
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
)
dao.find_by_ids.return_value = [MagicMock(), MagicMock()]
mocker.patch(
"superset.security_manager.raise_for_ownership",
side_effect=SupersetSecurityException(MagicMock()),
)
with pytest.raises(SemanticViewForbiddenError):
BulkDeleteSemanticViewCommand([1, 2]).run()
def test_bulk_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
"""Test that SemanticViewNotFoundError is raised when any id is missing."""
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
)
# Only one model returned for two requested ids
dao.find_by_ids.return_value = [MagicMock()]
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
from superset.commands.semantic_layer.exceptions import SemanticViewNotFoundError
with pytest.raises(SemanticViewNotFoundError):
BulkDeleteSemanticViewCommand([1, 2]).run()

View File

@@ -28,6 +28,8 @@ from superset.commands.semantic_layer.exceptions import (
SemanticLayerInvalidError,
SemanticLayerNotFoundError,
SemanticLayerUpdateFailedError,
SemanticViewCreateFailedError,
SemanticViewDeleteFailedError,
SemanticViewForbiddenError,
SemanticViewInvalidError,
SemanticViewNotFoundError,
@@ -1471,3 +1473,453 @@ def test_connections_source_type_semantic_layer_only(
assert result["source_type"] == "semantic_layer"
# Only one query (SemanticLayer), no Database query
mock_db_session.query.assert_called_once()
# =============================================================================
# SemanticViewRestApi.post (bulk create) tests
# =============================================================================
@SEMANTIC_LAYERS_APP
def test_post_semantic_view_bulk_create(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test POST / bulk creates semantic views."""
new_model = MagicMock()
new_model.uuid = uuid_lib.uuid4()
new_model.name = "View 1"
mock_command = mocker.patch(
"superset.semantic_layers.api.CreateSemanticViewCommand",
)
mock_command.return_value.run.return_value = new_model
payload = {
"views": [
{
"name": "View 1",
"semantic_layer_uuid": str(uuid_lib.uuid4()),
"configuration": {"database": "db1"},
},
],
}
response = client.post("/api/v1/semantic_view/", json=payload)
assert response.status_code == 201
result = response.json["result"]
assert len(result["created"]) == 1
assert result["created"][0]["name"] == "View 1"
@SEMANTIC_LAYERS_APP
def test_post_semantic_view_empty_views(
client: Any,
full_api_access: None,
) -> None:
"""Test POST / returns 400 when no views provided."""
response = client.post("/api/v1/semantic_view/", json={"views": []})
assert response.status_code == 400
@SEMANTIC_LAYERS_APP
def test_post_semantic_view_validation_error(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test POST / collects validation errors for individual views."""
# Missing required field "semantic_layer_uuid"
payload = {
"views": [
{"name": "Bad View"},
],
}
response = client.post("/api/v1/semantic_view/", json=payload)
assert response.status_code == 422
result = response.json["result"]
assert len(result["errors"]) == 1
assert result["errors"][0]["name"] == "Bad View"
@SEMANTIC_LAYERS_APP
def test_post_semantic_view_layer_not_found(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test POST / collects layer-not-found errors."""
mock_command = mocker.patch(
"superset.semantic_layers.api.CreateSemanticViewCommand",
)
mock_command.return_value.run.side_effect = SemanticLayerNotFoundError()
payload = {
"views": [
{
"name": "View 1",
"semantic_layer_uuid": str(uuid_lib.uuid4()),
"configuration": {},
},
],
}
response = client.post("/api/v1/semantic_view/", json=payload)
assert response.status_code == 422
result = response.json["result"]
assert len(result["errors"]) == 1
assert result["errors"][0]["error"] == "Semantic layer not found"
@SEMANTIC_LAYERS_APP
def test_post_semantic_view_create_failed(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test POST / collects create-failed errors."""
mock_command = mocker.patch(
"superset.semantic_layers.api.CreateSemanticViewCommand",
)
mock_command.return_value.run.side_effect = SemanticViewCreateFailedError()
payload = {
"views": [
{
"name": "View 1",
"semantic_layer_uuid": str(uuid_lib.uuid4()),
"configuration": {},
},
],
}
response = client.post("/api/v1/semantic_view/", json=payload)
assert response.status_code == 422
result = response.json["result"]
assert len(result["errors"]) == 1
@SEMANTIC_LAYERS_APP
def test_post_semantic_view_partial_success(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test POST / returns 201 with partial success (some created, some errors)."""
new_model = MagicMock()
new_model.uuid = uuid_lib.uuid4()
new_model.name = "Good View"
mock_command = mocker.patch(
"superset.semantic_layers.api.CreateSemanticViewCommand",
)
mock_command.return_value.run.side_effect = [
new_model,
SemanticLayerNotFoundError(),
]
layer_uuid = str(uuid_lib.uuid4())
payload = {
"views": [
{
"name": "Good View",
"semantic_layer_uuid": layer_uuid,
"configuration": {},
},
{
"name": "Bad View",
"semantic_layer_uuid": layer_uuid,
"configuration": {},
},
],
}
response = client.post("/api/v1/semantic_view/", json=payload)
assert response.status_code == 201
result = response.json["result"]
assert len(result["created"]) == 1
assert len(result["errors"]) == 1
# =============================================================================
# SemanticViewRestApi.delete tests
# =============================================================================
@SEMANTIC_LAYERS_APP
def test_delete_semantic_view(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test DELETE /<pk> deletes a semantic view."""
mock_command = mocker.patch(
"superset.semantic_layers.api.DeleteSemanticViewCommand",
)
mock_command.return_value.run.return_value = None
response = client.delete("/api/v1/semantic_view/1")
assert response.status_code == 200
mock_command.assert_called_once_with("1")
@SEMANTIC_LAYERS_APP
def test_delete_semantic_view_not_found(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test DELETE /<pk> returns 404 when view not found."""
mock_command = mocker.patch(
"superset.semantic_layers.api.DeleteSemanticViewCommand",
)
mock_command.return_value.run.side_effect = SemanticViewNotFoundError()
response = client.delete("/api/v1/semantic_view/999")
assert response.status_code == 404
@SEMANTIC_LAYERS_APP
def test_delete_semantic_view_failed(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test DELETE /<pk> returns 422 when deletion fails."""
mock_command = mocker.patch(
"superset.semantic_layers.api.DeleteSemanticViewCommand",
)
mock_command.return_value.run.side_effect = SemanticViewDeleteFailedError()
response = client.delete("/api/v1/semantic_view/1")
assert response.status_code == 422
# =============================================================================
# SemanticViewRestApi.bulk_delete tests
# =============================================================================
@SEMANTIC_LAYERS_APP
def test_bulk_delete_semantic_view(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test DELETE / deletes multiple semantic views and returns a count message."""
import prison as rison_lib
mock_command = mocker.patch(
"superset.semantic_layers.api.BulkDeleteSemanticViewCommand",
)
mock_command.return_value.run.return_value = None
q = rison_lib.dumps([1, 2, 3])
response = client.delete(f"/api/v1/semantic_view/?q={q}")
assert response.status_code == 200
assert "3" in response.json["message"]
mock_command.assert_called_once_with([1, 2, 3])
@SEMANTIC_LAYERS_APP
def test_bulk_delete_semantic_view_not_found(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test DELETE / returns 404 when any id is missing."""
import prison as rison_lib
mock_command = mocker.patch(
"superset.semantic_layers.api.BulkDeleteSemanticViewCommand",
)
mock_command.return_value.run.side_effect = SemanticViewNotFoundError()
q = rison_lib.dumps([1, 999])
response = client.delete(f"/api/v1/semantic_view/?q={q}")
assert response.status_code == 404
@SEMANTIC_LAYERS_APP
def test_bulk_delete_semantic_view_failed(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test DELETE / returns 422 when deletion fails."""
import prison as rison_lib
mock_command = mocker.patch(
"superset.semantic_layers.api.BulkDeleteSemanticViewCommand",
)
mock_command.return_value.run.side_effect = SemanticViewDeleteFailedError()
q = rison_lib.dumps([1, 2])
response = client.delete(f"/api/v1/semantic_view/?q={q}")
assert response.status_code == 422
# =============================================================================
# SemanticLayerRestApi.views tests
# =============================================================================
@SEMANTIC_LAYERS_APP
def test_get_views(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test POST /<uuid>/views returns available views."""
test_uuid = str(uuid_lib.uuid4())
mock_layer = MagicMock()
mock_layer.uuid = uuid_lib.uuid4()
mock_view1 = MagicMock()
mock_view1.name = "View A"
mock_view2 = MagicMock()
mock_view2.name = "View B"
mock_layer.implementation.get_semantic_views.return_value = [
mock_view1,
mock_view2,
]
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
mock_dao.find_by_uuid.return_value = mock_layer
mock_dao.get_semantic_views.return_value = []
response = client.post(
f"/api/v1/semantic_layer/{test_uuid}/views",
json={"runtime_data": {"database": "mydb"}},
)
assert response.status_code == 200
result = response.json["result"]
assert len(result) == 2
assert result[0]["name"] == "View A"
assert result[0]["already_added"] is False
assert result[1]["name"] == "View B"
@SEMANTIC_LAYERS_APP
def test_get_views_with_existing(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test POST /<uuid>/views marks already-added views."""
test_uuid = str(uuid_lib.uuid4())
mock_layer = MagicMock()
mock_layer.uuid = uuid_lib.uuid4()
mock_view = MagicMock()
mock_view.name = "Existing View"
mock_layer.implementation.get_semantic_views.return_value = [mock_view]
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
mock_dao.find_by_uuid.return_value = mock_layer
existing_view = MagicMock()
existing_view.name = "Existing View"
existing_view.configuration = '{"database": "mydb"}'
mock_dao.get_semantic_views.return_value = [existing_view]
response = client.post(
f"/api/v1/semantic_layer/{test_uuid}/views",
json={"runtime_data": {"database": "mydb"}},
)
assert response.status_code == 200
result = response.json["result"]
assert len(result) == 1
assert result[0]["name"] == "Existing View"
assert result[0]["already_added"] is True
@SEMANTIC_LAYERS_APP
def test_get_views_not_found(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test POST /<uuid>/views returns 404 when layer not found."""
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
mock_dao.find_by_uuid.return_value = None
response = client.post(
f"/api/v1/semantic_layer/{uuid_lib.uuid4()}/views",
json={},
)
assert response.status_code == 404
@SEMANTIC_LAYERS_APP
def test_get_views_exception(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test POST /<uuid>/views returns 400 when implementation raises."""
test_uuid = str(uuid_lib.uuid4())
mock_layer = MagicMock()
mock_layer.uuid = uuid_lib.uuid4()
mock_layer.implementation.get_semantic_views.side_effect = ValueError(
"Connection failed"
)
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
mock_dao.find_by_uuid.return_value = mock_layer
response = client.post(
f"/api/v1/semantic_layer/{test_uuid}/views",
json={"runtime_data": {}},
)
assert response.status_code == 400
assert "Unable to fetch semantic views" in response.json["message"]
@SEMANTIC_LAYERS_APP
def test_get_views_existing_dict_config(
client: Any,
full_api_access: None,
mocker: MockerFixture,
) -> None:
"""Test POST /<uuid>/views handles dict configuration on existing views."""
test_uuid = str(uuid_lib.uuid4())
mock_layer = MagicMock()
mock_layer.uuid = uuid_lib.uuid4()
mock_view = MagicMock()
mock_view.name = "View X"
mock_layer.implementation.get_semantic_views.return_value = [mock_view]
mock_dao = mocker.patch("superset.semantic_layers.api.SemanticLayerDAO")
mock_dao.find_by_uuid.return_value = mock_layer
existing_view = MagicMock()
existing_view.name = "View X"
existing_view.configuration = {"key": "val"} # dict, not string
mock_dao.get_semantic_views.return_value = [existing_view]
response = client.post(
f"/api/v1/semantic_layer/{test_uuid}/views",
json={"runtime_data": {"key": "val"}},
)
assert response.status_code == 200
result = response.json["result"]
assert result[0]["already_added"] is True

View File

@@ -560,12 +560,18 @@ def test_semantic_view_data(
mock_metrics: list[Metric],
) -> None:
"""Test SemanticView data property."""
from superset.semantic_layers.models import SemanticLayer
layer = SemanticLayer()
layer.name = "My Semantic Layer"
view = SemanticView()
view.name = "Orders View"
view.description = "View of order data"
view.id = 1
view.uuid = uuid.UUID("12345678-1234-5678-1234-567812345678")
view.semantic_layer_uuid = uuid.UUID("87654321-4321-8765-4321-876543218765")
view.semantic_layer = layer
view.cache_timeout = 3600
with patch.object(
@@ -582,6 +588,8 @@ def test_semantic_view_data(
assert data["name"] == "Orders View"
assert data["description"] == "View of order data"
assert data["cache_timeout"] == 3600
assert data["database"] == {}
assert data["parent"] == {"name": "My Semantic Layer"}
# Check columns
assert len(data["columns"]) == 2
@@ -637,11 +645,18 @@ def test_semantic_view_data_for_slices(
mock_metrics: list[Metric],
) -> None:
"""Test SemanticView data_for_slices returns same as data."""
from superset.semantic_layers.models import SemanticLayer
layer = SemanticLayer()
layer.name = "My Semantic Layer"
view = SemanticView()
view.name = "Orders View"
view.description = "View of order data"
view.id = 1
view.uuid = uuid.UUID("12345678-1234-5678-1234-567812345678")
view.semantic_layer_uuid = uuid.UUID("87654321-4321-8765-4321-876543218765")
view.semantic_layer = layer
view.cache_timeout = 3600
with patch.object(