You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/06/09 17:20:15 UTC
(superset) branch adopt/lineage-visualization created (now 23123ce6d75)
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a change to branch adopt/lineage-visualization
in repository https://gitbox.apache.org/repos/asf/superset.git
at 23123ce6d75 feat(lineage): add lineage visualization across datasets, charts and dashboards
This branch includes the following new commits:
new 23123ce6d75 feat(lineage): add lineage visualization across datasets, charts and dashboards
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(superset) 01/01: feat(lineage): add lineage visualization across datasets, charts and dashboards
Posted by ru...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch adopt/lineage-visualization
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 23123ce6d75c388555dd59e97b50c3704e2411e9
Author: Claude Code <no...@anthropic.com>
AuthorDate: Tue Jun 9 10:19:59 2026 -0700
feat(lineage): add lineage visualization across datasets, charts and dashboards
Adopts #37701 by @qf-jonathan. Adds interactive Sankey-based lineage that shows
the data relationships for a dataset, chart, or dashboard: three REST endpoints
(/api/v1/lineage/{dataset|chart|dashboard}/<id>) plus a LineageView/LineageModal
frontend surfaced from the dataset editor and the chart/dashboard menus.
Rebased onto current master and updated for drift since the original branch:
- repointed lineage imports from the removed '@apache-superset/core/ui' to
'@apache-superset/core/translation' and '/theme'
- re-applied the dataset lineage tab onto the migrated (TypeScript) DatasourceEditor
Closes #37701
Co-authored-by: Jonathan Alberth Quispe Fuentes <qf...@gmail.com>
Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
.../DatasourceEditor/DatasourceEditor.tsx | 22 +
.../Header/useHeaderActionsDropdownMenu.tsx | 19 +
superset-frontend/src/dashboard/types.ts | 1 +
.../useExploreAdditionalActionsMenu/index.tsx | 19 +
.../src/features/charts/ChartCard.tsx | 128 ++--
.../src/features/dashboards/DashboardCard.tsx | 122 ++--
.../datasets/AddDataset/EditDataset/index.tsx | 12 +
.../src/features/lineage/LineageModal.tsx | 79 +++
.../src/features/lineage/LineageView.tsx | 690 +++++++++++++++++++++
.../apiResources => features/lineage}/index.ts | 17 +-
superset-frontend/src/hooks/apiResources/index.ts | 1 +
.../src/hooks/apiResources/lineage.ts | 134 ++++
superset/charts/api.py | 98 +++
superset/charts/schemas.py | 48 ++
superset/constants.py | 1 +
superset/dashboards/api.py | 109 ++++
superset/dashboards/schemas.py | 57 ++
superset/datasets/api.py | 116 ++++
superset/datasets/schemas.py | 54 ++
tests/integration_tests/charts/api_tests.py | 32 +
tests/integration_tests/dashboards/api_tests.py | 32 +
tests/integration_tests/datasets/api_tests.py | 32 +
tests/integration_tests/fixtures/lineage.py | 266 ++++++++
23 files changed, 1976 insertions(+), 113 deletions(-)
diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
index 8bc2b6cf8f3..0bf47f57c78 100644
--- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
+++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
@@ -89,6 +89,8 @@ import {
import { DatabaseSelector } from '../../../DatabaseSelector';
import CollectionTable from '../CollectionTable';
import Fieldset from '../Fieldset';
+import { useDatasetLineage } from 'src/hooks/apiResources';
+import { LineageView } from 'src/features/lineage';
import Field from '../Field';
import { fetchSyncedColumns, updateColumns } from '../../utils';
import DatasetUsageTab from './components/DatasetUsageTab';
@@ -425,6 +427,16 @@ const StyledTableTabWrapper = styled.div`
}
`;
+// Functional wrapper for the lineage tab, since hooks can't be used directly in
+// the DatasourceEditor class component.
+function DatasetLineageTab({ datasourceId }: { datasourceId?: number }) {
+ const lineageResource = useDatasetLineage(datasourceId ?? 0);
+ if (!datasourceId) {
+ return <Loading />;
+ }
+ return <LineageView lineageResource={lineageResource} entityType="dataset" />;
+}
+
const DefaultColumnSettingsContainer = styled.div`
${({ theme }) => css`
margin-bottom: ${theme.sizeUnit * 4}px;
@@ -477,6 +489,7 @@ const TABS_KEYS = {
COLUMNS: 'COLUMNS',
CALCULATED_COLUMNS: 'CALCULATED_COLUMNS',
USAGE: 'USAGE',
+ LINEAGE: 'LINEAGE',
FOLDERS: 'FOLDERS',
SETTINGS: 'SETTINGS',
SPATIAL: 'SPATIAL',
@@ -2515,6 +2528,15 @@ class DatasourceEditor extends PureComponent<
</StyledTableTabWrapper>
),
},
+ {
+ key: TABS_KEYS.LINEAGE,
+ label: t('Lineage'),
+ children: (
+ <StyledTableTabWrapper>
+ <DatasetLineageTab datasourceId={datasource.id} />
+ </StyledTableTabWrapper>
+ ),
+ },
...(isFeatureEnabled(FeatureFlag.DatasetFolders)
? [
{
diff --git a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
index 102a0a70581..1131e19c553 100644
--- a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
+++ b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
@@ -37,6 +37,7 @@ import { getUrlParam } from 'src/utils/urlUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
import { usePermissions } from 'src/hooks/usePermissions';
+import { LineageModal } from 'src/features/lineage';
export const useHeaderActionsMenu = ({
customCss,
@@ -240,6 +241,24 @@ export const useHeaderActionsMenu = ({
key: MenuKeys.EditProperties,
label: t('Edit properties'),
});
+
+ // View lineage
+ if (dashboardId) {
+ menuItems.push(
+ createModalMenuItem(
+ MenuKeys.ViewLineage,
+ <LineageModal
+ entityType="dashboard"
+ entityId={dashboardId}
+ triggerNode={
+ <div data-test="view-lineage-menu-item">
+ {t('View lineage')}
+ </div>
+ }
+ />,
+ ),
+ );
+ }
}
// Divider
diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts
index 2a7056bebab..fd0ea611f90 100644
--- a/superset-frontend/src/dashboard/types.ts
+++ b/superset-frontend/src/dashboard/types.ts
@@ -393,4 +393,5 @@ export enum MenuKeys {
ManageEmailReports = 'manage_email_reports',
ExportPivotXlsx = 'export_pivot_xlsx',
EmbedCode = 'embed_code',
+ ViewLineage = 'view_lineage',
}
diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
index 6b9e7b048de..9f441c0d6de 100644
--- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
+++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
@@ -72,6 +72,7 @@ import { ReportObject } from 'src/features/reports/types';
import ViewQueryModal from '../controls/ViewQueryModal';
import EmbedCodeContent from '../EmbedCodeContent';
import { useDashboardsMenuItems } from './DashboardsSubMenu';
+import { LineageModal } from 'src/features/lineage';
export const SEARCH_THRESHOLD = 10;
@@ -102,6 +103,7 @@ const MENU_KEYS = {
EDIT_REPORT: 'edit_report',
DELETE_REPORT: 'delete_report',
VIEW_QUERY: 'view_query',
+ VIEW_LINEAGE: 'view_lineage',
RUN_IN_SQL_LAB: 'run_in_sql_lab',
};
@@ -1028,6 +1030,23 @@ export const useExploreAdditionalActionsMenu = (
onClick: () => setIsDropdownVisible(false),
});
+ // View lineage
+ if (slice?.slice_id) {
+ menuItems.push({
+ key: MENU_KEYS.VIEW_LINEAGE,
+ label: (
+ <LineageModal
+ entityType="chart"
+ entityId={slice.slice_id}
+ triggerNode={
+ <div data-test="view-lineage-menu-item">{t('View lineage')}</div>
+ }
+ />
+ ),
+ onClick: () => setIsDropdownVisible(false),
+ });
+ }
+
// Run in SQL Lab
if (datasource) {
menuItems.push({
diff --git a/superset-frontend/src/features/charts/ChartCard.tsx b/superset-frontend/src/features/charts/ChartCard.tsx
index 47562c84450..2ef82e5df18 100644
--- a/superset-frontend/src/features/charts/ChartCard.tsx
+++ b/superset-frontend/src/features/charts/ChartCard.tsx
@@ -36,6 +36,7 @@ import { handleChartDelete, CardStyles } from 'src/views/CRUD/utils';
import { assetUrl } from 'src/utils/assetUrl';
import type { ListViewFetchDataConfig as FetchDataConfig } from 'src/components';
import { TableTab } from 'src/views/CRUD/types';
+import { LineageModal } from 'src/features/lineage';
interface ChartCardProps {
chart: Chart;
@@ -76,6 +77,7 @@ export default function ChartCard({
const canEdit = hasPerm('can_write');
const canDelete = hasPerm('can_write');
const canExport = hasPerm('can_export');
+ const canRead = hasPerm('can_read');
const menuItems: MenuItem[] = [];
if (canEdit) {
@@ -100,6 +102,29 @@ export default function ChartCard({
});
}
+ if (canRead) {
+ menuItems.push({
+ key: 'lineage',
+ label: (
+ <LineageModal
+ entityType="chart"
+ entityId={chart.id}
+ triggerNode={
+ <div>
+ <Icons.ShareAltOutlined
+ iconSize="l"
+ css={css`
+ vertical-align: text-top;
+ `}
+ />{' '}
+ {t('View Lineage')}
+ </div>
+ }
+ />
+ ),
+ });
+ }
+
if (canExport) {
menuItems.push({
key: 'export',
@@ -167,54 +192,59 @@ export default function ChartCard({
}
return (
- <CardStyles
- onClick={() => {
- if (!bulkSelectEnabled && chart.url) {
- history.push(chart.url);
- }
- }}
- >
- <ListViewCard
- loading={loading}
- title={chart.slice_name}
- certifiedBy={chart.certified_by}
- certificationDetails={chart.certification_details}
- cover={
- !isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? (
- <></>
- ) : null
- }
- url={bulkSelectEnabled ? undefined : chart.url}
- imgURL={chart.thumbnail_url || ''}
- imgFallbackURL={assetUrl(
- '/static/assets/images/chart-card-fallback.svg',
- )}
- description={t('Modified %s', chart.changed_on_delta_humanized)}
- coverLeft={<FacePile users={chart.owners || []} />}
- coverRight={<Label>{chart.datasource_name_text}</Label>}
- linkComponent={Link}
- actions={
- <ListViewCard.Actions
- onClick={e => {
- e.stopPropagation();
- e.preventDefault();
- }}
- >
- {userId && (
- <FaveStar
- itemId={chart.id}
- saveFaveStar={saveFavoriteStatus}
- isStarred={favoriteStatus}
- />
- )}
- <Dropdown menu={{ items: menuItems }} trigger={['click', 'hover']}>
- <Button buttonSize="xsmall" type="link" buttonStyle="link">
- <Icons.MoreOutlined iconSize="xl" />
- </Button>
- </Dropdown>
- </ListViewCard.Actions>
- }
- />
- </CardStyles>
+ <>
+ <CardStyles
+ onClick={() => {
+ if (!bulkSelectEnabled && chart.url) {
+ history.push(chart.url);
+ }
+ }}
+ >
+ <ListViewCard
+ loading={loading}
+ title={chart.slice_name}
+ certifiedBy={chart.certified_by}
+ certificationDetails={chart.certification_details}
+ cover={
+ !isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? (
+ <></>
+ ) : null
+ }
+ url={bulkSelectEnabled ? undefined : chart.url}
+ imgURL={chart.thumbnail_url || ''}
+ imgFallbackURL={assetUrl(
+ '/static/assets/images/chart-card-fallback.svg',
+ )}
+ description={t('Modified %s', chart.changed_on_delta_humanized)}
+ coverLeft={<FacePile users={chart.owners || []} />}
+ coverRight={<Label>{chart.datasource_name_text}</Label>}
+ linkComponent={Link}
+ actions={
+ <ListViewCard.Actions
+ onClick={e => {
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ >
+ {userId && (
+ <FaveStar
+ itemId={chart.id}
+ saveFaveStar={saveFavoriteStatus}
+ isStarred={favoriteStatus}
+ />
+ )}
+ <Dropdown
+ menu={{ items: menuItems }}
+ trigger={['click', 'hover']}
+ >
+ <Button buttonSize="xsmall" type="link" buttonStyle="link">
+ <Icons.MoreOutlined iconSize="xl" />
+ </Button>
+ </Dropdown>
+ </ListViewCard.Actions>
+ }
+ />
+ </CardStyles>
+ </>
);
}
diff --git a/superset-frontend/src/features/dashboards/DashboardCard.tsx b/superset-frontend/src/features/dashboards/DashboardCard.tsx
index 5793e0e1bc0..6b57e301983 100644
--- a/superset-frontend/src/features/dashboards/DashboardCard.tsx
+++ b/superset-frontend/src/features/dashboards/DashboardCard.tsx
@@ -37,6 +37,7 @@ import { Icons } from '@superset-ui/core/components/Icons';
import { Dashboard } from 'src/views/CRUD/types';
import { assetUrl } from 'src/utils/assetUrl';
import { FacePile } from 'src/components';
+import { LineageModal } from 'src/features/lineage';
interface DashboardCardProps {
isChart?: boolean;
@@ -69,6 +70,7 @@ function DashboardCard({
const canEdit = hasPerm('can_write');
const canDelete = hasPerm('can_write');
const canExport = hasPerm('can_export');
+ const canRead = hasPerm('can_read');
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const [fetchingThumbnail, setFetchingThumbnail] = useState<boolean>(false);
@@ -99,6 +101,23 @@ function DashboardCard({
const menuItems: MenuItem[] = [];
+ if (canRead) {
+ menuItems.push({
+ key: 'lineage',
+ label: (
+ <LineageModal
+ entityType="dashboard"
+ entityId={dashboard.id}
+ triggerNode={
+ <div data-test="dashboard-card-option-lineage-button">
+ <Icons.ShareAltOutlined iconSize="l" /> {t('View Lineage')}
+ </div>
+ }
+ />
+ ),
+ });
+ }
+
if (canEdit && openDashboardEditModal) {
menuItems.push({
key: 'edit',
@@ -151,55 +170,60 @@ function DashboardCard({
}
return (
- <CardStyles
- onClick={() => {
- if (!bulkSelectEnabled) {
- history.push(dashboard.url);
- }
- }}
- >
- <ListViewCard
- loading={dashboard.loading || false}
- title={dashboard.dashboard_title}
- certifiedBy={dashboard.certified_by}
- certificationDetails={dashboard.certification_details}
- titleRight={<PublishedLabel isPublished={dashboard.published} />}
- cover={
- !isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? (
- <></>
- ) : null
- }
- url={bulkSelectEnabled ? undefined : dashboard.url}
- linkComponent={Link}
- imgURL={thumbnailUrl}
- imgFallbackURL={assetUrl(
- '/static/assets/images/dashboard-card-fallback.svg',
- )}
- description={t('Modified %s', dashboard.changed_on_delta_humanized)}
- coverLeft={<FacePile users={dashboard.owners || []} />}
- actions={
- <ListViewCard.Actions
- onClick={e => {
- e.stopPropagation();
- e.preventDefault();
- }}
- >
- {userId && (
- <FaveStar
- itemId={dashboard.id}
- saveFaveStar={saveFavoriteStatus}
- isStarred={favoriteStatus}
- />
- )}
- <Dropdown menu={{ items: menuItems }} trigger={['hover', 'click']}>
- <Button buttonSize="xsmall" buttonStyle="link">
- <Icons.MoreOutlined iconSize="xl" />
- </Button>
- </Dropdown>
- </ListViewCard.Actions>
- }
- />
- </CardStyles>
+ <>
+ <CardStyles
+ onClick={() => {
+ if (!bulkSelectEnabled) {
+ history.push(dashboard.url);
+ }
+ }}
+ >
+ <ListViewCard
+ loading={dashboard.loading || false}
+ title={dashboard.dashboard_title}
+ certifiedBy={dashboard.certified_by}
+ certificationDetails={dashboard.certification_details}
+ titleRight={<PublishedLabel isPublished={dashboard.published} />}
+ cover={
+ !isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? (
+ <></>
+ ) : null
+ }
+ url={bulkSelectEnabled ? undefined : dashboard.url}
+ linkComponent={Link}
+ imgURL={thumbnailUrl}
+ imgFallbackURL={assetUrl(
+ '/static/assets/images/dashboard-card-fallback.svg',
+ )}
+ description={t('Modified %s', dashboard.changed_on_delta_humanized)}
+ coverLeft={<FacePile users={dashboard.owners || []} />}
+ actions={
+ <ListViewCard.Actions
+ onClick={e => {
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ >
+ {userId && (
+ <FaveStar
+ itemId={dashboard.id}
+ saveFaveStar={saveFavoriteStatus}
+ isStarred={favoriteStatus}
+ />
+ )}
+ <Dropdown
+ menu={{ items: menuItems }}
+ trigger={['hover', 'click']}
+ >
+ <Button buttonSize="xsmall" buttonStyle="link">
+ <Icons.MoreOutlined iconSize="xl" />
+ </Button>
+ </Dropdown>
+ </ListViewCard.Actions>
+ }
+ />
+ </CardStyles>
+ </>
);
}
diff --git a/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx b/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx
index e0438cee0bd..aad0c5e049a 100644
--- a/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx
+++ b/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx
@@ -21,6 +21,8 @@ import { styled } from '@apache-superset/core/theme';
import useGetDatasetRelatedCounts from 'src/features/datasets/hooks/useGetDatasetRelatedCounts';
import { Badge } from '@superset-ui/core/components';
import Tabs from '@superset-ui/core/components/Tabs';
+import { useDatasetLineage } from 'src/hooks/apiResources';
+import { LineageView } from 'src/features/lineage';
const StyledTabs = styled(Tabs)`
${({ theme }) => `
@@ -51,16 +53,19 @@ const TRANSLATIONS = {
USAGE_TEXT: t('Usage'),
COLUMNS_TEXT: t('Columns'),
METRICS_TEXT: t('Metrics'),
+ LINEAGE_TEXT: t('Lineage'),
};
const TABS_KEYS = {
COLUMNS: 'COLUMNS',
METRICS: 'METRICS',
USAGE: 'USAGE',
+ LINEAGE: 'LINEAGE',
};
const EditPage = ({ id }: EditPageProps) => {
const { usageCount } = useGetDatasetRelatedCounts(id);
+ const lineageResource = useDatasetLineage(id);
const usageTab = (
<TabStyles>
@@ -85,6 +90,13 @@ const EditPage = ({ id }: EditPageProps) => {
label: usageTab,
children: null,
},
+ {
+ key: TABS_KEYS.LINEAGE,
+ label: TRANSLATIONS.LINEAGE_TEXT,
+ children: (
+ <LineageView lineageResource={lineageResource} entityType="dataset" />
+ ),
+ },
];
return <StyledTabs moreIcon={null} items={items} />;
diff --git a/superset-frontend/src/features/lineage/LineageModal.tsx b/superset-frontend/src/features/lineage/LineageModal.tsx
new file mode 100644
index 00000000000..77f820523e1
--- /dev/null
+++ b/superset-frontend/src/features/lineage/LineageModal.tsx
@@ -0,0 +1,79 @@
+/**
+ * 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 { FC, ReactNode } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { ModalTrigger } from '@superset-ui/core/components';
+import {
+ useChartLineage,
+ useDashboardLineage,
+ useDatasetLineage,
+} from 'src/hooks/apiResources';
+import LineageView from './LineageView';
+
+export interface LineageModalProps {
+ entityType: 'dataset' | 'chart' | 'dashboard';
+ entityId: string | number;
+ triggerNode: ReactNode;
+}
+
+const LineageModal: FC<LineageModalProps> = ({
+ entityType,
+ entityId,
+ triggerNode,
+}) => {
+ const datasetLineage = useDatasetLineage(
+ entityType === 'dataset' ? entityId : '',
+ );
+ const chartLineage = useChartLineage(entityType === 'chart' ? entityId : '');
+ const dashboardLineage = useDashboardLineage(
+ entityType === 'dashboard' ? entityId : '',
+ );
+
+ const lineageResource =
+ entityType === 'dataset'
+ ? datasetLineage
+ : entityType === 'chart'
+ ? chartLineage
+ : dashboardLineage;
+
+ const title =
+ entityType === 'dataset'
+ ? t('Dataset Lineage')
+ : entityType === 'chart'
+ ? t('Chart Lineage')
+ : t('Dashboard Lineage');
+
+ return (
+ <ModalTrigger
+ triggerNode={triggerNode}
+ modalTitle={title}
+ modalBody={
+ <LineageView
+ lineageResource={lineageResource}
+ entityType={entityType}
+ />
+ }
+ width="850px"
+ responsive
+ destroyOnHidden
+ />
+ );
+};
+
+export default LineageModal;
diff --git a/superset-frontend/src/features/lineage/LineageView.tsx b/superset-frontend/src/features/lineage/LineageView.tsx
new file mode 100644
index 00000000000..1b8b2bbfec0
--- /dev/null
+++ b/superset-frontend/src/features/lineage/LineageView.tsx
@@ -0,0 +1,690 @@
+/**
+ * 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 { FC, useMemo, useState, useCallback } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { styled, useTheme } from '@apache-superset/core/theme';
+import { Empty, Loading } from '@superset-ui/core/components';
+import { Button } from '@superset-ui/core/components';
+import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
+import type { Resource } from 'src/hooks/apiResources/apiResources';
+import type {
+ DatasetLineage,
+ ChartLineage,
+ DashboardLineage,
+ ChartEntity,
+ DashboardEntity,
+ DatasetEntity,
+ DatabaseEntity,
+} from 'src/hooks/apiResources/lineage';
+import Echart from '../../../plugins/plugin-chart-echarts/src/components/Echart';
+import type { EChartsCoreOption } from 'echarts/core';
+
+const LineageContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+`;
+
+const Legend = styled.div`
+ ${({ theme }) => `
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: ${theme.sizeUnit * 4}px;
+ padding: ${theme.sizeUnit * 3}px;
+ background-color: ${theme.colorBgLayout};
+ border-bottom: 1px solid ${theme.colorBorder};
+ `}
+`;
+
+const LegendItem = styled.div<{ color: string }>`
+ ${({ theme, color }) => `
+ display: flex;
+ align-items: center;
+ gap: ${theme.sizeUnit * 2}px;
+ font-size: ${theme.fontSizeSM}px;
+ color: ${theme.colorText};
+
+ &::before {
+ content: '';
+ width: 12px;
+ height: 12px;
+ border-radius: 2px;
+ background-color: ${color};
+ }
+ `}
+`;
+
+const DetailsPanel = styled.div`
+ ${({ theme }) => `
+ padding: ${theme.sizeUnit * 4}px;
+ background-color: ${theme.colorBgLayout};
+ border-top: 1px solid ${theme.colorBorder};
+ min-height: 120px;
+ `}
+`;
+
+const DetailsPanelHeader = styled.div`
+ ${({ theme }) => `
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: ${theme.sizeUnit * 3}px;
+ `}
+`;
+
+const DetailsPanelActions = styled.div`
+ ${({ theme }) => `
+ display: flex;
+ gap: ${theme.sizeUnit * 2}px;
+ `}
+`;
+
+const DetailsPanelTitle = styled.h4`
+ ${({ theme }) => `
+ margin: 0;
+ font-size: ${theme.fontSizeLG}px;
+ font-weight: ${theme.fontWeightStrong};
+ color: ${theme.colorText};
+ `}
+`;
+
+const DetailsPanelContent = styled.div`
+ ${({ theme }) => `
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.sizeUnit * 2}px;
+ `}
+`;
+
+const DetailRow = styled.div`
+ ${({ theme }) => `
+ display: flex;
+ gap: ${theme.sizeUnit * 2}px;
+ font-size: ${theme.fontSizeSM}px;
+ color: ${theme.colorText};
+ `}
+`;
+
+const DetailLabel = styled.span`
+ ${({ theme }) => `
+ font-weight: ${theme.fontWeightStrong};
+ min-width: 100px;
+ `}
+`;
+
+const DetailValue = styled.span`
+ ${({ theme }) => `
+ color: ${theme.colorTextSecondary};
+ `}
+`;
+
+type NodeDetails = {
+ name: string;
+ type: 'database' | 'dataset' | 'chart' | 'dashboard';
+ id?: number;
+ additionalInfo?: Record<string, any>;
+};
+
+type LineageViewProps = {
+ lineageResource:
+ | Resource<DatasetLineage>
+ | Resource<ChartLineage>
+ | Resource<DashboardLineage>;
+ entityType: 'dataset' | 'chart' | 'dashboard';
+};
+
+const LineageView: FC<LineageViewProps> = ({ lineageResource, entityType }) => {
+ const theme = useTheme();
+ const [selectedNode, setSelectedNode] = useState<NodeDetails | null>(null);
+
+ // Create a mapping of node names to their details
+ const nodeDetailsMap = useMemo(() => {
+ if (
+ lineageResource.status !== ResourceStatus.Complete ||
+ !lineageResource.result
+ ) {
+ return new Map<string, NodeDetails>();
+ }
+
+ const data = lineageResource.result;
+ const map = new Map<string, NodeDetails>();
+
+ if (entityType === 'dataset' && 'dataset' in data) {
+ const { dataset, upstream, downstream } = data as DatasetLineage;
+
+ // Add current dataset
+ map.set(dataset.name, {
+ name: dataset.name,
+ type: 'dataset',
+ id: dataset.id,
+ additionalInfo: {
+ schema: dataset.schema,
+ table_name: dataset.table_name,
+ database_name: dataset.database_name,
+ },
+ });
+
+ // Add upstream database
+ if (upstream?.database) {
+ map.set(upstream.database.database_name, {
+ name: upstream.database.database_name,
+ type: 'database',
+ id: upstream.database.id,
+ });
+ }
+
+ // Add downstream charts
+ if (downstream?.charts?.result) {
+ downstream.charts.result.forEach((chart: ChartEntity) => {
+ map.set(chart.slice_name, {
+ name: chart.slice_name,
+ type: 'chart',
+ id: chart.id,
+ additionalInfo: {
+ viz_type: chart.viz_type,
+ },
+ });
+ });
+ }
+
+ // Add downstream dashboards
+ if (downstream?.dashboards?.result) {
+ downstream.dashboards.result.forEach((dashboard: DashboardEntity) => {
+ map.set(dashboard.title, {
+ name: dashboard.title,
+ type: 'dashboard',
+ id: dashboard.id,
+ additionalInfo: {
+ slug: dashboard.slug,
+ },
+ });
+ });
+ }
+ } else if (entityType === 'chart' && 'chart' in data) {
+ const { chart, upstream, downstream } = data as ChartLineage;
+
+ // Add current chart
+ map.set(chart.slice_name, {
+ name: chart.slice_name,
+ type: 'chart',
+ id: chart.id,
+ additionalInfo: {
+ viz_type: chart.viz_type,
+ },
+ });
+
+ // Add upstream dataset
+ if (upstream?.dataset) {
+ map.set(upstream.dataset.name, {
+ name: upstream.dataset.name,
+ type: 'dataset',
+ id: upstream.dataset.id,
+ additionalInfo: {
+ schema: upstream.dataset.schema,
+ table_name: upstream.dataset.table_name,
+ },
+ });
+ }
+
+ // Add upstream database
+ if (upstream?.database) {
+ map.set(upstream.database.database_name, {
+ name: upstream.database.database_name,
+ type: 'database',
+ id: upstream.database.id,
+ });
+ }
+
+ // Add downstream dashboards
+ if (downstream?.dashboards?.result) {
+ downstream.dashboards.result.forEach((dashboard: DashboardEntity) => {
+ map.set(dashboard.title, {
+ name: dashboard.title,
+ type: 'dashboard',
+ id: dashboard.id,
+ additionalInfo: {
+ slug: dashboard.slug,
+ },
+ });
+ });
+ }
+ } else if (entityType === 'dashboard' && 'dashboard' in data) {
+ const { dashboard, upstream } = data as DashboardLineage;
+
+ // Add current dashboard
+ map.set(dashboard.title, {
+ name: dashboard.title,
+ type: 'dashboard',
+ id: dashboard.id,
+ additionalInfo: {
+ slug: dashboard.slug,
+ },
+ });
+
+ // First pass: detect duplicate chart names
+ const chartNameCounts = new Map<string, number>();
+ if (upstream?.charts?.result) {
+ upstream.charts.result.forEach((chart: ChartEntity) => {
+ const count = chartNameCounts.get(chart.slice_name) || 0;
+ chartNameCounts.set(chart.slice_name, count + 1);
+ });
+ }
+
+ // Add upstream charts
+ if (upstream?.charts?.result) {
+ upstream.charts.result.forEach((chart: ChartEntity) => {
+ // Only append ID if there are duplicate names
+ const hasDuplicate = (chartNameCounts.get(chart.slice_name) || 0) > 1;
+ const chartNodeName = hasDuplicate
+ ? `${chart.slice_name} (#${chart.id})`
+ : chart.slice_name;
+ map.set(chartNodeName, {
+ name: chart.slice_name,
+ type: 'chart',
+ id: chart.id,
+ additionalInfo: {
+ viz_type: chart.viz_type,
+ },
+ });
+ });
+ }
+
+ // Add upstream datasets
+ if (upstream?.datasets?.result) {
+ upstream.datasets.result.forEach((dataset: DatasetEntity) => {
+ map.set(dataset.name, {
+ name: dataset.name,
+ type: 'dataset',
+ id: dataset.id,
+ additionalInfo: {
+ schema: dataset.schema,
+ table_name: dataset.table_name,
+ },
+ });
+ });
+ }
+
+ // Add upstream databases
+ if (upstream?.databases?.result) {
+ upstream.databases.result.forEach((database: DatabaseEntity) => {
+ map.set(database.database_name, {
+ name: database.database_name,
+ type: 'database',
+ id: database.id,
+ });
+ });
+ }
+ }
+
+ return map;
+ }, [lineageResource, entityType]);
+
+ // Handle node click
+ const handleNodeClick = useCallback(
+ (params: any) => {
+ if (params.dataType === 'node') {
+ const nodeName = params.name;
+ const nodeDetails = nodeDetailsMap.get(nodeName);
+ if (nodeDetails) {
+ setSelectedNode(nodeDetails);
+ }
+ }
+ // Always stop event propagation to prevent tooltip issues
+ if (params.event) {
+ params.event.stop();
+ }
+ },
+ [nodeDetailsMap],
+ );
+
+ const echartOptions: EChartsCoreOption | null = useMemo(() => {
+ if (
+ lineageResource.status !== ResourceStatus.Complete ||
+ !lineageResource.result
+ ) {
+ return null;
+ }
+
+ const data = lineageResource.result;
+ const nodes: {
+ name: string;
+ itemStyle?: { color: string };
+ label?: { position?: string };
+ }[] = [];
+ const links: { source: string; target: string; value: number }[] = [];
+ const nodeSet = new Set<string>();
+
+ // Helper to add a node with label position
+ const addNode = (
+ name: string,
+ color: string,
+ labelPosition: 'left' | 'right' | 'inside',
+ ) => {
+ if (!nodeSet.has(name)) {
+ nodeSet.add(name);
+ nodes.push({
+ name,
+ itemStyle: { color },
+ label: {
+ position: labelPosition,
+ },
+ });
+ }
+ };
+
+ // Helper to add a link
+ const addLink = (source: string, target: string) => {
+ links.push({ source, target, value: 1 });
+ };
+
+ // Build nodes and links based on entity type
+ if (entityType === 'dataset' && 'dataset' in data) {
+ const { dataset, upstream, downstream } = data as DatasetLineage;
+
+ // Add current dataset node (center) - label inside
+ addNode(dataset.name, theme.colorPrimary, 'inside');
+
+ // Add upstream database - label on left
+ if (upstream?.database) {
+ addNode(upstream.database.database_name, theme.colorInfo, 'left');
+ addLink(upstream.database.database_name, dataset.name);
+ }
+
+ // Add downstream charts - label on right
+ const chartMap = new Map<number, ChartEntity>();
+ if (downstream?.charts?.result) {
+ downstream.charts.result.forEach((chart: ChartEntity) => {
+ chartMap.set(chart.id, chart);
+ addNode(chart.slice_name, theme.colorSuccess, 'right');
+ addLink(dataset.name, chart.slice_name);
+ });
+ }
+
+ // Add downstream dashboards - label on right
+ if (downstream?.dashboards?.result) {
+ downstream.dashboards.result.forEach((dashboard: DashboardEntity) => {
+ addNode(dashboard.title, theme.colorWarning, 'right');
+
+ // Link from charts to dashboards using chart_ids
+ if (dashboard.chart_ids && dashboard.chart_ids.length > 0) {
+ dashboard.chart_ids.forEach(chartId => {
+ const chart = chartMap.get(chartId);
+ if (chart) {
+ addLink(chart.slice_name, dashboard.title);
+ }
+ });
+ }
+ });
+ }
+ } else if (entityType === 'chart' && 'chart' in data) {
+ const { chart, upstream, downstream } = data as ChartLineage;
+
+ // Add current chart node (center) - label inside
+ addNode(chart.slice_name, theme.colorPrimary, 'inside');
+
+ // Add upstream dataset - label on left
+ if (upstream?.dataset) {
+ addNode(upstream.dataset.name, theme.colorInfo, 'left');
+ addLink(upstream.dataset.name, chart.slice_name);
+
+ // Add upstream database - label on left
+ if (upstream.database) {
+ addNode(upstream.database.database_name, theme.colorWarning, 'left');
+ addLink(upstream.database.database_name, upstream.dataset.name);
+ }
+ }
+
+ // Add downstream dashboards - label on right
+ if (downstream?.dashboards?.result) {
+ downstream.dashboards.result.forEach((dashboard: DashboardEntity) => {
+ addNode(dashboard.title, theme.colorSuccess, 'right');
+ addLink(chart.slice_name, dashboard.title);
+ });
+ }
+ } else if (entityType === 'dashboard' && 'dashboard' in data) {
+ const { dashboard, upstream } = data as DashboardLineage;
+
+ // Add current dashboard node (right) - label inside
+ addNode(dashboard.title, theme.colorPrimary, 'inside');
+
+ // First pass: detect duplicate chart names
+ const chartNameCounts = new Map<string, number>();
+ if (upstream?.charts?.result) {
+ upstream.charts.result.forEach((chart: ChartEntity) => {
+ const count = chartNameCounts.get(chart.slice_name) || 0;
+ chartNameCounts.set(chart.slice_name, count + 1);
+ });
+ }
+
+ // Create a map of chart id to chart for easy lookup
+ const chartMap = new Map<number, ChartEntity>();
+ const chartNodeNames = new Map<number, string>(); // Map chart ID to its node name
+ if (upstream?.charts?.result) {
+ upstream.charts.result.forEach((chart: ChartEntity) => {
+ chartMap.set(chart.id, chart);
+ // Only append ID if there are duplicate names
+ const hasDuplicate = (chartNameCounts.get(chart.slice_name) || 0) > 1;
+ const chartNodeName = hasDuplicate
+ ? `${chart.slice_name} (#${chart.id})`
+ : chart.slice_name;
+ chartNodeNames.set(chart.id, chartNodeName);
+ // Charts are upstream - label on left
+ addNode(chartNodeName, theme.colorInfo, 'left');
+ addLink(chartNodeName, dashboard.title);
+ });
+ }
+
+ // Create a map of dataset id to dataset for easy lookup
+ const datasetMap = new Map<number, any>();
+ if (upstream?.datasets?.result) {
+ upstream.datasets.result.forEach(dataset => {
+ datasetMap.set(dataset.id, dataset);
+ // Datasets are upstream - label on left
+ addNode(dataset.name, theme.colorSuccess, 'left');
+ });
+ }
+
+ // Link charts to their specific datasets using dataset_id from each chart
+ if (upstream?.charts?.result) {
+ upstream.charts.result.forEach((chart: ChartEntity) => {
+ if (chart.dataset_id) {
+ const dataset = datasetMap.get(chart.dataset_id);
+ const chartNodeName = chartNodeNames.get(chart.id);
+ if (dataset && chartNodeName) {
+ addLink(dataset.name, chartNodeName);
+ }
+ }
+ });
+ }
+
+ // Add upstream databases and link to their specific datasets
+ if (upstream?.databases?.result) {
+ upstream.databases.result.forEach(database => {
+ // Databases are upstream - label on left
+ addNode(database.database_name, theme.colorWarning, 'left');
+
+ // Link databases to datasets that belong to them using database_id
+ if (upstream.datasets?.result) {
+ upstream.datasets.result.forEach(dataset => {
+ if (dataset.database_id === database.id) {
+ addLink(database.database_name, dataset.name);
+ }
+ });
+ }
+ });
+ }
+ }
+
+ return {
+ series: {
+ animation: false,
+ data: nodes,
+ lineStyle: {
+ color: 'source',
+ },
+ links,
+ type: 'sankey',
+ },
+ tooltip: {
+ show: false,
+ },
+ };
+ }, [lineageResource, entityType, theme]);
+
+ // Build legend data based on entity type
+ const legendItems: { label: string; color: string }[] = useMemo(() => {
+ if (entityType === 'dataset') {
+ return [
+ { label: 'Database (Upstream)', color: theme.colorInfo },
+ { label: 'Dataset (Current)', color: theme.colorPrimary },
+ { label: 'Chart (Downstream)', color: theme.colorSuccess },
+ { label: 'Dashboard (Downstream)', color: theme.colorWarning },
+ ];
+ } else if (entityType === 'chart') {
+ return [
+ { label: 'Database (Upstream)', color: theme.colorWarning },
+ { label: 'Dataset (Upstream)', color: theme.colorInfo },
+ { label: 'Chart (Current)', color: theme.colorPrimary },
+ { label: 'Dashboard (Downstream)', color: theme.colorSuccess },
+ ];
+ } else if (entityType === 'dashboard') {
+ return [
+ { label: 'Database (Upstream)', color: theme.colorWarning },
+ { label: 'Dataset (Upstream)', color: theme.colorSuccess },
+ { label: 'Chart (Upstream)', color: theme.colorInfo },
+ { label: 'Dashboard (Current)', color: theme.colorPrimary },
+ ];
+ }
+ return [];
+ }, [entityType, theme]);
+
+ if (lineageResource.status === ResourceStatus.Loading) {
+ return <Loading />;
+ }
+
+ if (
+ lineageResource.status === ResourceStatus.Error ||
+ !lineageResource.result
+ ) {
+ return <Empty description={t('Failed to load lineage data')} />;
+ }
+
+ if (!echartOptions) {
+ return <Empty description={t('No lineage data available')} />;
+ }
+
+ // Helper function to get the URL for an entity
+ const getEntityUrl = (nodeDetails: NodeDetails): string => {
+ switch (nodeDetails.type) {
+ case 'dashboard':
+ return `/superset/dashboard/${nodeDetails.id}/`;
+ case 'chart':
+ return `/explore/?slice_id=${nodeDetails.id}`;
+ case 'dataset':
+ return `/dataset/${nodeDetails.id}`;
+ default:
+ return '#';
+ }
+ };
+
+ return (
+ <LineageContainer>
+ <Legend>
+ {legendItems.map(item => (
+ <LegendItem key={item.label} color={item.color}>
+ {item.label}
+ </LegendItem>
+ ))}
+ </Legend>
+ <Echart
+ refs={{}}
+ height={selectedNode ? 450 : 600}
+ width={800}
+ echartOptions={echartOptions}
+ vizType="sankey"
+ eventHandlers={{
+ click: handleNodeClick,
+ }}
+ />
+ {selectedNode && (
+ <DetailsPanel>
+ <DetailsPanelHeader>
+ <DetailsPanelTitle>
+ {selectedNode.type.charAt(0).toUpperCase() +
+ selectedNode.type.slice(1)}{' '}
+ Details
+ </DetailsPanelTitle>
+ <DetailsPanelActions>
+ {(selectedNode.type === 'dashboard' ||
+ selectedNode.type === 'chart') && (
+ <Button
+ buttonStyle="primary"
+ buttonSize="small"
+ onClick={() => {
+ window.location.href = getEntityUrl(selectedNode);
+ }}
+ >
+ {t('Open')}{' '}
+ {selectedNode.type.charAt(0).toUpperCase() +
+ selectedNode.type.slice(1)}
+ </Button>
+ )}
+ <Button
+ buttonStyle="tertiary"
+ buttonSize="small"
+ onClick={() => setSelectedNode(null)}
+ >
+ {t('Close')}
+ </Button>
+ </DetailsPanelActions>
+ </DetailsPanelHeader>
+ <DetailsPanelContent>
+ <DetailRow>
+ <DetailLabel>{t('Name')}:</DetailLabel>
+ <DetailValue>{selectedNode.name}</DetailValue>
+ </DetailRow>
+ {selectedNode.id && (
+ <DetailRow>
+ <DetailLabel>{t('ID')}:</DetailLabel>
+ <DetailValue>{selectedNode.id}</DetailValue>
+ </DetailRow>
+ )}
+ {selectedNode.additionalInfo &&
+ Object.entries(selectedNode.additionalInfo).map(
+ ([key, value]) => (
+ <DetailRow key={key}>
+ <DetailLabel>
+ {key.charAt(0).toUpperCase() +
+ key.slice(1).replace(/_/g, ' ')}
+ :
+ </DetailLabel>
+ <DetailValue>{String(value)}</DetailValue>
+ </DetailRow>
+ ),
+ )}
+ </DetailsPanelContent>
+ </DetailsPanel>
+ )}
+ </LineageContainer>
+ );
+};
+
+export default LineageView;
diff --git a/superset-frontend/src/hooks/apiResources/index.ts b/superset-frontend/src/features/lineage/index.ts
similarity index 63%
copy from superset-frontend/src/hooks/apiResources/index.ts
copy to superset-frontend/src/features/lineage/index.ts
index 53aa7aa113c..b197eca9258 100644
--- a/superset-frontend/src/hooks/apiResources/index.ts
+++ b/superset-frontend/src/features/lineage/index.ts
@@ -16,19 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
-export { skipToken } from '@reduxjs/toolkit/query/react';
-export {
- useApiResourceFullBody,
- useApiV1Resource,
- useTransformedResource,
-} from './apiResources';
-// A central catalog of API Resource hooks.
-// Add new API hooks here, organized under
-// different files for different resource types.
-export * from './catalogs';
-export * from './charts';
-export * from './dashboards';
-export * from './tables';
-export * from './schemas';
-export * from './queryValidations';
+export { default as LineageView } from './LineageView';
+export { default as LineageModal } from './LineageModal';
diff --git a/superset-frontend/src/hooks/apiResources/index.ts b/superset-frontend/src/hooks/apiResources/index.ts
index 53aa7aa113c..61d3628a61b 100644
--- a/superset-frontend/src/hooks/apiResources/index.ts
+++ b/superset-frontend/src/hooks/apiResources/index.ts
@@ -29,6 +29,7 @@ export {
export * from './catalogs';
export * from './charts';
export * from './dashboards';
+export * from './lineage';
export * from './tables';
export * from './schemas';
export * from './queryValidations';
diff --git a/superset-frontend/src/hooks/apiResources/lineage.ts b/superset-frontend/src/hooks/apiResources/lineage.ts
new file mode 100644
index 00000000000..87011a2f7f6
--- /dev/null
+++ b/superset-frontend/src/hooks/apiResources/lineage.ts
@@ -0,0 +1,134 @@
+/**
+ * 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 { useApiV1Resource } from './apiResources';
+
+// Database entity type
+export type DatabaseEntity = {
+ id: number;
+ database_name: string;
+ backend: string;
+};
+
+// Dataset entity type
+export type DatasetEntity = {
+ id: number;
+ name: string;
+ schema: string | null;
+ table_name: string;
+ database_id: number;
+ database_name: string;
+ chart_ids?: number[];
+};
+
+// Chart entity type
+export type ChartEntity = {
+ id: number;
+ slice_name: string;
+ viz_type: string;
+ dashboard_ids?: number[];
+ dataset_id?: number;
+};
+
+// Dashboard entity type
+export type DashboardEntity = {
+ id: number;
+ title: string;
+ slug: string;
+ chart_ids?: number[];
+};
+
+// Dataset lineage response type
+export type DatasetLineage = {
+ dataset: DatasetEntity;
+ upstream: {
+ database: DatabaseEntity;
+ };
+ downstream: {
+ charts: {
+ count: number;
+ result: ChartEntity[];
+ };
+ dashboards: {
+ count: number;
+ result: DashboardEntity[];
+ };
+ };
+};
+
+// Chart lineage response type
+export type ChartLineage = {
+ chart: ChartEntity & {
+ datasource_id: number;
+ datasource_type: string;
+ };
+ upstream: {
+ dataset: DatasetEntity;
+ database: DatabaseEntity;
+ };
+ downstream: {
+ dashboards: {
+ count: number;
+ result: DashboardEntity[];
+ };
+ };
+};
+
+// Dashboard lineage response type
+export type DashboardLineage = {
+ dashboard: DashboardEntity & {
+ published: boolean;
+ };
+ upstream: {
+ charts: {
+ count: number;
+ result: ChartEntity[];
+ };
+ datasets: {
+ count: number;
+ result: DatasetEntity[];
+ };
+ databases: {
+ count: number;
+ result: DatabaseEntity[];
+ };
+ };
+ downstream: null;
+};
+
+/**
+ * Hook to fetch lineage data for a dataset
+ * @param idOrUuid Dataset ID or UUID
+ */
+export const useDatasetLineage = (idOrUuid: string | number) =>
+ useApiV1Resource<DatasetLineage>(`/api/v1/dataset/${idOrUuid}/lineage`);
+
+/**
+ * Hook to fetch lineage data for a chart
+ * @param idOrUuid Chart ID or UUID
+ */
+export const useChartLineage = (idOrUuid: string | number) =>
+ useApiV1Resource<ChartLineage>(`/api/v1/chart/${idOrUuid}/lineage`);
+
+/**
+ * Hook to fetch lineage data for a dashboard
+ * @param idOrSlug Dashboard ID or slug
+ */
+export const useDashboardLineage = (idOrSlug: string | number) =>
+ useApiV1Resource<DashboardLineage>(`/api/v1/dashboard/${idOrSlug}/lineage`);
diff --git a/superset/charts/api.py b/superset/charts/api.py
index 3534d6dfa52..3f31006b085 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -131,6 +131,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
"screenshot",
"cache_screenshot",
"warm_up_cache",
+ "lineage",
}
class_permission_name = "Chart"
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
@@ -313,6 +314,103 @@ class ChartRestApi(BaseSupersetModelRestApi):
except ChartNotFoundError:
return self.response_404()
+ @expose("/<id_or_uuid>/lineage", methods=("GET",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.lineage",
+ log_to_statsd=False,
+ )
+ def lineage(self, id_or_uuid: str) -> Response:
+ """Get lineage information for a chart.
+ ---
+ get:
+ summary: Get lineage information for a chart
+ description: >-
+ Returns upstream (dataset, database) and downstream (dashboards) lineage
+ information for a chart
+ parameters:
+ - in: path
+ name: id_or_uuid
+ schema:
+ type: string
+ description: Either the id of the chart, or its uuid
+ responses:
+ 200:
+ description: Lineage information
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ChartLineageResponseSchema"
+ 401:
+ $ref: '#/components/responses/401'
+ 404:
+ $ref: '#/components/responses/404'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ try:
+ chart = ChartDAO.get_by_id_or_uuid(id_or_uuid)
+ except ChartNotFoundError:
+ return self.response_404()
+
+ chart_info = {
+ "id": chart.id,
+ "slice_name": chart.slice_name,
+ "viz_type": chart.viz_type,
+ }
+
+ # Get upstream (dataset and database) information
+ upstream: dict[str, Any] = {}
+ if dataset := chart.datasource:
+ upstream["dataset"] = {
+ "id": dataset.id,
+ "name": dataset.name,
+ "database_id": dataset.database_id,
+ "database_name": dataset.database.database_name
+ if dataset.database
+ else None,
+ "schema": dataset.schema,
+ "table_name": dataset.table_name,
+ }
+ if dataset.database:
+ upstream["database"] = {
+ "id": dataset.database.id,
+ "database_name": dataset.database.database_name,
+ "backend": dataset.database.backend,
+ }
+ else:
+ upstream["database"] = None
+ else:
+ upstream["dataset"] = None
+ upstream["database"] = None
+
+ # Get downstream (dashboards) information
+ dashboards = []
+ for dashboard in chart.dashboards:
+ dashboards.append(
+ {
+ "id": dashboard.id,
+ "title": dashboard.dashboard_title,
+ "slug": dashboard.slug,
+ }
+ )
+
+ downstream = {
+ "dashboards": {
+ "count": len(dashboards),
+ "result": dashboards,
+ },
+ }
+
+ result = {
+ "chart": chart_info,
+ "upstream": upstream,
+ "downstream": downstream,
+ }
+ return self.response(200, result=result)
+
@expose("/", methods=("POST",))
@protect()
@safe
diff --git a/superset/charts/schemas.py b/superset/charts/schemas.py
index 0a603cbb6ec..2f976ee2de1 100644
--- a/superset/charts/schemas.py
+++ b/superset/charts/schemas.py
@@ -1755,6 +1755,53 @@ class ChartGetResponseSchema(Schema):
datasource_uuid = fields.UUID(attribute="table.uuid")
+class ChartLineageChartSchema(Schema):
+ id = fields.Integer()
+ slice_name = fields.String()
+ viz_type = fields.String()
+
+
+class ChartLineageDatasetSchema(Schema):
+ id = fields.Integer()
+ name = fields.String()
+ database_id = fields.Integer()
+ database_name = fields.String()
+ schema = fields.String(allow_none=True)
+ table_name = fields.String()
+
+
+class ChartLineageDatabaseSchema(Schema):
+ id = fields.Integer()
+ database_name = fields.String()
+ backend = fields.String()
+
+
+class ChartLineageDashboardSchema(Schema):
+ id = fields.Integer()
+ title = fields.String()
+ slug = fields.String()
+
+
+class ChartLineageUpstreamSchema(Schema):
+ dataset = fields.Nested(ChartLineageDatasetSchema, allow_none=True)
+ database = fields.Nested(ChartLineageDatabaseSchema, allow_none=True)
+
+
+class ChartLineageDownstreamDashboardsSchema(Schema):
+ count = fields.Integer()
+ result = fields.List(fields.Nested(ChartLineageDashboardSchema))
+
+
+class ChartLineageDownstreamSchema(Schema):
+ dashboards = fields.Nested(ChartLineageDownstreamDashboardsSchema)
+
+
+class ChartLineageResponseSchema(Schema):
+ chart = fields.Nested(ChartLineageChartSchema)
+ upstream = fields.Nested(ChartLineageUpstreamSchema)
+ downstream = fields.Nested(ChartLineageDownstreamSchema)
+
+
CHART_SCHEMAS = (
ChartCacheWarmUpRequestSchema,
ChartCacheWarmUpResponseSchema,
@@ -1782,4 +1829,5 @@ CHART_SCHEMAS = (
ChartGetResponseSchema,
ChartCacheScreenshotResponseSchema,
GetFavStarIdsSchema,
+ ChartLineageResponseSchema,
)
diff --git a/superset/constants.py b/superset/constants.py
index 863d0cf82ba..c5d7fd6a92d 100644
--- a/superset/constants.py
+++ b/superset/constants.py
@@ -179,6 +179,7 @@ MODEL_API_RW_METHOD_PERMISSION_MAP = {
"put_colors": "write",
"sync_permissions": "write",
"restore": "write",
+ "lineage": "read",
}
EXTRA_FORM_DATA_APPEND_KEYS = {
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index ff0c2a89c66..497a4e6d4b7 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -252,6 +252,7 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
"put_chart_customizations",
"put_colors",
"export_as_example",
+ "lineage",
}
resource_name = "dashboard"
allow_browser_login = True
@@ -524,6 +525,114 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
)
return self.response(200, result=result)
+ @expose("/<id_or_slug>/lineage", methods=("GET",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @with_dashboard
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.lineage",
+ log_to_statsd=False,
+ )
+ # pylint: disable=arguments-differ,arguments-renamed
+ def lineage(self, dash: Dashboard) -> Response:
+ """Get lineage information for a dashboard.
+ ---
+ get:
+ summary: Get lineage information for a dashboard
+ description: >-
+ Returns upstream (charts, datasets, databases) lineage information
+ for a dashboard
+ parameters:
+ - in: path
+ name: id_or_slug
+ schema:
+ type: string
+ description: Either the id of the dashboard, or its slug
+ responses:
+ 200:
+ description: Lineage information
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DashboardLineageResponseSchema"
+ 401:
+ $ref: '#/components/responses/401'
+ 404:
+ $ref: '#/components/responses/404'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ dashboard_info = {
+ "id": dash.id,
+ "title": dash.dashboard_title,
+ "slug": dash.slug,
+ "published": dash.published,
+ }
+
+ # Get upstream (charts, datasets, databases) information
+ charts = []
+ dataset_map = {}
+ database_map = {}
+
+ for chart in dash.slices:
+ charts.append(
+ {
+ "id": chart.id,
+ "slice_name": chart.slice_name,
+ "viz_type": chart.viz_type,
+ "dataset_id": chart.datasource_id,
+ }
+ )
+
+ # Collect dataset information
+ dataset = chart.datasource
+ if dataset and dataset.id not in dataset_map:
+ dataset_map[dataset.id] = {
+ "id": dataset.id,
+ "name": dataset.name,
+ "database_id": dataset.database_id,
+ "database_name": dataset.database.database_name
+ if dataset.database
+ else None,
+ "schema": dataset.schema,
+ "table_name": dataset.table_name,
+ "chart_ids": [],
+ }
+
+ if dataset and dataset.id in dataset_map:
+ dataset_map[dataset.id]["chart_ids"].append(chart.id)
+
+ # Collect database information
+ if dataset and dataset.database and dataset.database.id not in database_map:
+ database_map[dataset.database.id] = {
+ "id": dataset.database.id,
+ "database_name": dataset.database.database_name,
+ "backend": dataset.database.backend,
+ }
+
+ upstream = {
+ "charts": {
+ "count": len(charts),
+ "result": charts,
+ },
+ "datasets": {
+ "count": len(dataset_map),
+ "result": list(dataset_map.values()),
+ },
+ "databases": {
+ "count": len(database_map),
+ "result": list(database_map.values()),
+ },
+ }
+
+ result = {
+ "dashboard": dashboard_info,
+ "upstream": upstream,
+ "downstream": None,
+ }
+ return self.response(200, result=result)
+
@expose("/<id_or_slug>/datasets", methods=("GET",))
@protect()
@handle_api_exception
diff --git a/superset/dashboards/schemas.py b/superset/dashboards/schemas.py
index a71b90a1afd..b06c018e503 100644
--- a/superset/dashboards/schemas.py
+++ b/superset/dashboards/schemas.py
@@ -558,3 +558,60 @@ class CacheScreenshotSchema(Schema):
fields.List(fields.Str(), validate=lambda x: len(x) == 2), required=False
)
permalinkKey = fields.Str(required=False) # noqa: N815
+
+
+class DashboardLineageDashboardSchema(Schema):
+ id = fields.Integer()
+ title = fields.String()
+ slug = fields.String()
+ published = fields.Boolean()
+
+
+class DashboardLineageChartSchema(Schema):
+ id = fields.Integer()
+ slice_name = fields.String()
+ viz_type = fields.String()
+ dataset_id = fields.Integer()
+
+
+class DashboardLineageDatasetSchema(Schema):
+ id = fields.Integer()
+ name = fields.String()
+ database_id = fields.Integer()
+ database_name = fields.String()
+ schema = fields.String(allow_none=True)
+ table_name = fields.String()
+ chart_ids = fields.List(fields.Integer())
+
+
+class DashboardLineageDatabaseSchema(Schema):
+ id = fields.Integer()
+ database_name = fields.String()
+ backend = fields.String()
+
+
+class DashboardLineageUpstreamChartsSchema(Schema):
+ count = fields.Integer()
+ result = fields.List(fields.Nested(DashboardLineageChartSchema))
+
+
+class DashboardLineageUpstreamDatasetsSchema(Schema):
+ count = fields.Integer()
+ result = fields.List(fields.Nested(DashboardLineageDatasetSchema))
+
+
+class DashboardLineageUpstreamDatabasesSchema(Schema):
+ count = fields.Integer()
+ result = fields.List(fields.Nested(DashboardLineageDatabaseSchema))
+
+
+class DashboardLineageUpstreamSchema(Schema):
+ charts = fields.Nested(DashboardLineageUpstreamChartsSchema)
+ datasets = fields.Nested(DashboardLineageUpstreamDatasetsSchema)
+ databases = fields.Nested(DashboardLineageUpstreamDatabasesSchema)
+
+
+class DashboardLineageResponseSchema(Schema):
+ dashboard = fields.Nested(DashboardLineageDashboardSchema)
+ upstream = fields.Nested(DashboardLineageUpstreamSchema)
+ downstream = fields.Field(allow_none=True)
diff --git a/superset/datasets/api.py b/superset/datasets/api.py
index cee61509067..5b95c91c810 100644
--- a/superset/datasets/api.py
+++ b/superset/datasets/api.py
@@ -64,6 +64,7 @@ from superset.datasets.schemas import (
DatasetCacheWarmUpResponseSchema,
DatasetDrillInfoSchema,
DatasetDuplicateSchema,
+ DatasetLineageResponseSchema,
DatasetPostSchema,
DatasetPutSchema,
DatasetRelatedObjectsResponse,
@@ -111,6 +112,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
"get_or_create_dataset",
"warm_up_cache",
"get_drill_info",
+ "lineage",
}
list_columns = [
"id",
@@ -299,6 +301,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
DatasetRelatedObjectsResponse,
DatasetDuplicateSchema,
GetOrCreateDatasetSchema,
+ DatasetLineageResponseSchema,
)
openapi_spec_methods = openapi_spec_methods_override
@@ -846,6 +849,119 @@ class DatasetRestApi(BaseSupersetModelRestApi):
dashboards={"count": len(dashboards), "result": dashboards},
)
+ @expose("/<id_or_uuid>/lineage", methods=("GET",))
+ @protect()
+ @safe
+ @statsd_metrics
+ @event_logger.log_this_with_context(
+ action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.lineage",
+ log_to_statsd=False,
+ )
+ def lineage(self, id_or_uuid: str) -> Response:
+ """Get lineage information for a dataset.
+ ---
+ get:
+ summary: Get lineage information for a dataset
+ description: >-
+ Returns upstream (database) and downstream (charts, dashboards) lineage
+ information for a dataset
+ parameters:
+ - in: path
+ name: id_or_uuid
+ schema:
+ type: string
+ description: Either the id of the dataset, or its uuid
+ responses:
+ 200:
+ description: Lineage information
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DatasetLineageResponseSchema"
+ 401:
+ $ref: '#/components/responses/401'
+ 404:
+ $ref: '#/components/responses/404'
+ 500:
+ $ref: '#/components/responses/500'
+ """
+ dataset = DatasetDAO.find_by_id_or_uuid(id_or_uuid)
+ if not dataset:
+ return self.response_404()
+
+ dataset_info = {
+ "id": dataset.id,
+ "name": dataset.name,
+ "database_id": dataset.database_id,
+ "database_name": (
+ dataset.database.database_name if dataset.database else None
+ ),
+ "schema": dataset.schema,
+ "table_name": dataset.table_name,
+ }
+
+ # Get upstream (database) information
+ upstream: dict[str, Any] = {}
+ if dataset.database:
+ upstream["database"] = {
+ "id": dataset.database.id,
+ "database_name": dataset.database.database_name,
+ "backend": dataset.database.backend,
+ }
+ else:
+ upstream["database"] = None
+
+ # Get downstream (charts and dashboards) information
+ related_data = DatasetDAO.get_related_objects(dataset.id)
+
+ # Build chart information with dashboard IDs
+ charts = []
+ for chart in related_data["charts"]:
+ dashboard_ids = [d.id for d in chart.dashboards]
+ charts.append(
+ {
+ "id": chart.id,
+ "slice_name": chart.slice_name,
+ "viz_type": chart.viz_type,
+ "dashboard_ids": dashboard_ids,
+ }
+ )
+
+ # Build dashboard information with chart IDs
+ dashboards = []
+ for dashboard in related_data["dashboards"]:
+ chart_ids = [
+ chart.id
+ for chart in dashboard.slices
+ if chart.datasource_id == dataset.id
+ ]
+ dashboards.append(
+ {
+ "id": dashboard.id,
+ "title": dashboard.dashboard_title,
+ "slug": dashboard.slug,
+ "chart_ids": chart_ids,
+ }
+ )
+
+ downstream = {
+ "charts": {
+ "count": len(charts),
+ "result": charts,
+ },
+ "dashboards": {
+ "count": len(dashboards),
+ "result": dashboards,
+ },
+ }
+
+ result = {
+ "dataset": dataset_info,
+ "upstream": upstream,
+ "downstream": downstream,
+ }
+ return self.response(200, result=result)
+
@expose("/", methods=("DELETE",))
@protect()
@safe
diff --git a/superset/datasets/schemas.py b/superset/datasets/schemas.py
index 386686d5700..8209f67cbfb 100644
--- a/superset/datasets/schemas.py
+++ b/superset/datasets/schemas.py
@@ -250,6 +250,60 @@ class DatasetRelatedObjectsResponse(Schema):
dashboards = fields.Nested(DatasetRelatedDashboards)
+class DatasetLineageDatasetSchema(Schema):
+ id = fields.Integer()
+ name = fields.String()
+ database_id = fields.Integer()
+ database_name = fields.String()
+ schema = fields.String(allow_none=True)
+ table_name = fields.String()
+
+
+class DatasetLineageDatabaseSchema(Schema):
+ id = fields.Integer()
+ database_name = fields.String()
+ backend = fields.String()
+
+
+class DatasetLineageChartSchema(Schema):
+ id = fields.Integer()
+ slice_name = fields.String()
+ viz_type = fields.String()
+ dashboard_ids = fields.List(fields.Integer())
+
+
+class DatasetLineageDashboardSchema(Schema):
+ id = fields.Integer()
+ title = fields.String()
+ slug = fields.String()
+ chart_ids = fields.List(fields.Integer())
+
+
+class DatasetLineageUpstreamSchema(Schema):
+ database = fields.Nested(DatasetLineageDatabaseSchema, allow_none=True)
+
+
+class DatasetLineageDownstreamChartsSchema(Schema):
+ count = fields.Integer()
+ result = fields.List(fields.Nested(DatasetLineageChartSchema))
+
+
+class DatasetLineageDownstreamDashboardsSchema(Schema):
+ count = fields.Integer()
+ result = fields.List(fields.Nested(DatasetLineageDashboardSchema))
+
+
+class DatasetLineageDownstreamSchema(Schema):
+ charts = fields.Nested(DatasetLineageDownstreamChartsSchema)
+ dashboards = fields.Nested(DatasetLineageDownstreamDashboardsSchema)
+
+
+class DatasetLineageResponseSchema(Schema):
+ dataset = fields.Nested(DatasetLineageDatasetSchema)
+ upstream = fields.Nested(DatasetLineageUpstreamSchema)
+ downstream = fields.Nested(DatasetLineageDownstreamSchema)
+
+
class ImportV1ColumnSchema(Schema):
# pylint: disable=unused-argument
@pre_load
diff --git a/tests/integration_tests/charts/api_tests.py b/tests/integration_tests/charts/api_tests.py
index 512b6f682c0..dbe3066b240 100644
--- a/tests/integration_tests/charts/api_tests.py
+++ b/tests/integration_tests/charts/api_tests.py
@@ -50,6 +50,7 @@ from tests.integration_tests.fixtures.birth_names_dashboard import (
load_birth_names_dashboard_with_slices, # noqa: F401
load_birth_names_data, # noqa: F401
)
+from tests.integration_tests.fixtures.client import client # noqa: F401
from tests.integration_tests.fixtures.energy_dashboard import (
load_energy_table_data, # noqa: F401
load_energy_table_with_slice, # noqa: F401
@@ -59,6 +60,10 @@ from tests.integration_tests.fixtures.importexport import (
database_config,
dataset_config,
)
+from tests.integration_tests.fixtures.lineage import (
+ inject_expected_chart_lineage, # noqa: F401
+ lineage_test_data, # noqa: F401
+)
from tests.integration_tests.fixtures.tags import (
create_custom_tags, # noqa: F401
get_filter_params,
@@ -2444,3 +2449,30 @@ class TestChartApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCase):
self.login(ADMIN_USERNAME)
rv = self.client.get("api/v1/chart/related/owners")
assert rv.status_code == 200
+
+ @pytest.mark.usefixtures("inject_expected_chart_lineage")
+ def test_get_chart_lineage(self):
+ """
+ Chart API: Test get chart lineage
+ """
+ self.login(ADMIN_USERNAME)
+ chart_id = self.chart_lineage["chart_id"]
+ expected = self.chart_lineage["expected"]
+
+ uri = f"api/v1/chart/{chart_id}/lineage"
+ rv = self.get_assert_metric(uri, "lineage")
+ assert rv.status_code == 200
+
+ data = json.loads(rv.data.decode("utf-8"))
+
+ # Assert the entire response matches expected structure
+ assert data == expected
+
+ def test_get_chart_lineage_not_found(self):
+ """
+ Chart API: Test get chart lineage with non-existent chart
+ """
+ self.login(ADMIN_USERNAME)
+ uri = "api/v1/chart/99999/lineage"
+ rv = self.client.get(uri)
+ assert rv.status_code == 404
diff --git a/tests/integration_tests/dashboards/api_tests.py b/tests/integration_tests/dashboards/api_tests.py
index bdc42ed9287..8e03d2bf1ac 100644
--- a/tests/integration_tests/dashboards/api_tests.py
+++ b/tests/integration_tests/dashboards/api_tests.py
@@ -66,6 +66,11 @@ from tests.integration_tests.fixtures.birth_names_dashboard import (
load_birth_names_dashboard_with_slices, # noqa: F401
load_birth_names_data, # noqa: F401
)
+from tests.integration_tests.fixtures.client import client # noqa: F401
+from tests.integration_tests.fixtures.lineage import (
+ inject_expected_dashboard_lineage, # noqa: F401
+ lineage_test_data, # noqa: F401
+)
from tests.integration_tests.fixtures.world_bank_dashboard import (
load_world_bank_dashboard_with_slices, # noqa: F401
load_world_bank_data, # noqa: F401
@@ -4049,6 +4054,33 @@ class TestDashboardApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCas
db.session.delete(dashboard)
db.session.commit()
+ @pytest.mark.usefixtures("inject_expected_dashboard_lineage")
+ def test_get_dashboard_lineage(self):
+ """
+ Dashboard API: Test get dashboard lineage
+ """
+ self.login(ADMIN_USERNAME)
+ dashboard_id = self.dashboard_lineage["dashboard_id"]
+ expected = self.dashboard_lineage["expected"]
+
+ uri = f"api/v1/dashboard/{dashboard_id}/lineage"
+ rv = self.get_assert_metric(uri, "lineage")
+ assert rv.status_code == 200
+
+ data = json.loads(rv.data.decode("utf-8"))
+
+ # Assert the entire response matches expected structure
+ assert data == expected
+
+ def test_get_dashboard_lineage_not_found(self):
+ """
+ Dashboard API: Test get dashboard lineage with non-existent dashboard
+ """
+ self.login(ADMIN_USERNAME)
+ uri = "api/v1/dashboard/99999/lineage"
+ rv = self.client.get(uri)
+ assert rv.status_code == 404
+
class TestDashboardCustomTagsFiltering(SupersetTestCase):
"""Test dashboard list API tags field behavior.
diff --git a/tests/integration_tests/datasets/api_tests.py b/tests/integration_tests/datasets/api_tests.py
index ba253573e2e..11e2b9a01f8 100644
--- a/tests/integration_tests/datasets/api_tests.py
+++ b/tests/integration_tests/datasets/api_tests.py
@@ -55,6 +55,7 @@ from tests.integration_tests.fixtures.birth_names_dashboard import (
load_birth_names_dashboard_with_slices, # noqa: F401
load_birth_names_data, # noqa: F401
)
+from tests.integration_tests.fixtures.client import client # noqa: F401
from tests.integration_tests.fixtures.energy_dashboard import (
load_energy_table_data, # noqa: F401
load_energy_table_with_slice, # noqa: F401
@@ -64,6 +65,10 @@ from tests.integration_tests.fixtures.importexport import (
dataset_config,
dataset_ui_export,
)
+from tests.integration_tests.fixtures.lineage import (
+ inject_expected_dataset_lineage, # noqa: F401
+ lineage_test_data, # noqa: F401
+)
class TestDatasetApi(SupersetTestCase):
@@ -3555,3 +3560,30 @@ class TestDatasetApi(SupersetTestCase):
assert rv.status_code == 404
self.items_to_delete = [dashboard, chart, dataset]
+
+ @pytest.mark.usefixtures("inject_expected_dataset_lineage")
+ def test_get_dataset_lineage(self):
+ """
+ Dataset API: Test get dataset lineage
+ """
+ self.login(ADMIN_USERNAME)
+ dataset_id = self.dataset_lineage["dataset_id"]
+ expected = self.dataset_lineage["expected"]
+
+ uri = f"api/v1/dataset/{dataset_id}/lineage"
+ rv = self.get_assert_metric(uri, "lineage")
+ assert rv.status_code == 200
+
+ data = json.loads(rv.data.decode("utf-8"))
+
+ # Assert the entire response matches expected structure
+ assert data == expected
+
+ def test_get_dataset_lineage_not_found(self):
+ """
+ Dataset API: Test get dataset lineage with non-existent dataset
+ """
+ self.login(ADMIN_USERNAME)
+ uri = "api/v1/dataset/99999/lineage"
+ rv = self.client.get(uri)
+ assert rv.status_code == 404
diff --git a/tests/integration_tests/fixtures/lineage.py b/tests/integration_tests/fixtures/lineage.py
new file mode 100644
index 00000000000..e4d606b3cb2
--- /dev/null
+++ b/tests/integration_tests/fixtures/lineage.py
@@ -0,0 +1,266 @@
+# 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 pytest
+
+from superset import db
+from superset.models.dashboard import Dashboard
+from superset.models.slice import Slice
+from superset.utils.database import get_example_database
+from tests.integration_tests.dashboard_utils import create_table_metadata
+
+
+@pytest.fixture
+def lineage_test_data(app_context, load_birth_names_data):
+ """
+ Base fixture that creates a simple lineage structure and returns
+ the created entities (database, dataset, charts, dashboard).
+ """
+ database = get_example_database()
+
+ # Create dataset
+ dataset = create_table_metadata(
+ table_name="lineage_test_dataset",
+ database=database,
+ )
+ db.session.add(dataset)
+ db.session.flush()
+
+ # Create charts
+ chart1 = Slice(
+ slice_name="Lineage Test Chart 1",
+ viz_type="table",
+ datasource_id=dataset.id,
+ datasource_type="table",
+ params="{}",
+ )
+ chart2 = Slice(
+ slice_name="Lineage Test Chart 2",
+ viz_type="pie",
+ datasource_id=dataset.id,
+ datasource_type="table",
+ params="{}",
+ )
+ db.session.add(chart1)
+ db.session.add(chart2)
+ db.session.flush()
+
+ # Create dashboard with charts
+ dashboard = Dashboard(
+ dashboard_title="Lineage Test Dashboard",
+ slug="lineage-test-dashboard",
+ slices=[chart1, chart2],
+ published=True,
+ )
+ db.session.add(dashboard)
+ db.session.commit()
+
+ # Return the created entities
+ result = {
+ "database": database,
+ "dataset": dataset,
+ "charts": [chart1, chart2],
+ "dashboard": dashboard,
+ }
+
+ yield result
+
+ # Cleanup
+ db.session.delete(dashboard)
+ db.session.delete(chart1)
+ db.session.delete(chart2)
+ for col in dataset.columns + dataset.metrics:
+ db.session.delete(col)
+ db.session.delete(dataset)
+ db.session.commit()
+
+
+@pytest.fixture(autouse=False)
+def inject_expected_dataset_lineage(request, lineage_test_data):
+ """
+ Injects dataset lineage data into test class instance.
+ """
+ dataset = lineage_test_data["dataset"]
+ database = lineage_test_data["database"]
+ charts = lineage_test_data["charts"]
+ dashboard = lineage_test_data["dashboard"]
+
+ request.instance.dataset_lineage = {
+ "dataset_id": dataset.id,
+ "expected": {
+ "dataset": {
+ "id": dataset.id,
+ "name": dataset.name,
+ "schema": dataset.schema,
+ "table_name": dataset.table_name,
+ "database_id": database.id,
+ "database_name": database.database_name,
+ },
+ "upstream": {
+ "database": {
+ "id": database.id,
+ "database_name": database.database_name,
+ "backend": database.backend,
+ }
+ },
+ "downstream": {
+ "charts": {
+ "count": 2,
+ "result": [
+ {
+ "id": charts[0].id,
+ "slice_name": charts[0].slice_name,
+ "viz_type": charts[0].viz_type,
+ "dashboard_ids": [dashboard.id],
+ },
+ {
+ "id": charts[1].id,
+ "slice_name": charts[1].slice_name,
+ "viz_type": charts[1].viz_type,
+ "dashboard_ids": [dashboard.id],
+ },
+ ],
+ },
+ "dashboards": {
+ "count": 1,
+ "result": [
+ {
+ "id": dashboard.id,
+ "title": dashboard.dashboard_title,
+ "slug": dashboard.slug,
+ "chart_ids": sorted([charts[0].id, charts[1].id]),
+ }
+ ],
+ },
+ },
+ },
+ }
+
+
+@pytest.fixture(autouse=False)
+def inject_expected_chart_lineage(request, lineage_test_data):
+ """
+ Injects chart lineage data into test class instance.
+ """
+ dataset = lineage_test_data["dataset"]
+ database = lineage_test_data["database"]
+ chart = lineage_test_data["charts"][0] # Use first chart
+ dashboard = lineage_test_data["dashboard"]
+
+ request.instance.chart_lineage = {
+ "chart_id": chart.id,
+ "expected": {
+ "chart": {
+ "id": chart.id,
+ "slice_name": chart.slice_name,
+ "viz_type": chart.viz_type,
+ },
+ "upstream": {
+ "dataset": {
+ "id": dataset.id,
+ "name": dataset.name,
+ "schema": dataset.schema,
+ "table_name": dataset.table_name,
+ "database_id": database.id,
+ "database_name": database.database_name,
+ },
+ "database": {
+ "id": database.id,
+ "database_name": database.database_name,
+ "backend": database.backend,
+ },
+ },
+ "downstream": {
+ "dashboards": {
+ "count": 1,
+ "result": [
+ {
+ "id": dashboard.id,
+ "title": dashboard.dashboard_title,
+ "slug": dashboard.slug,
+ }
+ ],
+ }
+ },
+ },
+ }
+
+
+@pytest.fixture(autouse=False)
+def inject_expected_dashboard_lineage(request, lineage_test_data):
+ """
+ Injects dashboard lineage data into test class instance.
+ """
+ dataset = lineage_test_data["dataset"]
+ database = lineage_test_data["database"]
+ charts = lineage_test_data["charts"]
+ dashboard = lineage_test_data["dashboard"]
+
+ request.instance.dashboard_lineage = {
+ "dashboard_id": dashboard.id,
+ "expected": {
+ "dashboard": {
+ "id": dashboard.id,
+ "title": dashboard.dashboard_title,
+ "slug": dashboard.slug,
+ "published": dashboard.published,
+ },
+ "upstream": {
+ "charts": {
+ "count": 2,
+ "result": [
+ {
+ "id": charts[0].id,
+ "slice_name": charts[0].slice_name,
+ "viz_type": charts[0].viz_type,
+ "dataset_id": dataset.id,
+ },
+ {
+ "id": charts[1].id,
+ "slice_name": charts[1].slice_name,
+ "viz_type": charts[1].viz_type,
+ "dataset_id": dataset.id,
+ },
+ ],
+ },
+ "datasets": {
+ "count": 1,
+ "result": [
+ {
+ "id": dataset.id,
+ "name": dataset.name,
+ "schema": dataset.schema,
+ "table_name": dataset.table_name,
+ "database_id": database.id,
+ "database_name": database.database_name,
+ "chart_ids": sorted([charts[0].id, charts[1].id]),
+ }
+ ],
+ },
+ "databases": {
+ "count": 1,
+ "result": [
+ {
+ "id": database.id,
+ "database_name": database.database_name,
+ "backend": database.backend,
+ }
+ ],
+ },
+ },
+ "downstream": None,
+ },
+ }