Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions packages/react/src/components/TableNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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 =
Expand Down Expand Up @@ -749,7 +765,7 @@ function TableNodeComponent({ id, data, selected }: NodeProps): JSX.Element {
)}
</div>
)}
{!isCollapsed && nodeData.filters && nodeData.filters.length > 0 && (
{!isCollapsed && activeFilters && activeFilters.length > 0 && (
<div
style={{
padding: '6px 12px',
Expand Down Expand Up @@ -789,7 +805,7 @@ function TableNodeComponent({ id, data, selected }: NodeProps): JSX.Element {
Filters
</span>
</div>
{nodeData.filters.map((filter, index) => (
{activeFilters.map((filter, index) => (
<div
key={index}
style={{
Expand Down Expand Up @@ -899,5 +915,14 @@ export const TableNode = memo(TableNodeComponent, (prev, next) => {
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;
});
7 changes: 7 additions & 0 deletions packages/react/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,13 @@ export interface TableNodeData extends Record<string, unknown> {
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 */
Expand Down
32 changes: 31 additions & 1 deletion packages/react/src/utils/graphBuilders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string>();
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.
*/
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
37 changes: 33 additions & 4 deletions packages/react/src/utils/nodeOccurrences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Node['filters']>[] {
const raw = node.metadata?.[OCCURRENCE_FILTERS_METADATA_KEY];
if (!Array.isArray(raw)) return [];
return raw as NonNullable<Node['filters']>[];
}

export function mergeNodesForNavigation(
existing: Node | null,
incoming: Node,
Expand Down Expand Up @@ -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 ?? []],
},
};
}
Expand All @@ -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<string>();
const dedupedFilters: NonNullable<Node['filters']> = [];
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: {
Expand All @@ -438,6 +466,7 @@ export function mergeNodesForNavigation(
[BODY_SOURCE_NAMES_METADATA_KEY]: mergedBodySourceNames,
}
: {}),
[OCCURRENCE_FILTERS_METADATA_KEY]: mergedOccFilters,
},
};
}
Expand Down
33 changes: 31 additions & 2 deletions packages/react/src/workers/graphBuilder.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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)
// =============================================================================
Expand Down Expand Up @@ -71,6 +94,7 @@ export interface SerializedTableNodeData extends Record<string, unknown> {
hiddenColumnCount?: number;
lineageHiddenColumnCount?: number;
filters?: FilterPredicate[];
occurrenceFilters?: FilterPredicate[][];
qualifiedName?: string;
schema?: string;
database?: string;
Expand Down Expand Up @@ -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,
Expand Down
Loading