diff --git a/packages/react/src/components/TableNode.tsx b/packages/react/src/components/TableNode.tsx index 21885410..f2d0fefe 100644 --- a/packages/react/src/components/TableNode.tsx +++ b/packages/react/src/components/TableNode.tsx @@ -310,6 +310,11 @@ function TableNodeComponent({ id, data, selected }: NodeProps): JSX.Element { // Use derived selector to avoid new Set reference on each render const isExpanded = useLineageStore((state) => state.expandedTableIds.has(id)); const showColumnEdges = useLineageStore((state) => state.showColumnEdges); + // Track focused occurrence index for nodes with multiple SQL occurrences. + // Only meaningful when this node is selected; defaults to 0 otherwise. + const focusedOccurrenceIndex = useLineageStore((state) => + state.selectedNodeId === id ? state.focusedOccurrenceIndex : 0 + ); const colors = useColors(); const isDark = useIsDarkMode(); @@ -329,6 +334,17 @@ function TableNodeComponent({ id, data, selected }: NodeProps): JSX.Element { const isCollapsed = nodeData.isCollapsed; // isExpanded is now derived directly from the store selector above const hiddenColumnCount = nodeData.hiddenColumnCount || 0; + + // When the same table appears multiple times in the SQL (e.g., self-join or + // multi-statement), occurrenceFilters holds per-occurrence filter lists. + // Show only the filters relevant to the currently focused occurrence so the + // user doesn't see duplicate/combined filters for all occurrences at once. + const activeFilters: typeof nodeData.filters = + nodeData.occurrenceFilters && nodeData.occurrenceFilters.length > 1 + ? (nodeData.occurrenceFilters[focusedOccurrenceIndex] ?? + nodeData.occurrenceFilters[0] ?? + nodeData.filters) + : nodeData.filters; const lineageHiddenColumnCount = nodeData.lineageHiddenColumnCount || 0; const useScrollableColumnList = !showColumnEdges; const shouldVirtualizeColumns = @@ -749,7 +765,7 @@ function TableNodeComponent({ id, data, selected }: NodeProps): JSX.Element { )} )} - {!isCollapsed && nodeData.filters && nodeData.filters.length > 0 && ( + {!isCollapsed && activeFilters && activeFilters.length > 0 && (
- {nodeData.filters.map((filter, index) => ( + {activeFilters.map((filter, index) => (
{ if (prevFilters[i].expression !== nextFilters[i].expression) return false; } + // Check per-occurrence filters (length change is sufficient — expression-level + // changes in individual occurrences are captured by the flat `filters` check above). + const prevOccFilters = prevData.occurrenceFilters; + const nextOccFilters = nextData.occurrenceFilters; + if (prevOccFilters !== nextOccFilters) { + if (!prevOccFilters || !nextOccFilters) return false; + if (prevOccFilters.length !== nextOccFilters.length) return false; + } + return true; }); diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index b1d59b8e..bb9d22cb 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -423,6 +423,13 @@ export interface TableNodeData extends Record { lineageHiddenColumnCount?: number; /** Filter predicates (WHERE/HAVING clauses) affecting this table */ filters?: FilterPredicate[]; + /** + * Per-occurrence filter predicates. Index i corresponds to occurrence i + * (aligned with OccurrenceCycler's focusedOccurrenceIndex). When present, + * TableNode renders only the focused occurrence's filters instead of the + * merged union. Absent for nodes that appear only once. + */ + occurrenceFilters?: FilterPredicate[][]; /** Fully qualified name (e.g., "catalog.schema.table") */ qualifiedName?: string; /** Schema name extracted from qualified name */ diff --git a/packages/react/src/utils/graphBuilders.ts b/packages/react/src/utils/graphBuilders.ts index f71e678e..29807592 100644 --- a/packages/react/src/utils/graphBuilders.ts +++ b/packages/react/src/utils/graphBuilders.ts @@ -9,6 +9,8 @@ import type { import { isTableLikeType, nodesInStatement, edgesInStatement } from '@pondpilot/flowscope-core'; import type { TableNodeData, ColumnNodeInfo, ScriptNodeData } from '../types'; import { GRAPH_CONFIG, UI_CONSTANTS } from '../constants'; +import type { FilterPredicate } from '@pondpilot/flowscope-core'; +import { OCCURRENCE_FILTERS_METADATA_KEY } from './nodeOccurrences'; import { getCreatedRelationNodeIds, OUTPUT_NODE_TYPE, @@ -203,6 +205,28 @@ function processTableColumns( return { columns: existingColumns, hiddenColumnCount }; } +/** + * Deduplicate filter predicates by expression, preserving first occurrence. + * + * The Rust core can emit duplicate filters when the same table is referenced + * multiple times within a single statement (e.g., self-join or two subqueries + * against the same table). Each occurrence contributes its own WHERE predicates + * to node.filters, resulting in N identical entries for N references. This + * function collapses them to unique expressions for display. + */ +function deduplicateFilters(filters: FilterPredicate[] | undefined): FilterPredicate[] | undefined { + if (!filters || filters.length === 0) return filters; + const seen = new Set(); + const result: FilterPredicate[] = []; + for (const f of filters) { + if (!seen.has(f.expression)) { + seen.add(f.expression); + result.push(f); + } + } + return result.length === filters.length ? filters : result; +} + /** * Base options shared by all node data builder functions. */ @@ -254,6 +278,11 @@ function buildTableNodeData( ? [canonical.catalog, canonical.schema, canonical.name].filter(Boolean).join('.') : node.label; + const rawOccurrenceFilters = node.metadata?.[OCCURRENCE_FILTERS_METADATA_KEY]; + const occurrenceFilters = Array.isArray(rawOccurrenceFilters) + ? (rawOccurrenceFilters as import('@pondpilot/flowscope-core').FilterPredicate[][]) + : undefined; + return { label: node.label, nodeType, @@ -269,7 +298,8 @@ function buildTableNodeData( lineageHiddenColumnCount: options.lineageHiddenColumnCount, isRecursive: options.isRecursive, isBaseTable: options.isBaseTable, - filters: node.filters, + filters: deduplicateFilters(node.filters), + occurrenceFilters: occurrenceFilters && occurrenceFilters.length > 1 ? occurrenceFilters : undefined, qualifiedName, schema: canonical?.schema, database: canonical?.catalog, diff --git a/packages/react/src/utils/nodeOccurrences.ts b/packages/react/src/utils/nodeOccurrences.ts index d0fe1712..e329d130 100644 --- a/packages/react/src/utils/nodeOccurrences.ts +++ b/packages/react/src/utils/nodeOccurrences.ts @@ -10,6 +10,7 @@ const BODY_SPANS_METADATA_KEY = 'bodySpans'; const BODY_STATEMENT_IDS_METADATA_KEY = 'bodyStatementIds'; const BODY_SOURCE_NAMES_METADATA_KEY = 'bodySourceNames'; const STATEMENT_AGGREGATIONS_METADATA_KEY = 'statementAggregations'; +export const OCCURRENCE_FILTERS_METADATA_KEY = 'occurrenceFilters'; interface OccurrenceEntry { span: Span; @@ -370,6 +371,16 @@ export function scopeNodeToStatement( }; } +/** + * Read per-occurrence filters stored in node metadata. + * Returns an array indexed by occurrence position, or an empty array if not present. + */ +function readOccurrenceFilters(node: Node): NonNullable[] { + const raw = node.metadata?.[OCCURRENCE_FILTERS_METADATA_KEY]; + if (!Array.isArray(raw)) return []; + return raw as NonNullable[]; +} + export function mergeNodesForNavigation( existing: Node | null, incoming: Node, @@ -401,6 +412,8 @@ export function mergeNodesForNavigation( [BODY_SOURCE_NAMES_METADATA_KEY]: nextBodySourceNames, } : {}), + // Seed occurrence-0 filters for per-occurrence display in TableNode. + [OCCURRENCE_FILTERS_METADATA_KEY]: [incoming.filters ?? []], }, }; } @@ -413,12 +426,27 @@ export function mergeNodesForNavigation( const mergedBodySpans = [...buildBodySpans(existing), ...nextBodySpans]; const mergedBodySourceNames = [...buildBodySourceNames(existing), ...nextBodySourceNames]; + // Append per-occurrence filters: each incoming statement's filters are stored + // at the next occurrence index so TableNode can show only the focused one. + const existingOccFilters = readOccurrenceFilters(existing); + const mergedOccFilters = [...existingOccFilters, incoming.filters ?? []]; + + // Build flat filters as the union of unique expressions across all occurrences. + // This is the legacy fallback used by code that doesn't yet read occurrenceFilters. + const seen = new Set(); + const dedupedFilters: NonNullable = []; + for (const occFilters of mergedOccFilters) { + for (const f of occFilters) { + if (!seen.has(f.expression)) { + seen.add(f.expression); + dedupedFilters.push(f); + } + } + } + return { ...existing, - filters: - incoming.filters && incoming.filters.length > 0 - ? [...(existing.filters || []), ...incoming.filters] - : existing.filters, + filters: dedupedFilters.length > 0 ? dedupedFilters : existing.filters, nameSpans: mergedOccurrenceSpans.length > 0 ? mergedOccurrenceSpans : existing.nameSpans, bodySpan: existing.bodySpan ?? incoming.bodySpan, metadata: { @@ -438,6 +466,7 @@ export function mergeNodesForNavigation( [BODY_SOURCE_NAMES_METADATA_KEY]: mergedBodySourceNames, } : {}), + [OCCURRENCE_FILTERS_METADATA_KEY]: mergedOccFilters, }, }; } diff --git a/packages/react/src/workers/graphBuilder.worker.ts b/packages/react/src/workers/graphBuilder.worker.ts index 2a8015d2..6ca81058 100644 --- a/packages/react/src/workers/graphBuilder.worker.ts +++ b/packages/react/src/workers/graphBuilder.worker.ts @@ -30,13 +30,36 @@ import { isScriptRelationNode, withStatementScope, } from '../utils/lineageHelpers'; -import { mergeNodesForNavigation, scopeNodeToStatement } from '../utils/nodeOccurrences'; +import { mergeNodesForNavigation, scopeNodeToStatement, OCCURRENCE_FILTERS_METADATA_KEY } from '../utils/nodeOccurrences'; import { buildConnectedColumnIdSet, filterColumnsForColumnLineage, EMPTY_CONNECTED_COLUMN_IDS, } from '../utils/columnLineageFilter'; +// ============================================================================= +// Helpers +// ============================================================================= + +/** + * Deduplicate filter predicates by expression, preserving first occurrence. + * See graphBuilders.ts for the full rationale. + */ +function deduplicateFilters( + filters: FilterPredicate[] | undefined +): FilterPredicate[] | undefined { + if (!filters || filters.length === 0) return filters; + const seen = new Set(); + const result: FilterPredicate[] = []; + for (const f of filters) { + if (!seen.has(f.expression)) { + seen.add(f.expression); + result.push(f); + } + } + return result.length === filters.length ? filters : result; +} + // ============================================================================= // Types for worker communication (all serializable - no Sets, no functions) // ============================================================================= @@ -71,6 +94,7 @@ export interface SerializedTableNodeData extends Record { hiddenColumnCount?: number; lineageHiddenColumnCount?: number; filters?: FilterPredicate[]; + occurrenceFilters?: FilterPredicate[][]; qualifiedName?: string; schema?: string; database?: string; @@ -405,7 +429,12 @@ function buildTableNodeData( lineageHiddenColumnCount: options.lineageHiddenColumnCount, isRecursive: options.isRecursive, isBaseTable: options.isBaseTable, - filters: node.filters, + filters: deduplicateFilters(node.filters), + occurrenceFilters: (() => { + const raw = node.metadata?.[OCCURRENCE_FILTERS_METADATA_KEY]; + if (!Array.isArray(raw) || raw.length <= 1) return undefined; + return raw as FilterPredicate[][]; + })(), qualifiedName, schema: canonical?.schema, database: canonical?.catalog,