Skip to content
Open
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
71 changes: 52 additions & 19 deletions ui/src/plugins/dev.perfetto.DataExplorer/json_handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,22 @@ import {getAllNodes as getAllNodesUtil} from './query_builder/graph_utils';
import {Trace} from '../../public/trace';
import {SqlModules} from '../../plugins/dev.perfetto.SqlModules/sql_modules';
import {nodeRegistry} from './query_builder/node_registry';
import {restoreLegacySecondaryInputs} from './query_builder/legacy_connections';

// Interfaces for the serialized JSON structure
export interface SerializedNode {
nodeId: string;
type: NodeType;
state: object;
nextNodes: string[];
// Input node IDs (for multi-source nodes like Union, Merge, IntervalIntersect)

// Graph-level connection fields (automatically captured during serialization).
// These replace per-node connection serialization (e.g. primaryInputId inside
// node state, or node-specific fields like leftNodeId, intervalNodes, etc.).
primaryInputId?: string;
secondaryInputIds?: {[port: string]: string};

// Deprecated: kept for backward compatibility with old saved graphs.
inputNodeIds?: string[];
}

Expand All @@ -50,12 +58,25 @@ function serializeNode(node: QueryNode): SerializedNode {
throw new Error(`Node type ${node.type} is not serializable.`);
}

return {
const serialized: SerializedNode = {
nodeId: node.nodeId,
type: node.type,
state: node.serializeState(),
nextNodes: node.nextNodes.map((n: QueryNode) => n.nodeId),
};

// Automatically capture connections at the graph level.
if (node.primaryInput) {
serialized.primaryInputId = node.primaryInput.nodeId;
}
if (node.secondaryInputs && node.secondaryInputs.connections.size > 0) {
serialized.secondaryInputIds = {};
for (const [port, inputNode] of node.secondaryInputs.connections) {
serialized.secondaryInputIds[port.toString()] = inputNode.nodeId;
}
}

return serialized;
}

interface LabelData {
Expand Down Expand Up @@ -265,36 +286,48 @@ export function deserializeState(
});
}

