Compare commits

...

4 Commits

Author SHA1 Message Date
Evan
766b4a8014 test(lineage): add coverage for the skip parameter in api resource hooks
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:54:54 -07:00
Evan
f8f4010ca5 test(lineage): add unit tests for lineage API hooks
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:44:41 -07:00
Evan
4326563a22 fix(lineage): address review feedback (i18n, permissions, node identity)
- Wrap untranslated "Details" JSX text in t() (fixes pre-commit custom
  rules + babel-extract regression)
- Filter dataset/chart/dashboard lineage endpoints by the current user's
  permissions so they never expose charts, dashboards, or datasource
  metadata the user cannot access (mirrors existing related_objects /
  get_datasets redaction patterns)
- Register DashboardLineageResponseSchema in openapi_spec_component_schemas
  to resolve the dangling $ref in the generated API spec
- Key Sankey graph nodes and the node-details map by a stable unique
  identity (type:id) so entities sharing a display name no longer collapse
  into a single node; keep the human-readable title as a separate label
- Add a skip mechanism to useApiV1Resource / lineage hooks so empty-id
  callers (e.g. LineageModal) no longer fire requests against invalid
  endpoints like /api/v1/chart//lineage
- Defer the dataset edit page lineage fetch until the Lineage tab is active
- Make "View lineage" available in dashboard view mode, not only edit mode
- Compare data["result"] in lineage integration tests to match the
  result-wrapped API contract

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:57:22 -07:00
Claude Code
23123ce6d7 feat(lineage): add lineage visualization across datasets, charts and dashboards
Adopts #37701 by @qf-jonathan. Adds interactive Sankey-based lineage that shows
the data relationships for a dataset, chart, or dashboard: three REST endpoints
(/api/v1/lineage/{dataset|chart|dashboard}/<id>) plus a LineageView/LineageModal
frontend surfaced from the dataset editor and the chart/dashboard menus.

Rebased onto current master and updated for drift since the original branch:
- repointed lineage imports from the removed '@apache-superset/core/ui' to
  '@apache-superset/core/translation' and '/theme'
- re-applied the dataset lineage tab onto the migrated (TypeScript) DatasourceEditor

Closes #37701

Co-authored-by: Jonathan Alberth Quispe Fuentes <qf.jonathan@gmail.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:19:59 -07:00
26 changed files with 2275 additions and 102 deletions

View File

