Compare commits

..
Author SHA1 Message Date
Beto Dealmeida 808d905c9e Fix edit dataset 2026-04-23 13:35:27 -04:00
Beto Dealmeida 152f11d1d4 Small fixes 2026-04-23 13:35:27 -04:00
Beto Dealmeida b2784f297c Fix lint 2026-04-23 13:35:27 -04:00
Beto Dealmeida 0e249e6348 Fix filters 2026-04-23 13:35:26 -04:00
Beto Dealmeida ab52bd14c4 Working on filters 2026-04-23 13:35:26 -04:00
Beto Dealmeida 5b4035427d Working on filters 2026-04-23 13:34:36 -04:00
Beto Dealmeida 07712fbd20 Fix header name 2026-04-23 13:33:49 -04:00
Beto Dealmeida 580243d84a Fix data connection name 2026-04-23 13:33:49 -04:00
Beto Dealmeida 4827fbb615 Small fixes 2026-04-23 13:33:49 -04:00
Beto Dealmeida 1d4f31c4df Fix lint 2026-04-23 13:33:49 -04:00
Beto Dealmeida bf71d49bd6 Revert some aggressive renames 2026-04-23 13:33:49 -04:00
Beto Dealmeida 47973bd789 More fixes 2026-04-23 13:33:49 -04:00
Beto Dealmeida 1b089f828a chore: rename database/database when using semantic layers 2026-04-23 13:33:49 -04:00
Beto Dealmeida 3c20d62ed2 Small fixes 2026-04-23 13:33:48 -04:00
Beto Dealmeida cc15ffd044 Bulk delete 2026-04-23 13:33:48 -04:00
Beto Dealmeida 1f43b3fa0e Address comments 2026-04-23 13:33:48 -04:00
Beto Dealmeida fc5a3cd32b Improve design 2026-04-23 13:33:48 -04:00
Beto Dealmeida 4490bffd50 Fix lint/tests 2026-04-23 13:33:48 -04:00
Beto Dealmeida a1a05134d2 Fix imports 2026-04-23 13:33:48 -04:00
Beto Dealmeida 8c69f13a71 feat: CRUD for adding/deleting semantic views 2026-04-23 13:33:48 -04:00
Beto Dealmeida 4c645ce952 Fix rebase 2026-04-23 13:33:48 -04:00
Beto Dealmeida 379403834a feat: UI for semantic layers 2026-04-23 13:33:48 -04:00
Beto Dealmeida 469acf12b2 Update permissions 2026-04-23 13:33:48 -04:00
Beto Dealmeida bc58d13c34 Address comments 2026-04-23 13:33:48 -04:00
Beto Dealmeida ebbf4777e3 feat: API for semantic layers 2026-04-23 13:33:48 -04:00
19 changed files with 90 additions and 688 deletions
@@ -153,79 +153,6 @@ 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,
@@ -268,7 +195,6 @@ export const exploreActions = {
sliceUpdated,
setForceQuery,
syncDatasourceMetadata,
fetchCompatibility,
};
export type ExploreActions = typeof exploreActions;
@@ -16,9 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { RefObject, useMemo } from 'react';
import { RefObject } 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';
@@ -28,7 +27,6 @@ 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';
@@ -72,40 +70,11 @@ 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(),
}),
@@ -118,14 +87,7 @@ export default function DatasourcePanelDragOption(
};
return (
<DatasourceItemContainer
data-test="DatasourcePanelDragOption"
ref={drag}
style={{
opacity: isCompatible ? 1 : 0.35,
cursor: isCompatible ? 'grab' : 'not-allowed',
}}
>
<DatasourceItemContainer data-test="DatasourcePanelDragOption" ref={drag}>
{type === DndItemType.Column ? (
<StyledColumnOption column={value as ColumnMeta} {...optionProps} />
) : (
@@ -122,7 +122,7 @@ const sortColumns = (slice: DatasourcePanelColumn[]) =>
if (col2?.is_dttm && !col1?.is_dttm) {
return 1;
}
return (col1?.column_name ?? '').localeCompare(col2?.column_name ?? '');
return 0;
})
.sort((a, b) => (b?.is_certified ?? 0) - (a?.is_certified ?? 0));
@@ -191,9 +191,7 @@ export default function DataSourcePanel({
const filteredMetrics = useMemo(() => {
if (!searchKeyword) {
return [...(allowedMetrics ?? [])].sort((a, b) =>
(a?.metric_name ?? '').localeCompare(b?.metric_name ?? ''),
);
return allowedMetrics ?? [];
}
return matchSorter(allowedMetrics, searchKeyword, {
keys: [
@@ -36,7 +36,6 @@ import {
JsonObject,
MatrixifyFormData,
DatasourceType,
ensureIsArray,
} from '@superset-ui/core';
import {
ControlStateMapping,
@@ -413,49 +412,6 @@ 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
@@ -142,10 +142,6 @@ 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);
@@ -171,22 +167,21 @@ const ColumnSelectPopover = ({
const sqlEditorRef = useRef<editors.EditorHandle>(null);
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]);
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],
);
// Filter metrics that are already selected in the chart
const availableMetrics = useMemo(() => {
@@ -556,11 +551,6 @@ 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,
@@ -575,9 +565,6 @@ 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={[
@@ -130,16 +130,6 @@ 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(() => {
@@ -314,7 +304,7 @@ function DndColumnMetricSelect(props: DndColumnMetricSelectProps) {
}}
editedColumn={column}
isTemporal={isTemporal}
disabledTabs={effectiveDisabledTabs}
disabledTabs={disabledTabs}
>
<OptionWrapper
key={`column-${idx}`}
@@ -454,7 +444,7 @@ function DndColumnMetricSelect(props: DndColumnMetricSelectProps) {
togglePopover={toggleColumnPopover}
closePopover={closeColumnPopover}
isTemporal={false}
disabledTabs={effectiveDisabledTabs}
disabledTabs={disabledTabs}
metrics={savedMetrics}
selectedMetrics={selectedMetrics}
>
@@ -17,7 +17,6 @@
* 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';
@@ -28,7 +27,6 @@ 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';
@@ -52,19 +50,6 @@ 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(() => {
@@ -140,7 +125,7 @@ function DndColumnSelect(props: DndColumnSelectProps) {
}}
editedColumn={column}
isTemporal={isTemporal}
disabledTabs={effectiveDisabledTabs}
disabledTabs={disabledTabs}
>
<OptionWrapper
key={idx}
@@ -224,7 +209,7 @@ function DndColumnSelect(props: DndColumnSelectProps) {
closePopover={closePopover}
visible={newColumnPopoverVisible}
isTemporal={isTemporal}
disabledTabs={effectiveDisabledTabs}
disabledTabs={disabledTabs}
>
<div />
</ColumnSelectPopoverTrigger>
@@ -132,26 +132,6 @@ 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(
@@ -204,7 +184,7 @@ const DndMetricSelect = (props: any) => {
const canDrop = useCallback(
(item: DatasourcePanelDndItem) => {
if (
disallowAdhocMetrics &&
extra.disallow_adhoc_metrics &&
(item.type !== DndItemType.Metric ||
!savedMetricSet.has(item.value.metric_name))
) {
@@ -313,7 +293,7 @@ const DndMetricSelect = (props: any) => {
columns={props.columns}
savedMetrics={props.savedMetrics}
savedMetricsOptions={getSavedMetricOptionsForMetric(index)}
datasource={datasourceForPopover}
datasource={props.datasource}
onMoveLabel={moveLabel}
onDropLabel={handleDropLabel}
type={`${DndItemType.AdhocMetricOption}_${props.name}_${props.label}`}
@@ -422,7 +402,7 @@ const DndMetricSelect = (props: any) => {
columns={props.columns}
savedMetricsOptions={newSavedMetricOptions}
savedMetric={EMPTY_OBJECT as savedMetricType}
datasource={datasourceForPopover}
datasource={props.datasource}
isControlledComponent
visible={newMetricPopoverVisible}
togglePopover={togglePopover}
@@ -415,25 +415,21 @@ export default class AdhocFilterEditPopover extends Component<
</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>
),
},
]),
{
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 && (
@@ -18,7 +18,6 @@
*/
/* 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';
@@ -95,8 +94,6 @@ interface AdhocMetricEditPopoverProps {
datasource?: DatasourceInfo;
isNewMetric?: boolean;
isLabelModified?: boolean;
/** Names of metrics the user may select; null means no filtering. */
compatibleMetrics?: string[] | null;
}
interface AdhocMetricEditPopoverState {
@@ -126,7 +123,7 @@ const StyledSelect = styled(Select)`
export const SAVED_TAB_KEY = 'SAVED';
class AdhocMetricEditPopover extends PureComponent<
export default class AdhocMetricEditPopover extends PureComponent<
AdhocMetricEditPopoverProps,
AdhocMetricEditPopoverState
> {
@@ -441,24 +438,15 @@ class AdhocMetricEditPopover extends PureComponent<
ensureIsArray(savedMetricsOptions).length > 0 ? (
<FormItem label={t('Saved metric')}>
<StyledSelect
options={[...ensureIsArray(savedMetricsOptions)]
.sort((a, b) =>
(a.metric_name ?? '').localeCompare(
b.metric_name ?? '',
),
)
.map(savedMetric => ({
options={ensureIsArray(savedMetricsOptions).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}
/>
@@ -608,20 +596,3 @@ 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;
@@ -65,9 +65,6 @@ export interface ExploreState {
metadata?: {
owners?: string[] | null;
};
compatibleMetrics?: string[] | null;
compatibleDimensions?: string[] | null;
compatibilityLoading?: boolean;
saveAction?: SaveActionType | null;
}
@@ -168,13 +165,6 @@ interface SetForceQueryAction {
force: boolean;
}
interface SetCompatibilityAction {
type: typeof actions.SET_COMPATIBILITY;
compatibleMetrics: string[] | null;
compatibleDimensions: string[] | null;
compatibilityLoading: boolean;
}
type ExploreAction =
| DynamicPluginControlsReadyAction
| ToggleFaveStarAction
@@ -193,7 +183,6 @@ type ExploreAction =
| SetStashFormDataAction
| SliceUpdatedAction
| SetForceQueryAction
| SetCompatibilityAction
| HydrateExplore;
// Extended control state for dynamic form controls - uses Record for flexibility
@@ -632,15 +621,6 @@ 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 {
-3
View File
@@ -130,9 +130,6 @@ export interface ExplorePageState {
standalone: boolean;
force: boolean;
common: JsonObject;
compatibleMetrics?: string[] | null;
compatibleDimensions?: string[] | null;
compatibilityLoading?: boolean;
};
sliceEntities?: JsonObject; // propagated from Dashboard view
}
@@ -170,13 +170,13 @@ export default function SemanticLayerModal({
setSelectedType(layer.type);
setFormData(layer.configuration ?? {});
setHasErrors(false);
// 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.
// Fetch base schema (no configuration -> no Snowflake connection) to
// show the form immediately. The existing maybeRefreshSchema machinery
// will trigger an enriched fetch in the background once deps are
// satisfied, and DynamicFieldControl will show per-field spinners.
const { json: schemaJson } = await SupersetClient.post({
endpoint: '/api/v1/semantic_layer/schema/configuration',
jsonPayload: { type: layer.type, configuration: layer.configuration },
jsonPayload: { type: layer.type },
});
applySchema(schemaJson.result);
setStep('config');
@@ -283,7 +283,7 @@ export default function SemanticLayerModal({
// Check if any dynamic field has all dependencies satisfied
const hasSatisfiedDeps = Object.values(dynamicDeps).some(deps =>
areDependenciesSatisfied(deps, data, configSchema ?? undefined),
areDependenciesSatisfied(deps, data),
);
if (!hasSatisfiedDeps) return;
@@ -297,7 +297,7 @@ export default function SemanticLayerModal({
fetchConfigSchema(selectedType, data);
}, SCHEMA_REFRESH_DEBOUNCE_MS);
},
[selectedType, fetchConfigSchema, configSchema],
[selectedType, fetchConfigSchema],
);
const handleFormChange = useCallback(
@@ -385,25 +385,16 @@ export default function SemanticLayerModal({
/>
</ModalFormField>
{configSchema && (
// 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>
<JsonForms
schema={configSchema}
uischema={uiSchema}
data={formData}
renderers={renderers}
cells={cellRegistryEntries}
config={{ refreshingSchema, formData }}
validationMode={validationMode}
onChange={handleFormChange}
/>
)}
</ModalContent>
)}
@@ -18,7 +18,7 @@
*/
import { useEffect } from 'react';
import { t } from '@apache-superset/core/translation';
import { Spin, Select, Form } from 'antd';
import { Spin } from 'antd';
import { withJsonFormsControlProps } from '@jsonforms/react';
import type {
JsonSchema,
@@ -46,18 +46,7 @@ export const SCHEMA_REFRESH_DEBOUNCE_MS = 500;
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',
},
},
options: { ...props.uischema.options, type: 'password' },
};
return TextControl({ ...props, uischema });
}
@@ -139,30 +128,17 @@ const readOnlyEntry = {
/**
* 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 !== ''
);
if (value === null || value === undefined || value === '') return false;
if (typeof value === 'object' && Object.keys(value).length === 0)
return false;
return true;
});
}
@@ -180,7 +156,6 @@ function DynamicFieldControl(props: ControlProps) {
areDependenciesSatisfied(
deps as string[],
(cfgData as Record<string, unknown>) ?? {},
props.rootSchema,
);
if (!refreshing) {
@@ -211,66 +186,11 @@ const dynamicFieldEntry = {
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,
];
@@ -201,7 +201,20 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
const [loading, setLoading] = useState(true);
const [lastFetchConfig, setLastFetchConfig] =
useState<ListViewFetchDataConfig | null>(null);
const [currentSourceFilter, setCurrentSourceFilter] = useState<string>('');
const currentSourceFilter = useMemo(() => {
const sourceTypeFilter = lastFetchConfig?.filters.find(
filter => filter.id === 'source_type',
);
if (
sourceTypeFilter?.value &&
typeof sourceTypeFilter.value === 'object' &&
'value' in sourceTypeFilter.value
) {
return sourceTypeFilter.value.value as string;
}
return (sourceTypeFilter?.value as string | undefined) ?? '';
}, [lastFetchConfig]);
// 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);
@@ -361,13 +374,6 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
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' && f.id !== 'database')
.filter(
@@ -395,13 +401,13 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
});
}
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.
@@ -498,20 +504,6 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
state.common?.conf?.PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET || false,
);
const currentSourceFilter = useMemo(() => {
const sourceTypeFilter = lastFetchConfig?.filters.find(
filter => filter.id === 'source_type',
);
if (
sourceTypeFilter?.value &&
typeof sourceTypeFilter.value === 'object' &&
'value' in sourceTypeFilter.value
) {
return sourceTypeFilter.value.value as string;
}
return (sourceTypeFilter?.value as string | undefined) ?? '';
}, [lastFetchConfig]);
const openDatasetImportModal = () => {
showImportModal(true);
};
-20
View File
@@ -270,26 +270,6 @@ 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.
-126
View File
@@ -14,7 +14,6 @@
# 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
@@ -28,9 +27,7 @@ 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
@@ -310,129 +307,6 @@ 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
-40
View File
@@ -507,43 +507,3 @@ 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.
"""
-43
View File
@@ -404,49 +404,6 @@ 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]