// Third pass: set backward connections using the node registry
// Third pass: restore backward connections from graph-level fields.
// Falls back to per-node hooks for backward compatibility with old formats.
for (const serializedNode of serializedGraph.nodes) {
const node = nodes.get(serializedNode.nodeId);
if (!node) {
throw new Error(
`Graph is corrupted. Node "${serializedNode.nodeId}" not found.`,
);
}
const descriptor = nodeRegistry.getByNodeType(serializedNode.type);
if (!descriptor) {
throw new Error(`Unknown node type: ${serializedNode.type}`);

// Restore primary input from graph-level field, or from node state
// for backward compatibility with old saved graphs.
const primaryInputId =
serializedNode.primaryInputId ??
(serializedNode.state as {primaryInputId?: string}).primaryInputId;
if (primaryInputId) {
const inputNode = nodes.get(primaryInputId);
if (inputNode) {
node.primaryInput = inputNode;
}
}

// Restore primary input for nodes that have one
const hasPrimary =
descriptor.hasPrimaryInput ?? descriptor.type === 'modification';
if (hasPrimary) {
const serializedState = serializedNode.state as {
primaryInputId?: string;
};
if (serializedState.primaryInputId) {
const inputNode = nodes.get(serializedState.primaryInputId);
// Restore secondary inputs from graph-level field.
if (serializedNode.secondaryInputIds && node.secondaryInputs) {
node.secondaryInputs.connections.clear();
for (const [portStr, inputNodeId] of Object.entries(
serializedNode.secondaryInputIds,
)) {
const inputNode = nodes.get(inputNodeId);
if (inputNode) {
node.primaryInput = inputNode;
node.secondaryInputs.connections.set(
parseInt(portStr, 10),
inputNode,
);
}
}
} else if (node.secondaryInputs) {
// Backward compatibility: old saved graphs stored connection IDs
// inside node state. A single lookup table in legacy_connections.ts
// maps each node type to the old field name pattern.
restoreLegacySecondaryInputs(node, serializedNode.state, nodes);
}

// Node-specific connection deserialization
descriptor.deserializeConnections?.(node, serializedNode.state, nodes);
}

// Fourth pass: post-deserialization (resolve internal references, then
Expand Down
140 changes: 140 additions & 0 deletions ui/src/plugins/dev.perfetto.DataExplorer/json_handler_unittest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2804,4 +2804,144 @@ describe('JSON serialization/deserialization', () => {
text: 'Test label',
}); // (400-400, 250-250)
});

// =========================================================================
// Tests for graph-level connection serialization
// Connections (primaryInputId, secondaryInputIds) should be stored at the
// SerializedNode level, NOT inside each node's serialized state.
// =========================================================================

test('serializes primaryInput at graph level, not in node state', () => {
const tableNode = new TableSourceNode({
sqlTable: sqlModules.getTable('slice'),
trace,
sqlModules,
});
const filterNode = new FilterNode({
filters: [{column: 'name', op: '=', value: 'test'}],
});
addConnection(tableNode, filterNode);

const initialState: DataExplorerState = {
rootNodes: [tableNode],
selectedNodes: new Set(),
nodeLayouts: new Map(),
labels: [],
};

const json = serializeState(initialState);
const parsed = JSON.parse(json);

// Find the filter node in the serialized output
const serializedFilter = parsed.nodes.find(
(n: SerializedNode) => n.nodeId === filterNode.nodeId,
);

// Connection should be at the SerializedNode level
expect(serializedFilter.primaryInputId).toBe(tableNode.nodeId);

// Connection should NOT be inside node state
expect(serializedFilter.state.primaryInputId).toBeUndefined();
});

test('serializes secondaryInputs at graph level, not in node state', () => {
const tableNode1 = new TableSourceNode({
sqlTable: sqlModules.getTable('slice'),
trace,
sqlModules,
});
const tableNode2 = new TableSourceNode({
sqlTable: sqlModules.getTable('slice'),
trace,
sqlModules,
});
const intervalNode = new IntervalIntersectNode({
inputNodes: [tableNode1, tableNode2],
});
tableNode1.nextNodes.push(intervalNode);
tableNode2.nextNodes.push(intervalNode);

const initialState: DataExplorerState = {
rootNodes: [tableNode1, tableNode2],
selectedNodes: new Set(),
nodeLayouts: new Map(),
labels: [],
};

const json = serializeState(initialState);
const parsed = JSON.parse(json);

// Find the interval intersect node
const serializedInterval = parsed.nodes.find(
(n: SerializedNode) => n.nodeId === intervalNode.nodeId,
);

// Secondary inputs should be at the SerializedNode level
expect(serializedInterval.secondaryInputIds).toEqual({
'0': tableNode1.nodeId,
'1': tableNode2.nodeId,
});

// Connection IDs should NOT be inside node state
expect(serializedInterval.state.intervalNodes).toBeUndefined();
});

test('deserializes connections from graph-level fields', () => {
// Build a graph: table -> filter (primaryInput) and table1, table2 -> union (secondaryInputs)
const tableNode = new TableSourceNode({
sqlTable: sqlModules.getTable('slice'),
trace,
sqlModules,
});
const filterNode = new FilterNode({
filters: [{column: 'name', op: '=', value: 'x'}],
});
addConnection(tableNode, filterNode);

const tableNode2 = new TableSourceNode({
sqlTable: sqlModules.getTable('slice'),
trace,
sqlModules,
});
const unionNode = new UnionNode({
inputNodes: [],
selectedColumns: [],
});
tableNode.nextNodes.push(unionNode);
tableNode2.nextNodes.push(unionNode);
unionNode.secondaryInputs.connections.set(0, tableNode);
unionNode.secondaryInputs.connections.set(1, tableNode2);

const initialState: DataExplorerState = {
rootNodes: [tableNode, tableNode2],
selectedNodes: new Set(),
nodeLayouts: new Map(),
labels: [],
};

const json = serializeState(initialState);
const deserialized = deserializeState(json, trace, sqlModules);

// Find the union node in deserialized graph
const allNodes = new Map<string, import('./query_node').QueryNode>();
const queue = [...deserialized.rootNodes];
while (queue.length > 0) {
const node = queue.shift()!;
if (allNodes.has(node.nodeId)) continue;
allNodes.set(node.nodeId, node);
queue.push(...node.nextNodes);
}

const deserializedUnion = [...allNodes.values()].find(
(n) => n.type === NodeType.kUnion,
) as UnionNode;
expect(deserializedUnion).toBeDefined();
expect(deserializedUnion.secondaryInputs.connections.size).toBe(2);

const deserializedFilter = [...allNodes.values()].find(
(n) => n.type === NodeType.kFilter,
) as FilterNode;
expect(deserializedFilter).toBeDefined();
expect(deserializedFilter.primaryInput).toBeDefined();
});
});
Loading
Loading