@@ -89,6 +89,8 @@ import {
import { DatabaseSelector } from '../../../DatabaseSelector';
import CollectionTable from '../CollectionTable';
import Fieldset from '../Fieldset';
import { useDatasetLineage } from 'src/hooks/apiResources';
import { LineageView } from 'src/features/lineage';
import Field from '../Field';
import { fetchSyncedColumns, updateColumns } from '../../utils';
import DatasetUsageTab from './components/DatasetUsageTab';
@@ -425,6 +427,16 @@ const StyledTableTabWrapper = styled.div`
}
`;
// Functional wrapper for the lineage tab, since hooks can't be used directly in
// the DatasourceEditor class component.
function DatasetLineageTab({ datasourceId }: { datasourceId?: number }) {
const lineageResource = useDatasetLineage(datasourceId ?? 0);
if (!datasourceId) {
return <Loading />;
}
return <LineageView lineageResource={lineageResource} entityType="dataset" />;
}
const DefaultColumnSettingsContainer = styled.div`
${({ theme }) => css`
margin-bottom: ${theme.sizeUnit * 4}px;
@@ -477,6 +489,7 @@ const TABS_KEYS = {
COLUMNS: 'COLUMNS',
CALCULATED_COLUMNS: 'CALCULATED_COLUMNS',
USAGE: 'USAGE',
LINEAGE: 'LINEAGE',
FOLDERS: 'FOLDERS',
SETTINGS: 'SETTINGS',
SPATIAL: 'SPATIAL',
@@ -2515,6 +2528,15 @@ class DatasourceEditor extends PureComponent<
</StyledTableTabWrapper>
),
},
{
key: TABS_KEYS.LINEAGE,
label: t('Lineage'),
children: (
<StyledTableTabWrapper>
<DatasetLineageTab datasourceId={datasource.id} />
</StyledTableTabWrapper>
),
},
...(isFeatureEnabled(FeatureFlag.DatasetFolders)
? [
{

View File

@@ -37,6 +37,7 @@ import { getUrlParam } from 'src/utils/urlUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
import { usePermissions } from 'src/hooks/usePermissions';
import { LineageModal } from 'src/features/lineage';
export const useHeaderActionsMenu = ({
customCss,
@@ -234,6 +235,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({

View File

@@ -393,4 +393,5 @@ export enum MenuKeys {
ManageEmailReports = 'manage_email_reports',
ExportPivotXlsx = 'export_pivot_xlsx',
EmbedCode = 'embed_code',
ViewLineage = 'view_lineage',
}

View File

@@ -72,6 +72,7 @@ import { ReportObject } from 'src/features/reports/types';
import ViewQueryModal from '../controls/ViewQueryModal';
import EmbedCodeContent from '../EmbedCodeContent';
import { useDashboardsMenuItems } from './DashboardsSubMenu';
import { LineageModal } from 'src/features/lineage';
export const SEARCH_THRESHOLD = 10;
@@ -102,6 +103,7 @@ const MENU_KEYS = {
EDIT_REPORT: 'edit_report',
DELETE_REPORT: 'delete_report',
VIEW_QUERY: 'view_query',
VIEW_LINEAGE: 'view_lineage',
RUN_IN_SQL_LAB: 'run_in_sql_lab',
};
@@ -1028,6 +1030,23 @@ export const useExploreAdditionalActionsMenu = (
onClick: () => setIsDropdownVisible(false),
});
// View lineage
if (slice?.slice_id) {
menuItems.push({
key: MENU_KEYS.VIEW_LINEAGE,
label: (
<LineageModal
entityType="chart"
entityId={slice.slice_id}
triggerNode={
<div data-test="view-lineage-menu-item">{t('View lineage')}</div>
}
/>
),
onClick: () => setIsDropdownVisible(false),
});
}
// Run in SQL Lab
if (datasource) {
menuItems.push({

View File

@@ -36,6 +36,7 @@ import { handleChartDelete, CardStyles } from 'src/views/CRUD/utils';
import { assetUrl } from 'src/utils/assetUrl';
import type { ListViewFetchDataConfig as FetchDataConfig } from 'src/components';
import { TableTab } from 'src/views/CRUD/types';
import { LineageModal } from 'src/features/lineage';
interface ChartCardProps {
chart: Chart;
@@ -76,6 +77,7 @@ export default function ChartCard({
const canEdit = hasPerm('can_write');
const canDelete = hasPerm('can_write');
const canExport = hasPerm('can_export');
const canRead = hasPerm('can_read');
const menuItems: MenuItem[] = [];
if (canEdit) {
@@ -100,6 +102,29 @@ export default function ChartCard({
});
}
if (canRead) {
menuItems.push({
key: 'lineage',
label: (
<LineageModal
entityType="chart"
entityId={chart.id}
triggerNode={
<div>
<Icons.ShareAltOutlined
iconSize="l"
css={css`
vertical-align: text-top;
`}
/>{' '}
{t('View Lineage')}
</div>
}
/>
),
});
}
if (canExport) {
menuItems.push({
key: 'export',
@@ -167,54 +192,59 @@ export default function ChartCard({
}
return (
<CardStyles
onClick={() => {
if (!bulkSelectEnabled && chart.url) {
history.push(chart.url);
}
}}
>
<ListViewCard
loading={loading}
title={chart.slice_name}
certifiedBy={chart.certified_by}
certificationDetails={chart.certification_details}
cover={
!isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? (
<></>
) : null
}
url={bulkSelectEnabled ? undefined : chart.url}
imgURL={chart.thumbnail_url || ''}
imgFallbackURL={assetUrl(
'/static/assets/images/chart-card-fallback.svg',
)}
description={t('Modified %s', chart.changed_on_delta_humanized)}
coverLeft={<FacePile users={chart.owners || []} />}
coverRight={<Label>{chart.datasource_name_text}</Label>}
linkComponent={Link}
actions={
<ListViewCard.Actions
onClick={e => {
e.stopPropagation();
e.preventDefault();
}}
>
{userId && (
<FaveStar
itemId={chart.id}
saveFaveStar={saveFavoriteStatus}
isStarred={favoriteStatus}
/>
)}
<Dropdown menu={{ items: menuItems }} trigger={['click', 'hover']}>
<Button buttonSize="xsmall" type="link" buttonStyle="link">
<Icons.MoreOutlined iconSize="xl" />
</Button>
</Dropdown>
</ListViewCard.Actions>
}
/>
</CardStyles>
<>
<CardStyles
onClick={() => {
if (!bulkSelectEnabled && chart.url) {
history.push(chart.url);
}
}}
>
<ListViewCard
loading={loading}
title={chart.slice_name}
certifiedBy={chart.certified_by}
certificationDetails={chart.certification_details}
cover={
!isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? (
<></>
) : null
}
url={bulkSelectEnabled ? undefined : chart.url}
imgURL={chart.thumbnail_url || ''}
imgFallbackURL={assetUrl(
'/static/assets/images/chart-card-fallback.svg',
)}
description={t('Modified %s', chart.changed_on_delta_humanized)}
coverLeft={<FacePile users={chart.owners || []} />}
coverRight={<Label>{chart.datasource_name_text}</Label>}
linkComponent={Link}
actions={
<ListViewCard.Actions
onClick={e => {
e.stopPropagation();
e.preventDefault();
}}
>
{userId && (
<FaveStar
itemId={chart.id}
saveFaveStar={saveFavoriteStatus}
isStarred={favoriteStatus}
/>
)}
<Dropdown
menu={{ items: menuItems }}
trigger={['click', 'hover']}
>
<Button buttonSize="xsmall" type="link" buttonStyle="link">
<Icons.MoreOutlined iconSize="xl" />
</Button>
</Dropdown>
</ListViewCard.Actions>
}
/>
</CardStyles>
</>
);
}

View File

@@ -37,6 +37,7 @@ import { Icons } from '@superset-ui/core/components/Icons';
import { Dashboard } from 'src/views/CRUD/types';
import { assetUrl } from 'src/utils/assetUrl';
import { FacePile } from 'src/components';
import { LineageModal } from 'src/features/lineage';
interface DashboardCardProps {
isChart?: boolean;
@@ -69,6 +70,7 @@ function DashboardCard({
const canEdit = hasPerm('can_write');
const canDelete = hasPerm('can_write');
const canExport = hasPerm('can_export');
const canRead = hasPerm('can_read');
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const [fetchingThumbnail, setFetchingThumbnail] = useState<boolean>(false);
@@ -99,6 +101,23 @@ function DashboardCard({
const menuItems: MenuItem[] = [];
if (canRead) {
menuItems.push({
key: 'lineage',
label: (
<LineageModal
entityType="dashboard"
entityId={dashboard.id}
triggerNode={
<div data-test="dashboard-card-option-lineage-button">
<Icons.ShareAltOutlined iconSize="l" /> {t('View Lineage')}
</div>
}
/>
),
});
}
if (canEdit && openDashboardEditModal) {
menuItems.push({
key: 'edit',
@@ -151,55 +170,60 @@ function DashboardCard({
}
return (
<CardStyles
onClick={() => {
if (!bulkSelectEnabled) {
history.push(dashboard.url);
}
}}
>
<ListViewCard
loading={dashboard.loading || false}
title={dashboard.dashboard_title}
certifiedBy={dashboard.certified_by}
certificationDetails={dashboard.certification_details}
titleRight={<PublishedLabel isPublished={dashboard.published} />}
cover={
!isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? (
<></>
) : null
}
url={bulkSelectEnabled ? undefined : dashboard.url}
linkComponent={Link}
imgURL={thumbnailUrl}
imgFallbackURL={assetUrl(
'/static/assets/images/dashboard-card-fallback.svg',
)}
description={t('Modified %s', dashboard.changed_on_delta_humanized)}
coverLeft={<FacePile users={dashboard.owners || []} />}
actions={
<ListViewCard.Actions
onClick={e => {
e.stopPropagation();
e.preventDefault();
}}
>
{userId && (
<FaveStar
itemId={dashboard.id}
saveFaveStar={saveFavoriteStatus}
isStarred={favoriteStatus}
/>
)}
<Dropdown menu={{ items: menuItems }} trigger={['hover', 'click']}>
<Button buttonSize="xsmall" buttonStyle="link">
<Icons.MoreOutlined iconSize="xl" />
</Button>
</Dropdown>
</ListViewCard.Actions>
}
/>
</CardStyles>
<>
<CardStyles
onClick={() => {
if (!bulkSelectEnabled) {
history.push(dashboard.url);
}
}}
>
<ListViewCard
loading={dashboard.loading || false}
title={dashboard.dashboard_title}
certifiedBy={dashboard.certified_by}
certificationDetails={dashboard.certification_details}
titleRight={<PublishedLabel isPublished={dashboard.published} />}
cover={
!isFeatureEnabled(FeatureFlag.Thumbnails) || !showThumbnails ? (
<></>
) : null
}
url={bulkSelectEnabled ? undefined : dashboard.url}
linkComponent={Link}
imgURL={thumbnailUrl}
imgFallbackURL={assetUrl(
'/static/assets/images/dashboard-card-fallback.svg',
)}
description={t('Modified %s', dashboard.changed_on_delta_humanized)}
coverLeft={<FacePile users={dashboard.owners || []} />}
actions={
<ListViewCard.Actions
onClick={e => {
e.stopPropagation();
e.preventDefault();
}}
>
{userId && (
<FaveStar
itemId={dashboard.id}
saveFaveStar={saveFavoriteStatus}
isStarred={favoriteStatus}
/>
)}
<Dropdown
menu={{ items: menuItems }}
trigger={['hover', 'click']}
>
<Button buttonSize="xsmall" buttonStyle="link">
<Icons.MoreOutlined iconSize="xl" />
</Button>
</Dropdown>
</ListViewCard.Actions>
}
/>
</CardStyles>
</>
);
}

View File

@@ -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;

View File

@@ -0,0 +1,79 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, ReactNode } from 'react';
import { t } from '@apache-superset/core/translation';
import { ModalTrigger } from '@superset-ui/core/components';
import {
useChartLineage,
useDashboardLineage,
useDatasetLineage,
} from 'src/hooks/apiResources';
import LineageView from './LineageView';
export interface LineageModalProps {
entityType: 'dataset' | 'chart' | 'dashboard';
entityId: string | number;
triggerNode: ReactNode;
}
const LineageModal: FC<LineageModalProps> = ({
entityType,
entityId,
triggerNode,
}) => {
const datasetLineage = useDatasetLineage(
entityType === 'dataset' ? entityId : '',
);
const chartLineage = useChartLineage(entityType === 'chart' ? entityId : '');
const dashboardLineage = useDashboardLineage(
entityType === 'dashboard' ? entityId : '',
);
const lineageResource =
entityType === 'dataset'
? datasetLineage
: entityType === 'chart'
? chartLineage
: dashboardLineage;
const title =
entityType === 'dataset'
? t('Dataset Lineage')
: entityType === 'chart'
? t('Chart Lineage')
: t('Dashboard Lineage');
return (
<ModalTrigger
triggerNode={triggerNode}
modalTitle={title}
modalBody={
<LineageView
lineageResource={lineageResource}
entityType={entityType}
/>
}
width="850px"
responsive
destroyOnHidden
/>
);
};
export default LineageModal;

View File

@@ -0,0 +1,728 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FC, useMemo, useState, useCallback } from 'react';
import { t } from '@apache-superset/core/translation';
import { styled, useTheme } from '@apache-superset/core/theme';
import { Empty, Loading } from '@superset-ui/core/components';
import { Button } from '@superset-ui/core/components';
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
import type { Resource } from 'src/hooks/apiResources/apiResources';
import type {
DatasetLineage,
ChartLineage,
DashboardLineage,
ChartEntity,
DashboardEntity,
DatasetEntity,
DatabaseEntity,
} from 'src/hooks/apiResources/lineage';
import Echart from '../../../plugins/plugin-chart-echarts/src/components/Echart';
import type { EChartsCoreOption } from 'echarts/core';
const LineageContainer = styled.div`
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
`;
const Legend = styled.div`
${({ theme }) => `
display: flex;
justify-content: center;
align-items: center;
gap: ${theme.sizeUnit * 4}px;
padding: ${theme.sizeUnit * 3}px;
background-color: ${theme.colorBgLayout};
border-bottom: 1px solid ${theme.colorBorder};
`}
`;
const LegendItem = styled.div<{ color: string }>`
${({ theme, color }) => `
display: flex;
align-items: center;
gap: ${theme.sizeUnit * 2}px;
font-size: ${theme.fontSizeSM}px;
color: ${theme.colorText};
&::before {
content: '';
width: 12px;
height: 12px;
border-radius: 2px;
background-color: ${color};
}
`}
`;
const DetailsPanel = styled.div`
${({ theme }) => `
padding: ${theme.sizeUnit * 4}px;
background-color: ${theme.colorBgLayout};
border-top: 1px solid ${theme.colorBorder};
min-height: 120px;
`}
`;
const DetailsPanelHeader = styled.div`
${({ theme }) => `
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: ${theme.sizeUnit * 3}px;
`}
`;
const DetailsPanelActions = styled.div`
${({ theme }) => `
display: flex;
gap: ${theme.sizeUnit * 2}px;
`}
`;
const DetailsPanelTitle = styled.h4`
${({ theme }) => `
margin: 0;
font-size: ${theme.fontSizeLG}px;
font-weight: ${theme.fontWeightStrong};
color: ${theme.colorText};
`}
`;
const DetailsPanelContent = styled.div`
${({ theme }) => `
display: flex;
flex-direction: column;
gap: ${theme.sizeUnit * 2}px;
`}
`;
const DetailRow = styled.div`
${({ theme }) => `
display: flex;
gap: ${theme.sizeUnit * 2}px;
font-size: ${theme.fontSizeSM}px;
color: ${theme.colorText};
`}
`;
const DetailLabel = styled.span`
${({ theme }) => `
font-weight: ${theme.fontWeightStrong};
min-width: 100px;
`}
`;
const DetailValue = styled.span`
${({ theme }) => `
color: ${theme.colorTextSecondary};
`}
`;
type NodeType = 'database' | 'dataset' | 'chart' | 'dashboard';
type NodeDetails = {
name: string;
type: NodeType;
id?: number;
additionalInfo?: Record<string, any>;
};
// 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 ?? ''}`;
type LineageViewProps = {
lineageResource:
| Resource<DatasetLineage>
| Resource<ChartLineage>
| Resource<DashboardLineage>;
entityType: 'dataset' | 'chart' | 'dashboard';
};
const LineageView: FC<LineageViewProps> = ({ lineageResource, entityType }) => {
const theme = useTheme();
const [selectedNode, setSelectedNode] = useState<NodeDetails | null>(null);
// Create a mapping of node names to their details
const nodeDetailsMap = useMemo(() => {
if (
lineageResource.status !== ResourceStatus.Complete ||
!lineageResource.result
) {
return new Map<string, NodeDetails>();
}
const data = lineageResource.result;
const map = new Map<string, NodeDetails>();
if (entityType === 'dataset' && 'dataset' in data) {
const { dataset, upstream, downstream } = data as DatasetLineage;
// Add current dataset
map.set(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: any) => {
if (params.dataType === 'node') {
const nodeName = params.name;
const nodeDetails = nodeDetailsMap.get(nodeName);
if (nodeDetails) {
setSelectedNode(nodeDetails);
}
}
// Always stop event propagation to prevent tooltip issues
if (params.event) {
params.event.stop();
}
},
[nodeDetailsMap],
);
const echartOptions: EChartsCoreOption | null = useMemo(() => {
if (
lineageResource.status !== ResourceStatus.Complete ||
!lineageResource.result
) {
return null;
}
const data = lineageResource.result;
const nodes: {
name: string;
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
if (dashboard.chart_ids && dashboard.chart_ids.length > 0) {
dashboard.chart_ids.forEach(chartId => {
const chartKey = chartKeys.get(chartId);
if (chartKey) {
addLink(chartKey, 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: 'Database (Upstream)', color: theme.colorInfo },
{ label: 'Dataset (Current)', color: theme.colorPrimary },
{ label: 'Chart (Downstream)', color: theme.colorSuccess },
{ label: 'Dashboard (Downstream)', color: theme.colorWarning },
];
} else if (entityType === 'chart') {
return [
{ label: 'Database (Upstream)', color: theme.colorWarning },
{ label: 'Dataset (Upstream)', color: theme.colorInfo },
{ label: 'Chart (Current)', color: theme.colorPrimary },
{ label: 'Dashboard (Downstream)', color: theme.colorSuccess },
];
} else if (entityType === 'dashboard') {
return [
{ label: 'Database (Upstream)', color: theme.colorWarning },
{ label: 'Dataset (Upstream)', color: theme.colorSuccess },
{ label: 'Chart (Upstream)', color: theme.colorInfo },
{ label: 'Dashboard (Current)', color: theme.colorPrimary },
];
}
return [];
}, [entityType, theme]);
if (lineageResource.status === ResourceStatus.Loading) {
return <Loading />;
}
if (
lineageResource.status === ResourceStatus.Error ||
!lineageResource.result
) {
return <Empty description={t('Failed to load lineage data')} />;
}
if (!echartOptions) {
return <Empty description={t('No lineage data available')} />;
}
// Helper function to get the URL for an entity
const getEntityUrl = (nodeDetails: NodeDetails): string => {
switch (nodeDetails.type) {
case 'dashboard':
return `/superset/dashboard/${nodeDetails.id}/`;
case 'chart':
return `/explore/?slice_id=${nodeDetails.id}`;
case 'dataset':
return `/dataset/${nodeDetails.id}`;
default:
return '#';
}
};
return (
<LineageContainer>
<Legend>
{legendItems.map(item => (
<LegendItem key={item.label} color={item.color}>
{item.label}
</LegendItem>
))}
</Legend>
<Echart
refs={{}}
height={selectedNode ? 450 : 600}
width={800}
echartOptions={echartOptions}
vizType="sankey"
eventHandlers={{
click: handleNodeClick,
}}
/>
{selectedNode && (
<DetailsPanel>
<DetailsPanelHeader>
<DetailsPanelTitle>
{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={() => {
window.location.href = getEntityUrl(selectedNode);
}}
>
{t('Open')}{' '}
{selectedNode.type.charAt(0).toUpperCase() +
selectedNode.type.slice(1)}
</Button>
)}
<Button
buttonStyle="tertiary"
buttonSize="small"
onClick={() => setSelectedNode(null)}
>
{t('Close')}
</Button>
</DetailsPanelActions>
</DetailsPanelHeader>
<DetailsPanelContent>
<DetailRow>
<DetailLabel>{t('Name')}:</DetailLabel>
<DetailValue>{selectedNode.name}</DetailValue>
</DetailRow>
{selectedNode.id && (
<DetailRow>
<DetailLabel>{t('ID')}:</DetailLabel>
<DetailValue>{selectedNode.id}</DetailValue>
</DetailRow>
)}
{selectedNode.additionalInfo &&
Object.entries(selectedNode.additionalInfo).map(
([key, value]) => (
<DetailRow key={key}>
<DetailLabel>
{key.charAt(0).toUpperCase() +
key.slice(1).replace(/_/g, ' ')}
:
</DetailLabel>
<DetailValue>{String(value)}</DetailValue>
</DetailRow>
),
)}
</DetailsPanelContent>
</DetailsPanel>
)}
</LineageContainer>
);
};
export default LineageView;

View File

@@ -0,0 +1,21 @@
/**
* 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.
*/
export { default as LineageView } from './LineageView';
export { default as LineageModal } from './LineageModal';

View File

@@ -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 any).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 any).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

View File

@@ -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,
);
}

View File

@@ -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';

View File

@@ -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();
});

View File

@@ -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),
);

View File

@@ -131,6 +131,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
"screenshot",
"cache_screenshot",
"warm_up_cache",
"lineage",
}
class_permission_name = "Chart"
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
@@ -313,6 +314,107 @@ class ChartRestApi(BaseSupersetModelRestApi):
except ChartNotFoundError:
return self.response_404()
@expose("/<id_or_uuid>/lineage", methods=("GET",))
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.lineage",
log_to_statsd=False,
)
def lineage(self, id_or_uuid: str) -> Response:
"""Get lineage information for a chart.
---
get:
summary: Get lineage information for a chart
description: >-
Returns upstream (dataset, database) and downstream (dashboards) lineage
information for a chart
parameters:
- in: path
name: id_or_uuid
schema:
type: string
description: Either the id of the chart, or its uuid
responses:
200:
description: Lineage information
content:
application/json:
schema:
$ref: "#/components/schemas/ChartLineageResponseSchema"
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
try:
chart = ChartDAO.get_by_id_or_uuid(id_or_uuid)
except ChartNotFoundError:
return self.response_404()
chart_info = {
"id": chart.id,
"slice_name": chart.slice_name,
"viz_type": chart.viz_type,
}
# Get upstream (dataset and database) information
upstream: dict[str, Any] = {}
if dataset := chart.datasource:
upstream["dataset"] = {
"id": dataset.id,
"name": dataset.name,
"database_id": dataset.database_id,
"database_name": dataset.database.database_name
if dataset.database
else None,
"schema": dataset.schema,
"table_name": dataset.table_name,
}
if dataset.database:
upstream["database"] = {
"id": dataset.database.id,
"database_name": dataset.database.database_name,
"backend": dataset.database.backend,
}
else:
upstream["database"] = None
else:
upstream["dataset"] = None
upstream["database"] = None
# Get downstream (dashboards) information, filtered by the current
# user's permissions so lineage never exposes dashboards the user
# cannot access.
dashboards = []
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

View File

@@ -1755,6 +1755,53 @@ class ChartGetResponseSchema(Schema):
datasource_uuid = fields.UUID(attribute="table.uuid")
class ChartLineageChartSchema(Schema):
id = fields.Integer()
slice_name = fields.String()
viz_type = fields.String()
class ChartLineageDatasetSchema(Schema):
id = fields.Integer()
name = fields.String()
database_id = fields.Integer()
database_name = fields.String()
schema = fields.String(allow_none=True)
table_name = fields.String()
class ChartLineageDatabaseSchema(Schema):
id = fields.Integer()
database_name = fields.String()
backend = fields.String()
class ChartLineageDashboardSchema(Schema):
id = fields.Integer()
title = fields.String()
slug = fields.String()
class ChartLineageUpstreamSchema(Schema):
dataset = fields.Nested(ChartLineageDatasetSchema, allow_none=True)
database = fields.Nested(ChartLineageDatabaseSchema, allow_none=True)
class ChartLineageDownstreamDashboardsSchema(Schema):
count = fields.Integer()
result = fields.List(fields.Nested(ChartLineageDashboardSchema))
class ChartLineageDownstreamSchema(Schema):
dashboards = fields.Nested(ChartLineageDownstreamDashboardsSchema)
class ChartLineageResponseSchema(Schema):
chart = fields.Nested(ChartLineageChartSchema)
upstream = fields.Nested(ChartLineageUpstreamSchema)
downstream = fields.Nested(ChartLineageDownstreamSchema)
CHART_SCHEMAS = (
ChartCacheWarmUpRequestSchema,
ChartCacheWarmUpResponseSchema,
@@ -1782,4 +1829,5 @@ CHART_SCHEMAS = (
ChartGetResponseSchema,
ChartCacheScreenshotResponseSchema,
GetFavStarIdsSchema,
ChartLineageResponseSchema,
)

View File

@@ -179,6 +179,7 @@ MODEL_API_RW_METHOD_PERMISSION_MAP = {
"put_colors": "write",
"sync_permissions": "write",
"restore": "write",
"lineage": "read",
}
EXTRA_FORM_DATA_APPEND_KEYS = {

View File

@@ -104,6 +104,7 @@ from superset.dashboards.schemas import (
DashboardCopySchema,
DashboardDatasetSchema,
DashboardGetResponseSchema,
DashboardLineageResponseSchema,
DashboardNativeFiltersConfigUpdateSchema,
DashboardPostSchema,
DashboardPutSchema,
@@ -252,6 +253,7 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
"put_chart_customizations",
"put_colors",
"export_as_example",
"lineage",
}
resource_name = "dashboard"
allow_browser_login = True
@@ -429,6 +431,7 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
DashboardCacheScreenshotResponseSchema,
DashboardCopySchema,
DashboardGetResponseSchema,
DashboardLineageResponseSchema,
DashboardDatasetSchema,
TabsPayloadSchema,
GetFavStarIdsSchema,
@@ -524,6 +527,126 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
)
return self.response(200, result=result)
@expose("/<id_or_slug>/lineage", methods=("GET",))
@protect()
@safe
@statsd_metrics
@with_dashboard
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.lineage",
log_to_statsd=False,
)
# pylint: disable=arguments-differ,arguments-renamed
def lineage(self, dash: Dashboard) -> Response:
"""Get lineage information for a dashboard.
---
get:
summary: Get lineage information for a dashboard
description: >-
Returns upstream (charts, datasets, databases) lineage information
for a dashboard
parameters:
- in: path
name: id_or_slug
schema:
type: string
description: Either the id of the dashboard, or its slug
responses:
200:
description: Lineage information
content:
application/json:
schema:
$ref: "#/components/schemas/DashboardLineageResponseSchema"
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
dashboard_info = {
"id": dash.id,
"title": dash.dashboard_title,
"slug": dash.slug,
"published": dash.published,
}
# Get upstream (charts, datasets, databases) information
charts = []
dataset_map = {}
database_map = {}
for chart in dash.slices:
charts.append(
{
"id": chart.id,
"slice_name": chart.slice_name,
"viz_type": chart.viz_type,
"dataset_id": chart.datasource_id,
}
)
# Collect dataset information. 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

View File

@@ -558,3 +558,60 @@ class CacheScreenshotSchema(Schema):
fields.List(fields.Str(), validate=lambda x: len(x) == 2), required=False
)
permalinkKey = fields.Str(required=False) # noqa: N815
class DashboardLineageDashboardSchema(Schema):
id = fields.Integer()
title = fields.String()
slug = fields.String()
published = fields.Boolean()
class DashboardLineageChartSchema(Schema):
id = fields.Integer()
slice_name = fields.String()
viz_type = fields.String()
dataset_id = fields.Integer()
class DashboardLineageDatasetSchema(Schema):
id = fields.Integer()
name = fields.String()
database_id = fields.Integer()
database_name = fields.String()
schema = fields.String(allow_none=True)
table_name = fields.String()
chart_ids = fields.List(fields.Integer())
class DashboardLineageDatabaseSchema(Schema):
id = fields.Integer()
database_name = fields.String()
backend = fields.String()
class DashboardLineageUpstreamChartsSchema(Schema):
count = fields.Integer()
result = fields.List(fields.Nested(DashboardLineageChartSchema))
class DashboardLineageUpstreamDatasetsSchema(Schema):
count = fields.Integer()
result = fields.List(fields.Nested(DashboardLineageDatasetSchema))
class DashboardLineageUpstreamDatabasesSchema(Schema):
count = fields.Integer()
result = fields.List(fields.Nested(DashboardLineageDatabaseSchema))
class DashboardLineageUpstreamSchema(Schema):
charts = fields.Nested(DashboardLineageUpstreamChartsSchema)
datasets = fields.Nested(DashboardLineageUpstreamDatasetsSchema)
databases = fields.Nested(DashboardLineageUpstreamDatabasesSchema)
class DashboardLineageResponseSchema(Schema):
dashboard = fields.Nested(DashboardLineageDashboardSchema)
upstream = fields.Nested(DashboardLineageUpstreamSchema)
downstream = fields.Field(allow_none=True)

View File

@@ -64,6 +64,7 @@ from superset.datasets.schemas import (
DatasetCacheWarmUpResponseSchema,
DatasetDrillInfoSchema,
DatasetDuplicateSchema,
DatasetLineageResponseSchema,
DatasetPostSchema,
DatasetPutSchema,
DatasetRelatedObjectsResponse,
@@ -111,6 +112,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
"get_or_create_dataset",
"warm_up_cache",
"get_drill_info",
"lineage",
}
list_columns = [
"id",
@@ -299,6 +301,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
DatasetRelatedObjectsResponse,
DatasetDuplicateSchema,
GetOrCreateDatasetSchema,
DatasetLineageResponseSchema,
)
openapi_spec_methods = openapi_spec_methods_override
@@ -846,6 +849,129 @@ class DatasetRestApi(BaseSupersetModelRestApi):
dashboards={"count": len(dashboards), "result": dashboards},
)
@expose("/<id_or_uuid>/lineage", methods=("GET",))
@protect()
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.lineage",
log_to_statsd=False,
)
def lineage(self, id_or_uuid: str) -> Response:
"""Get lineage information for a dataset.
---
get:
summary: Get lineage information for a dataset
description: >-
Returns upstream (database) and downstream (charts, dashboards) lineage
information for a dataset
parameters:
- in: path
name: id_or_uuid
schema:
type: string
description: Either the id of the dataset, or its uuid
responses:
200:
description: Lineage information
content:
application/json:
schema:
$ref: "#/components/schemas/DatasetLineageResponseSchema"
401:
$ref: '#/components/responses/401'
404:
$ref: '#/components/responses/404'
500:
$ref: '#/components/responses/500'
"""
dataset = DatasetDAO.find_by_id_or_uuid(id_or_uuid)
if not dataset:
return self.response_404()
dataset_info = {
"id": dataset.id,
"name": dataset.name,
"database_id": dataset.database_id,
"database_name": (
dataset.database.database_name if dataset.database else None
),
"schema": dataset.schema,
"table_name": dataset.table_name,
}
# Get upstream (database) information
upstream: dict[str, Any] = {}
if dataset.database:
upstream["database"] = {
"id": dataset.database.id,
"database_name": dataset.database.database_name,
"backend": dataset.database.backend,
}
else:
upstream["database"] = None
# Get downstream (charts and dashboards) information
related_data = DatasetDAO.get_related_objects(dataset.id)
# Build chart information with dashboard IDs, filtering both the charts
# and their linked dashboards by the current user's permissions so
# lineage never exposes assets the user cannot access.
charts = []
for chart in related_data["charts"]:
if not security_manager.can_access_chart(chart):
continue
dashboard_ids = [
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 = []
for dashboard in related_data["dashboards"]:
if not security_manager.can_access_dashboard(dashboard):
continue
chart_ids = [
chart.id
for chart in dashboard.slices
if chart.datasource_id == dataset.id
]
dashboards.append(
{
"id": dashboard.id,
"title": dashboard.dashboard_title,
"slug": dashboard.slug,
"chart_ids": chart_ids,
}
)
downstream = {
"charts": {
"count": len(charts),
"result": charts,
},
"dashboards": {
"count": len(dashboards),
"result": dashboards,
},
}
result = {
"dataset": dataset_info,
"upstream": upstream,
"downstream": downstream,
}
return self.response(200, result=result)
@expose("/", methods=("DELETE",))
@protect()
@safe

View File

@@ -250,6 +250,60 @@ class DatasetRelatedObjectsResponse(Schema):
dashboards = fields.Nested(DatasetRelatedDashboards)
class DatasetLineageDatasetSchema(Schema):
id = fields.Integer()
name = fields.String()
database_id = fields.Integer()
database_name = fields.String()
schema = fields.String(allow_none=True)
table_name = fields.String()
class DatasetLineageDatabaseSchema(Schema):
id = fields.Integer()
database_name = fields.String()
backend = fields.String()
class DatasetLineageChartSchema(Schema):
id = fields.Integer()
slice_name = fields.String()
viz_type = fields.String()
dashboard_ids = fields.List(fields.Integer())
class DatasetLineageDashboardSchema(Schema):
id = fields.Integer()
title = fields.String()
slug = fields.String()
chart_ids = fields.List(fields.Integer())
class DatasetLineageUpstreamSchema(Schema):
database = fields.Nested(DatasetLineageDatabaseSchema, allow_none=True)
class DatasetLineageDownstreamChartsSchema(Schema):
count = fields.Integer()
result = fields.List(fields.Nested(DatasetLineageChartSchema))
class DatasetLineageDownstreamDashboardsSchema(Schema):
count = fields.Integer()
result = fields.List(fields.Nested(DatasetLineageDashboardSchema))
class DatasetLineageDownstreamSchema(Schema):
charts = fields.Nested(DatasetLineageDownstreamChartsSchema)
dashboards = fields.Nested(DatasetLineageDownstreamDashboardsSchema)
class DatasetLineageResponseSchema(Schema):
dataset = fields.Nested(DatasetLineageDatasetSchema)
upstream = fields.Nested(DatasetLineageUpstreamSchema)
downstream = fields.Nested(DatasetLineageDownstreamSchema)
class ImportV1ColumnSchema(Schema):
# pylint: disable=unused-argument
@pre_load

View File

@@ -50,6 +50,7 @@ from tests.integration_tests.fixtures.birth_names_dashboard import (
load_birth_names_dashboard_with_slices, # noqa: F401
load_birth_names_data, # noqa: F401
)
from tests.integration_tests.fixtures.client import client # noqa: F401
from tests.integration_tests.fixtures.energy_dashboard import (
load_energy_table_data, # noqa: F401
load_energy_table_with_slice, # noqa: F401
@@ -59,6 +60,10 @@ from tests.integration_tests.fixtures.importexport import (
database_config,
dataset_config,
)
from tests.integration_tests.fixtures.lineage import (
inject_expected_chart_lineage, # noqa: F401
lineage_test_data, # noqa: F401
)
from tests.integration_tests.fixtures.tags import (
create_custom_tags, # noqa: F401
get_filter_params,
@@ -2444,3 +2449,30 @@ class TestChartApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCase):
self.login(ADMIN_USERNAME)
rv = self.client.get("api/v1/chart/related/owners")
assert rv.status_code == 200
@pytest.mark.usefixtures("inject_expected_chart_lineage")
def test_get_chart_lineage(self):
"""
Chart API: Test get chart lineage
"""
self.login(ADMIN_USERNAME)
chart_id = self.chart_lineage["chart_id"]
expected = self.chart_lineage["expected"]
uri = f"api/v1/chart/{chart_id}/lineage"
rv = self.get_assert_metric(uri, "lineage")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
# 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

View File

@@ -66,6 +66,11 @@ from tests.integration_tests.fixtures.birth_names_dashboard import (
load_birth_names_dashboard_with_slices, # noqa: F401
load_birth_names_data, # noqa: F401
)
from tests.integration_tests.fixtures.client import client # noqa: F401
from tests.integration_tests.fixtures.lineage import (
inject_expected_dashboard_lineage, # noqa: F401
lineage_test_data, # noqa: F401
)
from tests.integration_tests.fixtures.world_bank_dashboard import (
load_world_bank_dashboard_with_slices, # noqa: F401
load_world_bank_data, # noqa: F401
@@ -4049,6 +4054,33 @@ class TestDashboardApi(ApiOwnersTestCaseMixin, InsertChartMixin, SupersetTestCas
db.session.delete(dashboard)
db.session.commit()
@pytest.mark.usefixtures("inject_expected_dashboard_lineage")
def test_get_dashboard_lineage(self):
"""
Dashboard API: Test get dashboard lineage
"""
self.login(ADMIN_USERNAME)
dashboard_id = self.dashboard_lineage["dashboard_id"]
expected = self.dashboard_lineage["expected"]
uri = f"api/v1/dashboard/{dashboard_id}/lineage"
rv = self.get_assert_metric(uri, "lineage")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
# 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.

View File

@@ -55,6 +55,7 @@ from tests.integration_tests.fixtures.birth_names_dashboard import (
load_birth_names_dashboard_with_slices, # noqa: F401
load_birth_names_data, # noqa: F401
)
from tests.integration_tests.fixtures.client import client # noqa: F401
from tests.integration_tests.fixtures.energy_dashboard import (
load_energy_table_data, # noqa: F401
load_energy_table_with_slice, # noqa: F401
@@ -64,6 +65,10 @@ from tests.integration_tests.fixtures.importexport import (
dataset_config,
dataset_ui_export,
)
from tests.integration_tests.fixtures.lineage import (
inject_expected_dataset_lineage, # noqa: F401
lineage_test_data, # noqa: F401
)
class TestDatasetApi(SupersetTestCase):
@@ -3555,3 +3560,30 @@ class TestDatasetApi(SupersetTestCase):
assert rv.status_code == 404
self.items_to_delete = [dashboard, chart, dataset]
@pytest.mark.usefixtures("inject_expected_dataset_lineage")
def test_get_dataset_lineage(self):
"""
Dataset API: Test get dataset lineage
"""
self.login(ADMIN_USERNAME)
dataset_id = self.dataset_lineage["dataset_id"]
expected = self.dataset_lineage["expected"]
uri = f"api/v1/dataset/{dataset_id}/lineage"
rv = self.get_assert_metric(uri, "lineage")
assert rv.status_code == 200
data = json.loads(rv.data.decode("utf-8"))
# 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

View File

@@ -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,
},
}