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,