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/10/26 18:51:20 UTC
(superset) 01/02: feat(lineage): add lineage visualization across datasets, charts and dashboards
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 3d58d806d27aa911e3d0584579c4bb2ca513cd78
Author: Evan Rusackas <ev...@rusackas.com>
AuthorDate: Fri Oct 23 22:48:44 2026 -0700
feat(lineage): add lineage visualization across datasets, charts and dashboards
Squashed and rebased onto current master.
Co-Authored-By: Evan Rusackas <ev...@preset.io>
Co-Authored-By: Claude Sonnet 5.5 <no...@anthropic.com>
---
docs/static/resources/openapi.json | 570 ++++++++++++++++
.../DatasourceEditor/DatasourceEditor.tsx | 27 +
.../Header/useHeaderActionsDropdownMenu.tsx | 18 +
superset-frontend/src/dashboard/types.ts | 1 +
.../useExploreAdditionalActionsMenu/index.tsx | 19 +
.../src/features/charts/ChartCard.tsx | 25 +
.../src/features/dashboards/DashboardCard.tsx | 19 +
.../datasets/AddDataset/EditDataset/index.tsx | 28 +-
.../lineage/LazyLineageView.tsx} | 31 +-
.../src/features/lineage/LineageModal.tsx | 90 +++
.../src/features/lineage/LineageView.tsx | 743 +++++++++++++++++++++
.../apiResources => features/lineage}/index.ts | 17 +-
.../src/hooks/apiResources/apiResources.test.ts | 42 ++
.../src/hooks/apiResources/apiResources.ts | 16 +-
superset-frontend/src/hooks/apiResources/index.ts | 1 +
.../src/hooks/apiResources/lineage.test.ts | 128 ++++
.../src/hooks/apiResources/lineage.ts | 151 +++++
superset/charts/api.py | 109 +++
superset/charts/schemas.py | 48 ++
superset/constants.py | 1 +
superset/daos/dataset.py | 8 +-
superset/dashboards/api.py | 124 ++++
superset/dashboards/schemas.py | 59 ++
superset/datasets/api.py | 127 ++++
superset/datasets/schemas.py | 54 ++
superset/translations/messages.pot | 61 ++
tests/integration_tests/charts/api_tests.py | 32 +
tests/integration_tests/dashboards/api_tests.py | 32 +
tests/integration_tests/datasets/api_tests.py | 36 +-
tests/integration_tests/fixtures/lineage.py | 266 ++++++++
30 files changed, 2846 insertions(+), 37 deletions(-)
diff --git a/docs/static/resources/openapi.json b/docs/static/resources/openapi.json
index 643f30c4932..4492b4f6910 100644
--- a/docs/static/resources/openapi.json
+++ b/docs/static/resources/openapi.json
@@ -2976,6 +2976,131 @@
},
"type": "object"
},
+ "ChartLineageChart": {
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "slice_name": {
+ "type": "string"
+ },
+ "viz_type": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "ChartLineageDashboard": {
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "slug": {
+ "nullable": true,
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "ChartLineageDatabase": {
+ "properties": {
+ "backend": {
+ "type": "string"
+ },
+ "database_name": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ }
+ },
+ "type": "object"
+ },
+ "ChartLineageDataset": {
+ "properties": {
+ "database_id": {
+ "type": "integer"
+ },
+ "database_name": {
+ "nullable": true,
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "schema": {
+ "nullable": true,
+ "type": "string"
+ },
+ "table_name": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "ChartLineageDownstream": {
+ "properties": {
+ "dashboards": {
+ "$ref": "#/components/schemas/ChartLineageDownstreamDashboards"
+ }
+ },
+ "type": "object"
+ },
+ "ChartLineageDownstreamDashboards": {
+ "properties": {
+ "count": {
+ "type": "integer"
+ },
+ "result": {
+ "items": {
+ "$ref": "#/components/schemas/ChartLineageDashboard"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "ChartLineageResponseSchema": {
+ "properties": {
+ "chart": {
+ "$ref": "#/components/schemas/ChartLineageChart"
+ },
+ "downstream": {
+ "$ref": "#/components/schemas/ChartLineageDownstream"
+ },
+ "upstream": {
+ "$ref": "#/components/schemas/ChartLineageUpstream"
+ }
+ },
+ "type": "object"
+ },
+ "ChartLineageUpstream": {
+ "properties": {
+ "database": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/ChartLineageDatabase"
+ }
+ ],
+ "nullable": true
+ },
+ "dataset": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/ChartLineageDataset"
+ }
+ ],
+ "nullable": true
+ }
+ },
+ "type": "object"
+ },
"ChartRestApi.get": {
"properties": {
"id": {
@@ -4211,6 +4336,158 @@
},
"type": "object"
},
+ "DashboardLineageChart": {
+ "properties": {
+ "dataset_id": {
+ "type": "integer"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "slice_name": {
+ "type": "string"
+ },
+ "viz_type": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "DashboardLineageDashboard": {
+ "properties": {
+ "id": {
+ "type": "integer"
+ },
+ "published": {
+ "type": "boolean"
+ },
+ "slug": {
+ "nullable": true,
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "DashboardLineageDatabase": {
+ "properties": {
+ "backend": {
+ "type": "string"
+ },
+ "database_name": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ }
+ },
+ "type": "object"
+ },
+ "DashboardLineageDataset": {
+ "properties": {
+ "chart_ids": {
+ "items": {
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "database_id": {
+ "nullable": true,
+ "type": "integer"
+ },
+ "database_name": {
+ "nullable": true,
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "schema": {
+ "nullable": true,
+ "type": "string"
+ },
+ "table_name": {
+ "nullable": true,
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "DashboardLineageResponseSchema": {
+ "properties": {
+ "dashboard": {
+ "$ref": "#/components/schemas/DashboardLineageDashboard"
+ },
+ "downstream": {
+ "nullable": true
+ },
+ "upstream": {
+ "$ref": "#/components/schemas/DashboardLineageUpstream"
+ }
+ },
+ "type": "object"
+ },
+ "DashboardLineageUpstream": {
+ "properties": {
+ "charts": {
+ "$ref": "#/components/schemas/DashboardLineageUpstreamCharts"
+ },
+ "databases": {
+ "$ref": "#/components/schemas/DashboardLineageUpstreamDatabases"
+ },
+ "datasets": {
+ "$ref": "#/components/schemas/DashboardLineageUpstreamDatasets"
+ }
+ },
+ "type": "object"
+ },
+ "DashboardLineageUpstreamCharts": {
+ "properties": {
+ "count": {
+ "type": "integer"
+ },
+ "result": {
+ "items": {
+ "$ref": "#/components/schemas/DashboardLineageChart"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "DashboardLineageUpstreamDatabases": {
+ "properties": {
+ "count": {
+ "type": "integer"
+ },
+ "result": {
+ "items": {
+ "$ref": "#/components/schemas/DashboardLineageDatabase"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "DashboardLineageUpstreamDatasets": {
+ "properties": {
+ "count": {
+ "type": "integer"
+ },
+ "result": {
+ "items": {
+ "$ref": "#/components/schemas/DashboardLineageDataset"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
"DashboardNativeFiltersConfigUpdateSchema": {
"properties": {
"deleted": {
@@ -5998,6 +6275,152 @@
],
"type": "object"
},
+ "DatasetLineageChart": {
+ "properties": {
+ "dashboard_ids": {
+ "items": {
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "slice_name": {
+ "type": "string"
+ },
+ "viz_type": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "DatasetLineageDashboard": {
+ "properties": {
+ "chart_ids": {
+ "items": {
+ "type": "integer"
+ },
+ "type": "array"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "slug": {
+ "nullable": true,
+ "type": "string"
+ },
+ "title": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "DatasetLineageDatabase": {
+ "properties": {
+ "backend": {
+ "type": "string"
+ },
+ "database_name": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ }
+ },
+ "type": "object"
+ },
+ "DatasetLineageDataset": {
+ "properties": {
+ "database_id": {
+ "type": "integer"
+ },
+ "database_name": {
+ "nullable": true,
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "schema": {
+ "nullable": true,
+ "type": "string"
+ },
+ "table_name": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "DatasetLineageDownstream": {
+ "properties": {
+ "charts": {
+ "$ref": "#/components/schemas/DatasetLineageDownstreamCharts"
+ },
+ "dashboards": {
+ "$ref": "#/components/schemas/DatasetLineageDownstreamDashboards"
+ }
+ },
+ "type": "object"
+ },
+ "DatasetLineageDownstreamCharts": {
+ "properties": {
+ "count": {
+ "type": "integer"
+ },
+ "result": {
+ "items": {
+ "$ref": "#/components/schemas/DatasetLineageChart"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "DatasetLineageDownstreamDashboards": {
+ "properties": {
+ "count": {
+ "type": "integer"
+ },
+ "result": {
+ "items": {
+ "$ref": "#/components/schemas/DatasetLineageDashboard"
+ },
+ "type": "array"
+ }
+ },
+ "type": "object"
+ },
+ "DatasetLineageResponseSchema": {
+ "properties": {
+ "dataset": {
+ "$ref": "#/components/schemas/DatasetLineageDataset"
+ },
+ "downstream": {
+ "$ref": "#/components/schemas/DatasetLineageDownstream"
+ },
+ "upstream": {
+ "$ref": "#/components/schemas/DatasetLineageUpstream"
+ }
+ },
+ "type": "object"
+ },
+ "DatasetLineageUpstream": {
+ "properties": {
+ "database": {
+ "allOf": [
+ {
+ "$ref": "#/components/schemas/DatasetLineageDatabase"
+ }
+ ],
+ "nullable": true
+ }
+ },
+ "type": "object"
+ },
"DatasetMetricCurrencyPut": {
"properties": {
"symbol": {
@@ -16807,6 +17230,55 @@
]
}
},
+ "/api/v1/chart/{id_or_uuid}/lineage": {
+ "get": {
+ "description": "Returns upstream (dataset, database) and downstream (dashboards) lineage information for a chart",
+ "parameters": [
+ {
+ "description": "Either the id of the chart, or its uuid",
+ "in": "path",
+ "name": "id_or_uuid",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ChartLineageResponseSchema"
+ }
+ }
+ },
+ "description": "Lineage information"
+ },
+ "401": {
+ "$ref": "#/components/responses/401"
+ },
+ "404": {
+ "$ref": "#/components/responses/404"
+ },
+ "500": {
+ "$ref": "#/components/responses/500"
+ }
+ },
+ "security": [
+ {
+ "jwt": []
+ },
+ {
+ "api_key": []
+ }
+ ],
+ "summary": "Get lineage information for a chart",
+ "tags": [
+ "Charts"
+ ]
+ }
+ },
"/api/v1/chart/{pk}": {
"delete": {
"parameters": [
@@ -19624,6 +20096,55 @@
]
}
},
+ "/api/v1/dashboard/{id_or_slug}/lineage": {
+ "get": {
+ "description": "Returns upstream (charts, datasets, databases) lineage information for a dashboard",
+ "parameters": [
+ {
+ "description": "Either the id of the dashboard, or its slug",
+ "in": "path",
+ "name": "id_or_slug",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DashboardLineageResponseSchema"
+ }
+ }
+ },
+ "description": "Lineage information"
+ },
+ "401": {
+ "$ref": "#/components/responses/401"
+ },
+ "404": {
+ "$ref": "#/components/responses/404"
+ },
+ "500": {
+ "$ref": "#/components/responses/500"
+ }
+ },
+ "security": [
+ {
+ "jwt": []
+ },
+ {
+ "api_key": []
+ }
+ ],
+ "summary": "Get lineage information for a dashboard",
+ "tags": [
+ "Dashboards"
+ ]
+ }
+ },
"/api/v1/dashboard/{id_or_slug}/tabs": {
"get": {
"description": "Returns a list of a dashboard's tabs and dashboard's nested tree structure for associated tabs.",
@@ -24478,6 +24999,55 @@
]
}
},
+ "/api/v1/dataset/{id_or_uuid}/lineage": {
+ "get": {
+ "description": "Returns upstream (database) and downstream (charts, dashboards) lineage information for a dataset",
+ "parameters": [
+ {
+ "description": "Either the id of the dataset, or its uuid",
+ "in": "path",
+ "name": "id_or_uuid",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/DatasetLineageResponseSchema"
+ }
+ }
+ },
+ "description": "Lineage information"
+ },
+ "401": {
+ "$ref": "#/components/responses/401"
+ },
+ "404": {
+ "$ref": "#/components/responses/404"
+ },
+ "500": {
+ "$ref": "#/components/responses/500"
+ }
+ },
+ "security": [
+ {
+ "jwt": []
+ },
+ {
+ "api_key": []
+ }
+ ],
+ "summary": "Get lineage information for a dataset",
+ "tags": [
+ "Datasets"
+ ]
+ }
+ },
"/api/v1/dataset/{id_or_uuid}/related_objects": {
"get": {
"parameters": [
diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
index 34e88c1b14b..e5e9ab7ec51 100644
--- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
+++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
@@ -63,6 +63,7 @@ import {
Col,
Divider,
EditableTitle,
+ Empty,
Flex,
FormLabel,
Icons,
@@ -93,6 +94,8 @@ import { DatabaseSelector } from '../../../DatabaseSelector';
import SpatialControl from 'src/explore/components/controls/SpatialControl';
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';
@@ -475,6 +478,20 @@ 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 ?? '');
+ if (!datasourceId) {
+ return (
+ <Empty
+ description={t('Lineage is available after the dataset is saved')}
+ />
+ );
+ }
+ return <LineageView lineageResource={lineageResource} entityType="dataset" />;
+}
+
const DefaultColumnSettingsContainer = styled.div`
${({ theme }) => css`
margin-bottom: ${theme.sizeUnit * 4}px;
@@ -534,6 +551,7 @@ const TABS_KEYS = {
COLUMNS: 'COLUMNS',
CALCULATED_COLUMNS: 'CALCULATED_COLUMNS',
USAGE: 'USAGE',
+ LINEAGE: 'LINEAGE',
FOLDERS: 'FOLDERS',
SETTINGS: 'SETTINGS',
SPATIAL: 'SPATIAL',
@@ -2730,6 +2748,15 @@ function DatasourceEditor({
</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 a2812f6d056..5cf9b91f98e 100644
--- a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
+++ b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
@@ -41,6 +41,7 @@ import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
import { usePermissions } from 'src/hooks/usePermissions';
import { openVersionHistoryPanel } from 'src/features/versionHistory/reducer';
import getUserName from 'src/utils/getUserName';
+import { LineageModal } from 'src/features/lineage';
export const useHeaderActionsMenu = ({
customCss,
@@ -306,6 +307,23 @@ export const useHeaderActionsMenu = ({
});
}
+ // View lineage (available in both view and edit mode; lineage is
+ // read-only information about the dashboard's upstream assets)
+ if (dashboardId) {
+ menuItems.push(
+ createModalMenuItem(
+ MenuKeys.ViewLineage,
+ <LineageModal
+ entityType="dashboard"
+ entityId={dashboardId}
+ triggerNode={
+ <div data-test="view-lineage-menu-item">{t('View lineage')}</div>
+ }
+ />,
+ ),
+ );
+ }
+
// Edit properties
if (editMode) {
menuItems.push({
diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts
index 37cab5f5c84..ce0e9db59f3 100644
--- a/superset-frontend/src/dashboard/types.ts
+++ b/superset-frontend/src/dashboard/types.ts
@@ -423,4 +423,5 @@ export enum MenuKeys {
ExportPivotXlsx = 'export_pivot_xlsx',
EmbedCode = 'embed_code',
VersionHistory = 'version_history',
+ ViewLineage = 'view_lineage',
}
diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
index 56ece0349fd..efc0921a464 100644
--- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
+++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
@@ -75,6 +75,7 @@ import ViewQueryModal from '../controls/ViewQueryModal';
import EmbedCodeContent from '../EmbedCodeContent';
import { useDashboardsMenuItems } from './DashboardsSubMenu';
import { useExploreDataExport } from './useExploreDataExport';
+import { LineageModal } from 'src/features/lineage';
export const SEARCH_THRESHOLD = 10;
@@ -150,6 +151,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',
VERSION_HISTORY: 'version_history',
};
@@ -1151,6 +1153,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 654914b71b4..4ad71f1cce6 100644
--- a/superset-frontend/src/features/charts/ChartCard.tsx
+++ b/superset-frontend/src/features/charts/ChartCard.tsx
@@ -46,6 +46,7 @@ import type { ListViewFetchDataConfig as FetchDataConfig } from 'src/components'
import { TableTab } from 'src/views/CRUD/types';
import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils';
import type { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
+import { LineageModal } from 'src/features/lineage';
const menuItemButtonCss = css`
appearance: none;
@@ -105,6 +106,7 @@ export default function ChartCard({
chart.editors,
chart.extra_editors,
);
+ const canRead = hasPerm('can_read');
const menuItems: MenuItem[] = [];
if (canEdit) {
@@ -140,6 +142,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',
diff --git a/superset-frontend/src/features/dashboards/DashboardCard.tsx b/superset-frontend/src/features/dashboards/DashboardCard.tsx
index 08ffc7cbbc4..264f6ffda20 100644
--- a/superset-frontend/src/features/dashboards/DashboardCard.tsx
+++ b/superset-frontend/src/features/dashboards/DashboardCard.tsx
@@ -37,6 +37,7 @@ import { KebabMenuButton } from 'src/components';
import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils';
import type { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import { useIsMobile } from 'src/hooks/useIsMobile';
+import { LineageModal } from 'src/features/lineage';
const menuItemButtonCss = css`
appearance: none;
@@ -90,6 +91,7 @@ function DashboardCard({
dashboard.editors,
dashboard.extra_editors,
);
+ const canRead = hasPerm('can_read');
const digest = dashboard.changed_on_utc || dashboard.changed_on;
const thumbnailUrl =
isFeatureEnabled(FeatureFlag.Thumbnails) && dashboard.id && digest
@@ -98,6 +100,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',
diff --git a/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx b/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx
index e0438cee0bd..d816b2b1450 100644
--- a/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx
+++ b/superset-frontend/src/features/datasets/AddDataset/EditDataset/index.tsx
@@ -16,11 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { useState } from 'react';
import { t } from '@apache-superset/core/translation';
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 +54,25 @@ 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 [activeKey, setActiveKey] = useState(TABS_KEYS.COLUMNS);
+ // Only fetch lineage once the user opens the Lineage tab to avoid
+ // unnecessary requests/backend load on page load.
+ const lineageResource = useDatasetLineage(
+ id,
+ activeKey !== TABS_KEYS.LINEAGE,
+ );
const usageTab = (
<TabStyles>
@@ -85,9 +97,23 @@ 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} />;
+ return (
+ <StyledTabs
+ moreIcon={null}
+ items={items}
+ activeKey={activeKey}
+ onChange={setActiveKey}
+ />
+ );
};
export default EditPage;
diff --git a/superset-frontend/src/hooks/apiResources/index.ts b/superset-frontend/src/features/lineage/LazyLineageView.tsx
similarity index 54%
copy from superset-frontend/src/hooks/apiResources/index.ts
copy to superset-frontend/src/features/lineage/LazyLineageView.tsx
index 53aa7aa113c..e1ea2f00322 100644
--- a/superset-frontend/src/hooks/apiResources/index.ts
+++ b/superset-frontend/src/features/lineage/LazyLineageView.tsx
@@ -16,19 +16,20 @@
* specific language governing permissions and limitations
* under the License.
*/
-export { skipToken } from '@reduxjs/toolkit/query/react';
-export {
- useApiResourceFullBody,
- useApiV1Resource,
- useTransformedResource,
-} from './apiResources';
+import { FC, Suspense, lazy } from 'react';
+import { Loading } from '@superset-ui/core/components';
+import type { LineageViewProps } from './LineageView';
-// 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';
+// The lineage view pulls in the ECharts renderer and the Sankey series, which
+// only a small fraction of page loads ever open. Loading it on demand keeps
+// that weight out of the SPA entrypoint for the list, explore and dashboard
+// pages that merely render a "Lineage" menu item.
+const LineageViewChunk = lazy(() => import('./LineageView'));
+
+const LazyLineageView: FC<LineageViewProps> = props => (
+ <Suspense fallback={<Loading />}>
+ <LineageViewChunk {...props} />
+ </Suspense>
+);
+
+export default LazyLineageView;
diff --git a/superset-frontend/src/features/lineage/LineageModal.tsx b/superset-frontend/src/features/lineage/LineageModal.tsx
new file mode 100644
index 00000000000..53b1683a65b
--- /dev/null
+++ b/superset-frontend/src/features/lineage/LineageModal.tsx
@@ -0,0 +1,90 @@
+/**
+ * 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, useState } 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 './LazyLineageView';
+
+export interface LineageModalProps {
+ entityType: 'dataset' | 'chart' | 'dashboard';
+ entityId: string | number;
+ triggerNode: ReactNode;
+}
+
+const LineageModal: FC<LineageModalProps> = ({
+ entityType,
+ entityId,
+ triggerNode,
+}) => {
+ // Defer the lineage fetch until the modal is actually opened so that simply
+ // rendering the trigger (e.g. inside an actions dropdown) does not hit the
+ // lineage endpoint.
+ const [opened, setOpened] = useState(false);
+
+ const datasetLineage = useDatasetLineage(
+ entityType === 'dataset' ? entityId : '',
+ !opened,
+ );
+ const chartLineage = useChartLineage(
+ entityType === 'chart' ? entityId : '',
+ !opened,
+ );
+ const dashboardLineage = useDashboardLineage(
+ entityType === 'dashboard' ? entityId : '',
+ !opened,
+ );
+
+ 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}
+ beforeOpen={() => setOpened(true)}
+ 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..fb9f5ec3e52
--- /dev/null
+++ b/superset-frontend/src/features/lineage/LineageView.tsx
@@ -0,0 +1,743 @@
+/**
+ * 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 { useResizeDetector } from 'react-resize-detector';
+import { t } from '@apache-superset/core/translation';
+import { styled, useTheme } from '@apache-superset/core/theme';
+import { Button, Empty, Loading } from '@superset-ui/core/components';
+import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
+import type { Resource } from 'src/hooks/apiResources/apiResources';
+import { redirect } from 'src/utils/navigationUtils';
+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 NodeType = 'database' | 'dataset' | 'chart' | 'dashboard';
+
+type NodeDetails = {
+ name: string;
+ type: NodeType;
+ id?: number;
+ additionalInfo?: Record<string, string | number | null | undefined>;
+};
+
+// Build a stable, unique graph identity for a node so that entities sharing the
+// same display name (e.g. two charts with identical titles) never collapse into
+// a single Sankey node. The human-readable name is kept separately as the label.
+const nodeKey = (type: NodeType, id?: number, name?: string): string =>
+ id != null ? `${type}:${id}` : `${type}:${name ?? ''}`;
+
+export 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);
+ const { width: chartWidth = 800, ref: chartContainerRef } =
+ useResizeDetector();
+
+ // 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(nodeKey('dataset', dataset.id, 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(
+ nodeKey(
+ 'database',
+ upstream.database.id,
+ 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(nodeKey('chart', chart.id, 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(nodeKey('dashboard', dashboard.id, 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(nodeKey('chart', chart.id, 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(
+ nodeKey('dataset', upstream.dataset.id, 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(
+ nodeKey(
+ 'database',
+ upstream.database.id,
+ 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(nodeKey('dashboard', dashboard.id, 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(nodeKey('dashboard', dashboard.id, dashboard.title), {
+ name: dashboard.title,
+ type: 'dashboard',
+ id: dashboard.id,
+ additionalInfo: {
+ slug: dashboard.slug,
+ },
+ });
+
+ // Add upstream charts
+ if (upstream?.charts?.result) {
+ upstream.charts.result.forEach((chart: ChartEntity) => {
+ map.set(nodeKey('chart', chart.id, chart.slice_name), {
+ 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(nodeKey('dataset', dataset.id, 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(nodeKey('database', database.id, database.database_name), {
+ name: database.database_name,
+ type: 'database',
+ id: database.id,
+ });
+ });
+ }
+ }
+
+ return map;
+ }, [lineageResource, entityType]);
+
+ // Handle node click
+ const handleNodeClick = useCallback(
+ (params: {
+ dataType?: string;
+ name?: string;
+ event?: { stop: () => void };
+ }) => {
+ if (params.dataType === 'node' && params.name) {
+ const nodeDetails = nodeDetailsMap.get(params.name);
+ 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;
+ label?: { position?: string; formatter?: string };
+ itemStyle?: { color: string };
+ }[] = [];
+ const links: { source: string; target: string; value: number }[] = [];
+ const nodeSet = new Set<string>();
+
+ // Helper to add a node. `key` is the stable unique identity used for graph
+ // links and detail lookups; `label` is the human-readable text shown.
+ const addNode = (
+ key: string,
+ label: string,
+ color: string,
+ labelPosition: 'left' | 'right' | 'inside',
+ ) => {
+ if (!nodeSet.has(key)) {
+ nodeSet.add(key);
+ nodes.push({
+ name: key,
+ itemStyle: { color },
+ label: {
+ position: labelPosition,
+ formatter: label,
+ },
+ });
+ }
+ };
+
+ // Helper to add a link between two node keys
+ 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;
+
+ const datasetKey = nodeKey('dataset', dataset.id, dataset.name);
+ // Add current dataset node (center) - label inside
+ addNode(datasetKey, dataset.name, theme.colorPrimary, 'inside');
+
+ // Add upstream database - label on left
+ if (upstream?.database) {
+ const dbKey = nodeKey(
+ 'database',
+ upstream.database.id,
+ upstream.database.database_name,
+ );
+ addNode(
+ dbKey,
+ upstream.database.database_name,
+ theme.colorInfo,
+ 'left',
+ );
+ addLink(dbKey, datasetKey);
+ }
+
+ // Add downstream charts - label on right
+ const chartKeys = new Map<number, string>();
+ if (downstream?.charts?.result) {
+ downstream.charts.result.forEach((chart: ChartEntity) => {
+ const chartKey = nodeKey('chart', chart.id, chart.slice_name);
+ chartKeys.set(chart.id, chartKey);
+ addNode(chartKey, chart.slice_name, theme.colorSuccess, 'right');
+ addLink(datasetKey, chartKey);
+ });
+ }
+
+ // Add downstream dashboards - label on right
+ if (downstream?.dashboards?.result) {
+ downstream.dashboards.result.forEach((dashboard: DashboardEntity) => {
+ const dashKey = nodeKey('dashboard', dashboard.id, dashboard.title);
+ addNode(dashKey, dashboard.title, theme.colorWarning, 'right');
+
+ // Link from charts to dashboards using chart_ids
+ let linked = false;
+ if (dashboard.chart_ids && dashboard.chart_ids.length > 0) {
+ dashboard.chart_ids.forEach(chartId => {
+ const chartKey = chartKeys.get(chartId);
+ if (chartKey) {
+ addLink(chartKey, dashKey);
+ linked = true;
+ }
+ });
+ }
+ // Fall back to a direct dataset -> dashboard edge so the dashboard
+ // still renders when none of its charts resolve to visible nodes
+ // (e.g. after permission filtering); Sankey omits orphaned nodes.
+ if (!linked) {
+ addLink(datasetKey, dashKey);
+ }
+ });
+ }
+ } else if (entityType === 'chart' && 'chart' in data) {
+ const { chart, upstream, downstream } = data as ChartLineage;
+
+ const chartKey = nodeKey('chart', chart.id, chart.slice_name);
+ // Add current chart node (center) - label inside
+ addNode(chartKey, chart.slice_name, theme.colorPrimary, 'inside');
+
+ // Add upstream dataset - label on left
+ if (upstream?.dataset) {
+ const datasetKey = nodeKey(
+ 'dataset',
+ upstream.dataset.id,
+ upstream.dataset.name,
+ );
+ addNode(datasetKey, upstream.dataset.name, theme.colorInfo, 'left');
+ addLink(datasetKey, chartKey);
+
+ // Add upstream database - label on left
+ if (upstream.database) {
+ const dbKey = nodeKey(
+ 'database',
+ upstream.database.id,
+ upstream.database.database_name,
+ );
+ addNode(
+ dbKey,
+ upstream.database.database_name,
+ theme.colorWarning,
+ 'left',
+ );
+ addLink(dbKey, datasetKey);
+ }
+ }
+
+ // Add downstream dashboards - label on right
+ if (downstream?.dashboards?.result) {
+ downstream.dashboards.result.forEach((dashboard: DashboardEntity) => {
+ const dashKey = nodeKey('dashboard', dashboard.id, dashboard.title);
+ addNode(dashKey, dashboard.title, theme.colorSuccess, 'right');
+ addLink(chartKey, dashKey);
+ });
+ }
+ } else if (entityType === 'dashboard' && 'dashboard' in data) {
+ const { dashboard, upstream } = data as DashboardLineage;
+
+ const dashKey = nodeKey('dashboard', dashboard.id, dashboard.title);
+ // Add current dashboard node (right) - label inside
+ addNode(dashKey, dashboard.title, theme.colorPrimary, 'inside');
+
+ // Add upstream charts - label on left
+ const chartKeys = new Map<number, string>();
+ if (upstream?.charts?.result) {
+ upstream.charts.result.forEach((chart: ChartEntity) => {
+ const chartKey = nodeKey('chart', chart.id, chart.slice_name);
+ chartKeys.set(chart.id, chartKey);
+ addNode(chartKey, chart.slice_name, theme.colorInfo, 'left');
+ addLink(chartKey, dashKey);
+ });
+ }
+
+ // Add upstream datasets - label on left
+ const datasetKeys = new Map<number, string>();
+ if (upstream?.datasets?.result) {
+ upstream.datasets.result.forEach(dataset => {
+ const datasetKey = nodeKey('dataset', dataset.id, dataset.name);
+ datasetKeys.set(dataset.id, datasetKey);
+ addNode(datasetKey, 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 datasetKey = datasetKeys.get(chart.dataset_id);
+ const chartKey = chartKeys.get(chart.id);
+ if (datasetKey && chartKey) {
+ addLink(datasetKey, chartKey);
+ }
+ }
+ });
+ }
+
+ // Add upstream databases and link to their specific datasets
+ if (upstream?.databases?.result) {
+ upstream.databases.result.forEach(database => {
+ const dbKey = nodeKey(
+ 'database',
+ database.id,
+ database.database_name,
+ );
+ addNode(dbKey, 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) {
+ const datasetKey = datasetKeys.get(dataset.id);
+ if (datasetKey) {
+ addLink(dbKey, datasetKey);
+ }
+ }
+ });
+ }
+ });
+ }
+ }
+
+ 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: t('Database (Upstream)'), color: theme.colorInfo },
+ { label: t('Dataset (Current)'), color: theme.colorPrimary },
+ { label: t('Chart (Downstream)'), color: theme.colorSuccess },
+ { label: t('Dashboard (Downstream)'), color: theme.colorWarning },
+ ];
+ } else if (entityType === 'chart') {
+ return [
+ { label: t('Database (Upstream)'), color: theme.colorWarning },
+ { label: t('Dataset (Upstream)'), color: theme.colorInfo },
+ { label: t('Chart (Current)'), color: theme.colorPrimary },
+ { label: t('Dashboard (Downstream)'), color: theme.colorSuccess },
+ ];
+ } else if (entityType === 'dashboard') {
+ return [
+ { label: t('Database (Upstream)'), color: theme.colorWarning },
+ { label: t('Dataset (Upstream)'), color: theme.colorSuccess },
+ { label: t('Chart (Upstream)'), color: theme.colorInfo },
+ { label: t('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. Datasets have no standalone
+ // detail page, so only dashboards and charts expose an "Open" action.
+ const getEntityUrl = (nodeDetails: NodeDetails): string => {
+ switch (nodeDetails.type) {
+ case 'dashboard':
+ return `/dashboard/${nodeDetails.id}/`;
+ case 'chart':
+ return `/explore/?slice_id=${nodeDetails.id}`;
+ default:
+ return '#';
+ }
+ };
+
+ return (
+ <LineageContainer>
+ <Legend>
+ {legendItems.map(item => (
+ <LegendItem key={item.label} color={item.color}>
+ {item.label}
+ </LegendItem>
+ ))}
+ </Legend>
+ <div ref={chartContainerRef} style={{ width: '100%' }}>
+ <Echart
+ refs={{}}
+ height={selectedNode ? 450 : 600}
+ width={chartWidth}
+ echartOptions={echartOptions}
+ vizType="sankey"
+ eventHandlers={{
+ click: handleNodeClick,
+ }}
+ />
+ </div>
+ {selectedNode && (
+ <DetailsPanel>
+ <DetailsPanelHeader>
+ <DetailsPanelTitle>
+ {t(
+ '%s Details',
+ selectedNode.type.charAt(0).toUpperCase() +
+ selectedNode.type.slice(1),
+ )}
+ </DetailsPanelTitle>
+ <DetailsPanelActions>
+ {(selectedNode.type === 'dashboard' ||
+ selectedNode.type === 'chart') && (
+ <Button
+ buttonStyle="primary"
+ buttonSize="small"
+ onClick={() => {
+ redirect(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..8c77ba35f03 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 './LazyLineageView';
+export { default as LineageModal } from './LineageModal';
diff --git a/superset-frontend/src/hooks/apiResources/apiResources.test.ts b/superset-frontend/src/hooks/apiResources/apiResources.test.ts
index 9a66838638c..727e31f19b0 100644
--- a/superset-frontend/src/hooks/apiResources/apiResources.test.ts
+++ b/superset-frontend/src/hooks/apiResources/apiResources.test.ts
@@ -97,6 +97,48 @@ describe('apiResource hooks', () => {
error: fakeError,
});
});
+
+ test('skips the fetch and stays loading when skip is true', async () => {
+ const fetchMock = jest.fn().mockResolvedValue(fakeApiResult);
+ (makeApi as jest.Mock).mockReturnValue(fetchMock);
+ const { result } = renderHook(() =>
+ useApiResourceFullBody('/test/endpoint', true),
+ );
+ await act(async () => {
+ jest.runAllTimers();
+ });
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(result.current).toEqual({
+ status: ResourceStatus.Loading,
+ result: null,
+ error: null,
+ });
+ });
+
+ test('re-enables the fetch when skip toggles from true to false', async () => {
+ const fetchMock = jest.fn().mockResolvedValue(fakeApiResult);
+ (makeApi as jest.Mock).mockReturnValue(fetchMock);
+ const { result, rerender } = renderHook(
+ ({ skip }) => useApiResourceFullBody('/test/endpoint', skip),
+ { initialProps: { skip: true } },
+ );
+ await act(async () => {
+ jest.runAllTimers();
+ });
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(result.current.status).toEqual(ResourceStatus.Loading);
+
+ rerender({ skip: false });
+ await act(async () => {
+ jest.runAllTimers();
+ });
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(result.current).toEqual({
+ status: ResourceStatus.Complete,
+ result: fakeApiResult,
+ error: null,
+ });
+ });
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
diff --git a/superset-frontend/src/hooks/apiResources/apiResources.ts b/superset-frontend/src/hooks/apiResources/apiResources.ts
index 04166db1d32..ec6894128d4 100644
--- a/superset-frontend/src/hooks/apiResources/apiResources.ts
+++ b/superset-frontend/src/hooks/apiResources/apiResources.ts
@@ -86,6 +86,7 @@ const initialState: LoadingState = {
*/
export function useApiResourceFullBody<RESULT>(
endpoint: string,
+ skip = false,
): Resource<RESULT> {
const [resource, setResource] = useState<Resource<RESULT>>(initialState);
const cancelRef = useRef<() => void>(() => {});
@@ -98,6 +99,12 @@ export function useApiResourceFullBody<RESULT>(
// when this effect runs, the endpoint has changed.
// cancel any current calls so that state doesn't get messed up.
cancelRef.current();
+
+ // Allow callers to opt out of fetching (e.g. when the identifier isn't
+ // known yet) so we don't fire requests against invalid endpoints.
+ if (skip) {
+ return undefined;
+ }
let cancelled = false;
cancelRef.current = () => {
cancelled = true;
@@ -132,7 +139,7 @@ export function useApiResourceFullBody<RESULT>(
return () => {
cancelled = true;
};
- }, [endpoint]);
+ }, [endpoint, skip]);
return resource;
}
@@ -181,9 +188,12 @@ const extractInnerResult = <T>(responseBody: { result: T }) =>
*
* @param endpoint The url where the resource is located.
*/
-export function useApiV1Resource<RESULT>(endpoint: string): Resource<RESULT> {
+export function useApiV1Resource<RESULT>(
+ endpoint: string,
+ skip = false,
+): Resource<RESULT> {
return useTransformedResource(
- useApiResourceFullBody<{ result: RESULT }>(endpoint),
+ useApiResourceFullBody<{ result: RESULT }>(endpoint, skip),
extractInnerResult,
);
}
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.test.ts b/superset-frontend/src/hooks/apiResources/lineage.test.ts
new file mode 100644
index 00000000000..38072dcc8ea
--- /dev/null
+++ b/superset-frontend/src/hooks/apiResources/lineage.test.ts
@@ -0,0 +1,128 @@
+/**
+ * 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 { renderHook, waitFor } from '@testing-library/react';
+import { makeApi } from '@superset-ui/core';
+import {
+ useChartLineage,
+ useDashboardLineage,
+ useDatasetLineage,
+} from './lineage';
+
+jest.mock('@superset-ui/core', () => ({
+ ...jest.requireActual('@superset-ui/core'),
+ makeApi: jest.fn(),
+}));
+
+const mockedMakeApi = jest.mocked(makeApi);
+
+// makeApi returns a function that issues the request; capture the endpoint it
+// was configured with so we can assert the correct URL was built.
+function mockApiSuccess(payload: unknown) {
+ const fetcher = jest.fn().mockResolvedValue({ result: payload });
+ mockedMakeApi.mockReturnValue(fetcher as any);
+ return fetcher;
+}
+
+function mockApiError(error: Error) {
+ const fetcher = jest.fn().mockRejectedValue(error);
+ mockedMakeApi.mockReturnValue(fetcher as any);
+ return fetcher;
+}
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+test('useDatasetLineage fetches dataset lineage and unwraps result', async () => {
+ const payload = {
+ dataset: { id: 1, name: 'ds' },
+ upstream: { database: { id: 2, database_name: 'db', backend: 'pg' } },
+ downstream: {
+ charts: { count: 0, result: [] },
+ dashboards: { count: 0, result: [] },
+ },
+ };
+ mockApiSuccess(payload);
+
+ const { result } = renderHook(() => useDatasetLineage(1));
+
+ expect(result.current.status).toBe('loading');
+ await waitFor(() => expect(result.current.status).toBe('complete'));
+
+ expect(mockedMakeApi).toHaveBeenCalledWith(
+ expect.objectContaining({
+ method: 'GET',
+ endpoint: '/api/v1/dataset/1/lineage',
+ }),
+ );
+ expect(result.current.result).toEqual(payload);
+ expect(result.current.error).toBeNull();
+});
+
+test('useChartLineage builds the chart lineage endpoint', async () => {
+ mockApiSuccess({ chart: { id: 5, slice_name: 'c', viz_type: 'pie' } });
+
+ const { result } = renderHook(() => useChartLineage(5));
+
+ await waitFor(() => expect(result.current.status).toBe('complete'));
+ expect(mockedMakeApi).toHaveBeenCalledWith(
+ expect.objectContaining({ endpoint: '/api/v1/chart/5/lineage' }),
+ );
+});
+
+test('useDashboardLineage builds the dashboard lineage endpoint', async () => {
+ mockApiSuccess({ dashboard: { id: 9, title: 'd', slug: 'd' } });
+
+ const { result } = renderHook(() => useDashboardLineage(9));
+
+ await waitFor(() => expect(result.current.status).toBe('complete'));
+ expect(mockedMakeApi).toHaveBeenCalledWith(
+ expect.objectContaining({ endpoint: '/api/v1/dashboard/9/lineage' }),
+ );
+});
+
+test('lineage hooks surface network errors', async () => {
+ mockApiError(new Error('Network error'));
+
+ const { result } = renderHook(() => useDatasetLineage(1));
+
+ await waitFor(() => expect(result.current.status).toBe('error'));
+ expect(result.current.result).toBeNull();
+ expect(result.current.error).toBeInstanceOf(Error);
+});
+
+test('lineage hooks skip the request when the id is empty', async () => {
+ const fetcher = mockApiSuccess({});
+
+ const { result } = renderHook(() => useDatasetLineage(''));
+
+ // Empty id resolves immediately without ever firing a request, so we never
+ // hit an invalid endpoint such as `/api/v1/dataset//lineage`.
+ expect(result.current.status).toBe('loading');
+ expect(fetcher).not.toHaveBeenCalled();
+});
+
+test('lineage hooks skip the request when skip is true', async () => {
+ const fetcher = mockApiSuccess({});
+
+ const { result } = renderHook(() => useChartLineage(5, true));
+
+ expect(result.current.status).toBe('loading');
+ expect(fetcher).not.toHaveBeenCalled();
+});
diff --git a/superset-frontend/src/hooks/apiResources/lineage.ts b/superset-frontend/src/hooks/apiResources/lineage.ts
new file mode 100644
index 00000000000..56e7a7a6a80
--- /dev/null
+++ b/superset-frontend/src/hooks/apiResources/lineage.ts
@@ -0,0 +1,151 @@
+/**
+ * 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;
+};
+
+// A missing/empty identifier means we have nothing to fetch yet; skip the
+// request so we never hit invalid endpoints like `/api/v1/chart//lineage`.
+const isEmptyId = (idOrUuid: string | number): boolean =>
+ idOrUuid === '' || idOrUuid == null;
+
+/**
+ * Hook to fetch lineage data for a dataset
+ * @param idOrUuid Dataset ID or UUID
+ * @param skip When true, defers the request (e.g. until the tab is active)
+ */
+export const useDatasetLineage = (idOrUuid: string | number, skip = false) =>
+ useApiV1Resource<DatasetLineage>(
+ `/api/v1/dataset/${idOrUuid}/lineage`,
+ skip || isEmptyId(idOrUuid),
+ );
+
+/**
+ * Hook to fetch lineage data for a chart
+ * @param idOrUuid Chart ID or UUID
+ * @param skip When true, defers the request (e.g. until the tab is active)
+ */
+export const useChartLineage = (idOrUuid: string | number, skip = false) =>
+ useApiV1Resource<ChartLineage>(
+ `/api/v1/chart/${idOrUuid}/lineage`,
+ skip || isEmptyId(idOrUuid),
+ );
+
+/**
+ * Hook to fetch lineage data for a dashboard
+ * @param idOrSlug Dashboard ID or slug
+ * @param skip When true, defers the request (e.g. until the tab is active)
+ */
+export const useDashboardLineage = (idOrSlug: string | number, skip = false) =>
+ useApiV1Resource<DashboardLineage>(
+ `/api/v1/dashboard/${idOrSlug}/lineage`,
+ skip || isEmptyId(idOrSlug),
+ );
diff --git a/superset/charts/api.py b/superset/charts/api.py
index fd83e07cc93..6447bc7cb73 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -261,6 +261,7 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
"get_version",
"activity",
"restore_version",
+ "lineage",
}
class_permission_name = "Chart"
# Custom methods (``restore``) need an explicit entry; FAB's @protect()
@@ -691,6 +692,114 @@ class ChartRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
]
return self.response(200, result=result)
+ @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. Schema/table/database
+ # details are only exposed to users who can access the underlying
+ # datasource; otherwise they are redacted so lineage never leaks
+ # datasource internals (the dataset id/name are kept so the graph
+ # still renders), mirroring the dashboard lineage endpoint.
+ upstream: dict[str, Any] = {}
+ if dataset := chart.datasource:
+ can_access = security_manager.can_access_datasource(dataset)
+ upstream["dataset"] = {
+ "id": dataset.id,
+ "name": dataset.name,
+ "database_id": dataset.database_id if can_access else None,
+ "database_name": (
+ dataset.database.database_name
+ if can_access and dataset.database
+ else None
+ ),
+ "schema": dataset.schema if can_access else None,
+ "table_name": dataset.table_name if can_access else None,
+ }
+ if can_access and 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, filtered by the current
+ # user's permissions so lineage never exposes dashboards the user
+ # cannot access.
+ dashboards: list[dict[str, Any]] = []
+ for dashboard in chart.dashboards:
+ if not security_manager.can_access_dashboard(dashboard):
+ continue
+ 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 a4c90628b75..975e47b8083 100644
--- a/superset/charts/schemas.py
+++ b/superset/charts/schemas.py
@@ -2139,6 +2139,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(allow_none=True)
+ 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(allow_none=True)
+
+
+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,
@@ -2166,4 +2213,5 @@ CHART_SCHEMAS = (
ChartGetResponseSchema,
ChartCacheScreenshotResponseSchema,
GetFavStarIdsSchema,
+ ChartLineageResponseSchema,
)
diff --git a/superset/constants.py b/superset/constants.py
index 818683352df..aace2d5a8c6 100644
--- a/superset/constants.py
+++ b/superset/constants.py
@@ -204,6 +204,7 @@ MODEL_API_RW_METHOD_PERMISSION_MAP = {
"list_versions": "read",
"get_version": "read",
"activity": "read",
+ "lineage": "read",
}
EXTRA_FORM_DATA_APPEND_KEYS = {
diff --git a/superset/daos/dataset.py b/superset/daos/dataset.py
index f571bec5bca..6d798bb25a5 100644
--- a/superset/daos/dataset.py
+++ b/superset/daos/dataset.py
@@ -23,7 +23,7 @@ from typing import Any, Dict, List
import dateutil.parser
from sqlalchemy import or_, select
from sqlalchemy.exc import SQLAlchemyError
-from sqlalchemy.orm import joinedload, Query
+from sqlalchemy.orm import joinedload, Query, selectinload
from superset.connectors.sqla.models import (
RLSFilterTables,
@@ -161,6 +161,9 @@ class DatasetDAO(BaseDAO[SqlaTable]):
Slice.datasource_id.in_(dataset_ids),
Slice.datasource_type == DatasourceType.TABLE,
)
+ # Eager-load the related dashboards so callers (e.g. lineage) can
+ # iterate ``chart.dashboards`` without triggering a query per chart.
+ .options(selectinload(Slice.dashboards))
.all()
)
chart_ids = [chart.id for chart in charts]
@@ -170,6 +173,9 @@ class DatasetDAO(BaseDAO[SqlaTable]):
db.session.query(Dashboard)
.join(Dashboard.slices)
.filter(Slice.id.in_(chart_ids))
+ # Eager-load slices so callers can iterate ``dashboard.slices``
+ # without a query per dashboard.
+ .options(selectinload(Dashboard.slices))
)
.distinct()
.all()
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index e2335c2d868..473947d7884 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -158,6 +158,7 @@ from superset.dashboards.schemas import (
DashboardExportXlsxPostSchema,
DashboardExportXlsxResponseSchema,
DashboardGetResponseSchema,
+ DashboardLineageResponseSchema,
DashboardNativeFiltersConfigUpdateSchema,
DashboardPostSchema,
DashboardPutSchema,
@@ -456,6 +457,7 @@ class DashboardRestApi(
"get_version",
"activity",
"restore_version",
+ "lineage",
}
resource_name = "dashboard"
allow_browser_login = True
@@ -698,9 +700,11 @@ class DashboardRestApi(
DashboardCacheScreenshotResponseSchema,
DashboardCopySchema,
DashboardGetResponseSchema,
+ DashboardLineageResponseSchema,
DashboardDatasetSchema,
DashboardExportXlsxPostSchema,
DashboardExportXlsxResponseSchema,
+ DashboardLineageResponseSchema,
TabsPayloadSchema,
GetFavStarIdsSchema,
EmbeddedDashboardResponseSchema,
@@ -820,6 +824,126 @@ class DashboardRestApi(
current_entity_etag_uuid(Dashboard, dash.id, dash.uuid),
)
+ @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: list[dict[str, Any]] = []
+ dataset_map: dict[int, dict[str, Any]] = {}
+ database_map: dict[int, dict[str, Any]] = {}
+
+ 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. Schema/table/database details are
+ # only exposed to users who can access the underlying datasource;
+ # otherwise they are redacted so lineage never leaks datasource
+ # internals (the dataset id/name are kept so the graph still
+ # renders).
+ dataset = chart.datasource
+ if dataset and dataset.id not in dataset_map:
+ can_access = security_manager.can_access_datasource(dataset)
+ dataset_map[dataset.id] = {
+ "id": dataset.id,
+ "name": dataset.name,
+ "database_id": dataset.database_id if can_access else None,
+ "database_name": (
+ dataset.database.database_name
+ if can_access and dataset.database
+ else None
+ ),
+ "schema": dataset.schema if can_access else None,
+ "table_name": dataset.table_name if can_access else None,
+ "chart_ids": [],
+ }
+
+ if dataset and dataset.id in dataset_map:
+ dataset_map[dataset.id]["chart_ids"].append(chart.id)
+
+ # Collect database information, only for accessible datasources
+ if (
+ dataset
+ and security_manager.can_access_datasource(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 409240d2eae..c241bb72713 100644
--- a/superset/dashboards/schemas.py
+++ b/superset/dashboards/schemas.py
@@ -721,3 +721,62 @@ class DashboardExportXlsxResponseSchema(Schema):
job_id = fields.String(
metadata={"description": "Correlation id for the async export task"}
)
+
+
+class DashboardLineageDashboardSchema(Schema):
+ id = fields.Integer()
+ title = fields.String()
+ slug = fields.String(allow_none=True)
+ 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/schema/table details are redacted to ``None`` when the user
+ # cannot access the underlying datasource, so they must be nullable.
+ database_id = fields.Integer(allow_none=True)
+ database_name = fields.String(allow_none=True)
+ schema = fields.String(allow_none=True)
+ table_name = fields.String(allow_none=True)
+ 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 4fb8c333ca4..65d24fd2a73 100644
--- a/superset/datasets/api.py
+++ b/superset/datasets/api.py
@@ -90,6 +90,7 @@ from superset.datasets.schemas import (
DatasetCacheWarmUpResponseSchema,
DatasetDrillInfoSchema,
DatasetDuplicateSchema,
+ DatasetLineageResponseSchema,
DatasetPostSchema,
DatasetPurgeImpactSchema,
DatasetPurgeRequestSchema,
@@ -217,6 +218,7 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
"get_version",
"activity",
"restore_version",
+ "lineage",
}
list_columns = [
"id",
@@ -450,6 +452,7 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
DatasetDuplicateSchema,
GetOrCreateDatasetSchema,
VersionListItemSchema,
+ DatasetLineageResponseSchema,
)
openapi_spec_methods = openapi_spec_methods_override
@@ -1380,6 +1383,130 @@ class DatasetRestApi(SoftDeleteApiMixin, BaseSupersetModelRestApi):
},
}
+ @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: dict[str, Any] = {
+ "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: dict[str, Any] = DatasetDAO.get_related_objects(dataset.id)
+
+ # Build chart information with dashboard IDs, filtering both the charts
+ # and their linked dashboards by the current user's permissions so
+ # lineage never exposes assets the user cannot access.
+ charts: list[dict[str, Any]] = []
+ for chart in related_data["charts"]:
+ if not security_manager.can_access_chart(chart):
+ continue
+ dashboard_ids: list[int] = [
+ d.id
+ for d in chart.dashboards
+ if security_manager.can_access_dashboard(d)
+ ]
+ 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: list[dict[str, Any]] = []
+ for dashboard in related_data["dashboards"]:
+ if not security_manager.can_access_dashboard(dashboard):
+ continue
+ chart_ids: list[int] = [
+ chart.id
+ for chart in dashboard.slices
+ if chart.datasource_id == dataset.id
+ and security_manager.can_access_chart(chart)
+ ]
+ dashboards.append(
+ {
+ "id": dashboard.id,
+ "title": dashboard.dashboard_title,
+ "slug": dashboard.slug,
+ "chart_ids": chart_ids,
+ }
+ )
+
+ downstream: dict[str, Any] = {
+ "charts": {
+ "count": len(charts),
+ "result": charts,
+ },
+ "dashboards": {
+ "count": len(dashboards),
+ "result": dashboards,
+ },
+ }
+
+ result: dict[str, Any] = {
+ "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 163776b818a..b271719433c 100644
--- a/superset/datasets/schemas.py
+++ b/superset/datasets/schemas.py
@@ -366,6 +366,60 @@ class DatasetPurgeImpactSchema(Schema):
)
+class DatasetLineageDatasetSchema(Schema):
+ id = fields.Integer()
+ name = fields.String()
+ database_id = fields.Integer()
+ database_name = fields.String(allow_none=True)
+ 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(allow_none=True)
+ 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/superset/translations/messages.pot b/superset/translations/messages.pot
index 3197a2c18b9..3bbe4f12f54 100644
--- a/superset/translations/messages.pot
+++ b/superset/translations/messages.pot
@@ -279,6 +279,10 @@ msgstr ""
msgid "%s (copy from %s)"
msgstr ""
+#, python-format
+msgid "%s Details"
+msgstr ""
+
#, python-format
msgid "%s ENCRYPTED EXTRA"
msgstr ""
@@ -3105,6 +3109,15 @@ msgstr ""
msgid "Chart %(id)s not found"
msgstr ""
+msgid "Chart (Current)"
+msgstr ""
+
+msgid "Chart (Downstream)"
+msgstr ""
+
+msgid "Chart (Upstream)"
+msgstr ""
+
#, python-format
msgid "Chart Data: %s"
msgstr ""
@@ -3118,6 +3131,9 @@ msgstr[1] ""
msgid "Chart ID"
msgstr ""
+msgid "Chart Lineage"
+msgstr ""
+
msgid "Chart Options"
msgstr ""
@@ -4529,12 +4545,21 @@ msgstr ""
msgid "Dashboard %(dashboard_id)s not found"
msgstr ""
+msgid "Dashboard (Current)"
+msgstr ""
+
+msgid "Dashboard (Downstream)"
+msgstr ""
+
msgid "Dashboard Filter"
msgstr ""
msgid "Dashboard Id"
msgstr ""
+msgid "Dashboard Lineage"
+msgstr ""
+
#, python-format
msgid "Dashboard [%s] just got created and chart [%s] was added to it"
msgstr ""
@@ -4702,6 +4727,9 @@ msgstr ""
msgid "Database"
msgstr ""
+msgid "Database (Upstream)"
+msgstr ""
+
msgid "Database Connections"
msgstr ""
@@ -4787,6 +4815,15 @@ msgstr ""
msgid "Dataset %(table)s already exists"
msgstr ""
+msgid "Dataset (Current)"
+msgstr ""
+
+msgid "Dataset (Upstream)"
+msgstr ""
+
+msgid "Dataset Lineage"
+msgstr ""
+
msgid "Dataset Name"
msgstr ""
@@ -6671,6 +6708,9 @@ msgstr ""
msgid "Failed to load drill-to-detail rows"
msgstr ""
+msgid "Failed to load lineage data"
+msgstr ""
+
msgid "Failed to load results"
msgstr ""
@@ -7478,6 +7518,9 @@ msgstr ""
msgid "Huge"
msgstr ""
+msgid "ID"
+msgstr ""
+
msgid "ISO 3166-2"
msgstr ""
@@ -8413,6 +8456,12 @@ msgstr ""
msgid "Line width unit"
msgstr ""
+msgid "Lineage"
+msgstr ""
+
+msgid "Lineage is available after the dataset is saved"
+msgstr ""
+
msgid "Linear"
msgstr ""
@@ -9468,6 +9517,9 @@ msgstr ""
msgid "No items"
msgstr ""
+msgid "No lineage data available"
+msgstr ""
+
msgid "No matching records found"
msgstr ""
@@ -9911,6 +9963,9 @@ msgstr ""
msgid "Opacity, expects values between 0 and 100"
msgstr ""
+msgid "Open"
+msgstr ""
+
msgid "Open Datasource tab"
msgstr ""
@@ -16328,6 +16383,9 @@ msgstr ""
msgid "View Dataset"
msgstr ""
+msgid "View Lineage"
+msgstr ""
+
msgid "View all charts"
msgstr ""
@@ -16337,6 +16395,9 @@ msgstr ""
msgid "View in SQL Lab"
msgstr ""
+msgid "View lineage"
+msgstr ""
+
msgid "View query"
msgstr ""
diff --git a/tests/integration_tests/charts/api_tests.py b/tests/integration_tests/charts/api_tests.py
index 3a7cedb8c28..1adc38e9b10 100644
--- a/tests/integration_tests/charts/api_tests.py
+++ b/tests/integration_tests/charts/api_tests.py
@@ -61,6 +61,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
@@ -70,6 +71,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,
@@ -2980,3 +2985,30 @@ class TestChartApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCase):
self.login(ADMIN_USERNAME)
rv = self.client.get("api/v1/chart/related/editors")
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"))
+
+ # The lineage payload is wrapped under "result"
+ assert data["result"] == 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 c396af1d663..429ffe125d5 100644
--- a/tests/integration_tests/dashboards/api_tests.py
+++ b/tests/integration_tests/dashboards/api_tests.py
@@ -109,6 +109,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
@@ -8122,6 +8127,33 @@ class TestDashboardApi(ApiEditorsTestCaseMixin, InsertChartMixin, SupersetTestCa
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"))
+
+ # The lineage payload is wrapped under "result"
+ assert data["result"] == 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 4844193cb29..9da7533e3fd 100644
--- a/tests/integration_tests/datasets/api_tests.py
+++ b/tests/integration_tests/datasets/api_tests.py
@@ -65,6 +65,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
@@ -74,6 +75,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
+)
# Fields the dataset ``show`` payload exposes but the ``PUT`` schema doesn't
# accept: audit timestamps plus attributes derived from the model (type
@@ -385,8 +390,8 @@ class TestDatasetApi(SupersetTestCase):
"""
Dataset API: Test get dataset list with invalid JWT authentication
"""
- client = self.create_app().test_client()
- rv = client.get(
+ test_client = self.create_app().test_client()
+ rv = test_client.get(
"api/v1/dataset/",
headers={"Authorization": "Bearer not-a-token"},
)
@@ -4023,6 +4028,33 @@ class TestDatasetApi(SupersetTestCase):
self.items_to_delete = [dash, chart, dataset, dashboard_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"))
+
+ # The lineage payload is wrapped under "result"
+ assert data["result"] == 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
+
class TestRequiresJsonReturns400(SupersetTestCase):
"""sc-120966: a body-less POST to a @safe + @requires_json endpoint
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,
+ },
+ }