| // Copyright (C) 2025 The Android Open Source Project |
| // |
| // Licensed 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. |
| |
| // QUERY EXECUTION MODEL |
| // ==================== |
| // |
| // The Explore Page uses a two-phase execution model with centralized control |
| // in QueryExecutionService.processNode(). |
| // |
| // CENTRALIZED ARCHITECTURE |
| // ------------------------ |
| // All autoExecute logic is handled by QueryExecutionService.processNode(): |
| // |
| // | autoExecute | manual | Behavior | |
| // |-------------|--------|-----------------------------------------------| |
| // | true | false | Analyze + execute if query changed | |
| // | true | true | Analyze + execute (forced) | |
| // | false | false | Skip everything (save engine queries) | |
| // | false | true | Analyze + execute (user clicked "Run Query") | |
| // |
| // PHASE 1: ANALYSIS (Validation) |
| // ------------------------------ |
| // When node state changes, NodeExplorer calls service.processNode({ manual: false }). |
| // The service decides whether to analyze based on autoExecute flag. |
| // If analysis runs: |
| // 1. Sends structured queries to the engine |
| // 2. Engine VALIDATES the query and returns generated SQL (doesn't execute) |
| // 3. Returns a Query object: {sql, textproto, modules, preambles, columns} |
| // |
| // PHASE 2: EXECUTION (Materialization) |
| // ------------------------------------ |
| // The service decides whether to execute based on autoExecute and manual flags. |
| // If execution runs: |
| // 1. Materializes the query into a PERFETTO table |
| // 2. SQL = modules + preambles + query.sql |
| // 3. Table name: _exp_materialized_{sanitizedNodeId} |
| // 4. Creates SQLDataSource pointing to the materialized table |
| // 5. Fetches metadata (COUNT and column info) from materialized table |
| // 6. SQLDataSource handles server-side pagination, filtering, sorting |
| // 7. Updates node.state.issues with any errors/warnings |
| // 8. For SqlSourceNode, updates available columns |
| // |
| // Auto-execute is set to FALSE for: |
| // - SqlSourceNode: User writes SQL manually, should control execution |
| // - IntervalIntersectNode: Multi-node operation, potentially expensive |
| // - UnionNode: Multi-node operation, potentially expensive |
| // - FilterDuringNode: Multi-node operation, potentially expensive |
| // |
| // STATE MANAGEMENT |
| // --------------- |
| // - this.query: Current validated query (from analysis phase) |
| // - this.queryExecuted: Flag to prevent duplicate execution |
| // - this.response: Query results from execution |
| // - this.dataSource: Wrapped data source for DataGrid display |
| |
| import m from 'mithril'; |
| import {classNames} from '../../../base/classnames'; |
| import {Button, ButtonVariant} from '../../../widgets/button'; |
| import {Icons} from '../../../base/semantic_icons'; |
| import {Intent} from '../../../widgets/common'; |
| import {Icon} from '../../../widgets/icon'; |
| import {Card} from '../../../widgets/card'; |
| import {Keycap} from '../../../widgets/hotkey_glyphs'; |
| import {Trace} from '../../../public/trace'; |
| import {SqlModules} from '../../dev.perfetto.SqlModules/sql_modules'; |
| import {QueryNode, Query, isAQuery, queryToRun} from '../query_node'; |
| import {NodeExplorer} from './node_explorer'; |
| import {Graph} from './graph/graph'; |
| import {DataExplorer} from './data_explorer'; |
| import { |
| SplitPanel, |
| SplitPanelDrawerVisibility, |
| } from '../../../widgets/split_panel'; |
| import {SQLDataSource} from '../../../components/widgets/datagrid/sql_data_source'; |
| import {createSimpleSchema} from '../../../components/widgets/datagrid/sql_schema'; |
| import {QueryResponse} from '../../../components/query_table/queries'; |
| import {addQueryResultsTab} from '../../../components/query_table/query_result_tab'; |
| import {SqlSourceNode} from './nodes/sources/sql_source'; |
| import {findErrors, findWarnings} from './query_builder_utils'; |
| import {NodeIssues} from './node_issues'; |
| import {DataExplorerEmptyState} from './widgets'; |
| import {UIFilter} from './operations/filter'; |
| import {QueryExecutionService} from './query_execution_service'; |
| import {ResizeHandle} from '../../../widgets/resize_handle'; |
| import {nodeRegistry} from './node_registry'; |
| import {getAllDownstreamNodes} from './graph_utils'; |
| import {Popup, PopupPosition} from '../../../widgets/popup'; |
| import {DataSource} from '../../../components/widgets/datagrid/data_source'; |
| |
| // Side panel width - must match --pf-qb-side-panel-width in builder.scss |
| const SIDE_PANEL_WIDTH = 60; |
| |
| export interface BuilderAttrs { |
| readonly trace: Trace; |
| readonly sqlModules: SqlModules; |
| readonly queryExecutionService: QueryExecutionService; |
| |
| readonly rootNodes: QueryNode[]; |
| readonly selectedNode?: QueryNode; |
| readonly nodeLayouts: Map<string, {x: number; y: number}>; |
| readonly labels?: ReadonlyArray<{ |
| id: string; |
| x: number; |
| y: number; |
| width: number; |
| text: string; |
| }>; |
| |
| // Add nodes. |
| readonly onAddSourceNode: (id: string) => void; |
| readonly onAddOperationNode: (id: string, node: QueryNode) => void; |
| |
| readonly onRootNodeCreated: (node: QueryNode) => void; |
| readonly onNodeSelected: (node?: QueryNode) => void; |
| readonly onDeselect: () => void; |
| readonly onNodeLayoutChange: ( |
| nodeId: string, |
| layout: {x: number; y: number}, |
| ) => void; |
| readonly onLabelsChange?: ( |
| labels: Array<{ |
| id: string; |
| x: number; |
| y: number; |
| width: number; |
| text: string; |
| }>, |
| ) => void; |
| |
| readonly onDeleteNode: (node: QueryNode) => void; |
| readonly onClearAllNodes: () => void; |
| readonly onDuplicateNode: (node: QueryNode) => void; |
| readonly onConnectionRemove: ( |
| fromNode: QueryNode, |
| toNode: QueryNode, |
| isSecondaryInput: boolean, |
| ) => void; |
| readonly onFilterAdd: ( |
| node: QueryNode, |
| filter: UIFilter | UIFilter[], |
| filterOperator?: 'AND' | 'OR', |
| ) => void; |
| |
| // Import / Export JSON |
| readonly onImport: () => void; |
| readonly onExport: () => void; |
| |
| readonly onLoadExample: () => void; |
| |
| // Node state change callback |
| readonly onNodeStateChange?: () => void; |
| |
| // Undo / Redo |
| readonly onUndo?: () => void; |
| readonly onRedo?: () => void; |
| readonly canUndo?: boolean; |
| readonly canRedo?: boolean; |
| } |
| |
| enum SelectedView { |
| kInfo = 0, |
| kModify = 1, |
| kResult = 2, |
| } |
| |
| export class Builder implements m.ClassComponent<BuilderAttrs> { |
| private trace: Trace; |
| private queryExecutionService: QueryExecutionService; |
| private query?: Query | Error; |
| private queryExecuted: boolean = false; |
| private isQueryRunning: boolean = false; |
| private isAnalyzing: boolean = false; |
| private previousSelectedNode?: QueryNode; |
| private isExplorerCollapsed: boolean = false; |
| private response?: QueryResponse; |
| private dataSource?: DataSource; |
| private drawerVisibility = SplitPanelDrawerVisibility.COLLAPSED; |
| private selectedView: SelectedView = SelectedView.kInfo; |
| private sidebarWidth: number = 500; // Default width in pixels |
| private readonly MIN_SIDEBAR_WIDTH = 250; |
| private readonly MAX_SIDEBAR_WIDTH = 800; |
| private hasEverSelectedNode = false; |
| |
| constructor({attrs}: m.Vnode<BuilderAttrs>) { |
| this.trace = attrs.trace; |
| // Use the shared QueryExecutionService from parent |
| this.queryExecutionService = attrs.queryExecutionService; |
| } |
| |
| private handleSidebarResize(deltaPx: number) { |
| // Subtract delta because the handle is on the left edge of the sidebar |
| // Dragging left (negative delta) = narrower sidebar (positive change) |
| // Dragging right (positive delta) = wider sidebar (negative change) |
| this.sidebarWidth = Math.max( |
| this.MIN_SIDEBAR_WIDTH, |
| Math.min(this.MAX_SIDEBAR_WIDTH, this.sidebarWidth - deltaPx), |
| ); |
| m.redraw(); |
| } |
| |
| private renderSourceCards(attrs: BuilderAttrs): m.Children { |
| const sourceNodes = nodeRegistry |
| .list() |
| .filter(([_id, node]) => node.showOnLandingPage === true) |
| .map(([id, node]) => { |
| const name = node.name ?? 'Unnamed Source'; |
| const description = node.description ?? ''; |
| const icon = node.icon ?? ''; |
| const hotkey = |
| node.hotkey && typeof node.hotkey === 'string' |
| ? node.hotkey.toUpperCase() |
| : undefined; |
| |
| return m( |
| Card, |
| { |
| 'interactive': true, |
| 'onclick': () => attrs.onAddSourceNode(id), |
| 'tabindex': 0, |
| 'role': 'button', |
| 'aria-label': `Add ${name} source`, |
| 'className': 'pf-source-card', |
| 'onkeydown': (e: KeyboardEvent) => { |
| if (e.key === 'Enter' || e.key === ' ') { |
| e.preventDefault(); |
| attrs.onAddSourceNode(id); |
| } |
| }, |
| }, |
| m('.pf-source-card-clickable', m(Icon, {icon}), m('h3', name)), |
| m('p', description), |
| hotkey ? m('.pf-source-card-hotkey', m(Keycap, hotkey)) : null, |
| ); |
| }); |
| |
| // Add Examples card at the end |
| const examplesCard = m( |
| Card, |
| { |
| 'interactive': true, |
| 'onclick': () => attrs.onLoadExample(), |
| 'tabindex': 0, |
| 'role': 'button', |
| 'aria-label': 'Load example graph', |
| 'className': 'pf-source-card', |
| 'onkeydown': (e: KeyboardEvent) => { |
| if (e.key === 'Enter' || e.key === ' ') { |
| e.preventDefault(); |
| attrs.onLoadExample(); |
| } |
| }, |
| }, |
| m( |
| '.pf-source-card-clickable', |
| m(Icon, {icon: 'auto_stories'}), |
| m('h3', 'Examples'), |
| ), |
| m('p', 'Load an example graph'), |
| ); |
| |
| if (sourceNodes.length === 0) { |
| return [examplesCard]; |
| } |
| |
| return [examplesCard, ...sourceNodes]; |
| } |
| |
| view({attrs}: m.CVnode<BuilderAttrs>) { |
| const {trace, rootNodes, onNodeSelected, selectedNode, onClearAllNodes} = |
| attrs; |
| |
| if (selectedNode && selectedNode !== this.previousSelectedNode) { |
| this.resetQueryState(); |
| this.isQueryRunning = false; |
| this.isAnalyzing = false; |
| |
| // Show drawer the first time any node is selected |
| if (!this.hasEverSelectedNode) { |
| this.drawerVisibility = SplitPanelDrawerVisibility.VISIBLE; |
| this.hasEverSelectedNode = true; |
| } |
| |
| const hasModifyPanel = selectedNode.nodeSpecificModify() != null; |
| // If current view is Info, switch to Modify (if available) when selecting a new node |
| if (this.selectedView === SelectedView.kInfo && hasModifyPanel) { |
| this.selectedView = SelectedView.kModify; |
| } |
| // If current view is Modify but modify panel is not available, switch to Info |
| if (this.selectedView === SelectedView.kModify && !hasModifyPanel) { |
| this.selectedView = SelectedView.kInfo; |
| } |
| } |
| |
| // When transitioning to unselected state with collapsed explorer, reappear at minimum size |
| if ( |
| !selectedNode && |
| this.previousSelectedNode && |
| this.isExplorerCollapsed |
| ) { |
| this.isExplorerCollapsed = false; |
| this.sidebarWidth = this.MIN_SIDEBAR_WIDTH; |
| } |
| |
| this.previousSelectedNode = selectedNode; |
| |
| const layoutClasses = |
| classNames( |
| 'pf-query-builder-layout', |
| this.isExplorerCollapsed && 'explorer-collapsed', |
| ) || ''; |
| |
| const explorer = selectedNode |
| ? m(NodeExplorer, { |
| // The key to force mithril to re-create the component when the |
| // selected node changes, preventing state from leaking between |
| // different nodes. |
| key: selectedNode.nodeId, |
| trace, |
| node: selectedNode, |
| queryExecutionService: this.queryExecutionService, |
| resolveNode: (nodeId: string) => this.resolveNode(nodeId, rootNodes), |
| hasExistingResult: this.queryExecuted, |
| onQueryAnalyzed: (query: Query | Error | undefined) => { |
| this.query = query; |
| }, |
| onAnalysisStateChange: (isAnalyzing: boolean) => { |
| this.isAnalyzing = isAnalyzing; |
| }, |
| onExecutionStart: () => { |
| this.isQueryRunning = true; |
| this.queryExecuted = false; |
| }, |
| onExecutionSuccess: (result) => { |
| this.handleExecutionSuccess(selectedNode, result); |
| }, |
| onExecutionError: (error) => { |
| this.handleQueryError(selectedNode, error); |
| this.isQueryRunning = false; |
| m.redraw(); |
| }, |
| onchange: () => { |
| // When a node's state changes, notify all downstream nodes |
| // to update their columns and UI. This ensures that when e.g. |
| // a column is renamed in ModifyColumnsNode, the AggregationNode |
| // sees the new column name. |
| const downstreamNodes = getAllDownstreamNodes(selectedNode); |
| for (const node of downstreamNodes) { |
| // Skip the node itself (it's included in downstream nodes) |
| if (node.nodeId === selectedNode.nodeId) continue; |
| node.onPrevNodesUpdated?.(); |
| } |
| attrs.onNodeStateChange?.(); |
| }, |
| isCollapsed: this.isExplorerCollapsed, |
| selectedView: this.selectedView, |
| onViewChange: (view: number) => { |
| this.selectedView = view; |
| }, |
| }) |
| : m('.pf-unselected-explorer', this.renderSourceCards(attrs)); |
| |
| return m( |
| SplitPanel, |
| { |
| className: layoutClasses, |
| visibility: this.drawerVisibility, |
| onVisibilityChange: (v) => { |
| this.drawerVisibility = v; |
| }, |
| startingHeight: 300, |
| drawerContent: selectedNode |
| ? m(DataExplorer, { |
| trace: this.trace, |
| query: this.query, |
| node: selectedNode, |
| response: this.response, |
| dataSource: this.dataSource, |
| isQueryRunning: this.isQueryRunning, |
| isAnalyzing: this.isAnalyzing, |
| onchange: () => { |
| attrs.onNodeStateChange?.(); |
| }, |
| onFilterAdd: (filter, filterOperator) => { |
| attrs.onFilterAdd(selectedNode, filter, filterOperator); |
| }, |
| isFullScreen: |
| this.drawerVisibility === SplitPanelDrawerVisibility.FULLSCREEN, |
| onFullScreenToggle: () => { |
| if ( |
| this.drawerVisibility === |
| SplitPanelDrawerVisibility.FULLSCREEN |
| ) { |
| this.drawerVisibility = SplitPanelDrawerVisibility.VISIBLE; |
| } else { |
| this.drawerVisibility = SplitPanelDrawerVisibility.FULLSCREEN; |
| } |
| }, |
| onExecute: async () => { |
| if (!selectedNode.validate()) { |
| return; |
| } |
| |
| // Use the centralized service with manual=true. |
| // The service handles both analysis and execution. |
| await this.queryExecutionService.processNode( |
| selectedNode, |
| this.trace.engine, |
| { |
| manual: true, // User explicitly clicked "Run Query" |
| hasExistingResult: this.queryExecuted, |
| ...this.createManualExecutionCallbacks(selectedNode), |
| }, |
| ); |
| }, |
| onExportToTimeline: () => { |
| this.exportToTimeline(selectedNode); |
| }, |
| }) |
| : m(DataExplorerEmptyState, { |
| icon: 'info', |
| title: 'Select a node to see the data', |
| }), |
| }, |
| m( |
| '.pf-qb-node-graph', |
| m(Graph, { |
| rootNodes, |
| selectedNode, |
| onNodeSelected, |
| nodeLayouts: attrs.nodeLayouts, |
| labels: attrs.labels, |
| onNodeLayoutChange: attrs.onNodeLayoutChange, |
| onLabelsChange: attrs.onLabelsChange, |
| onDeselect: attrs.onDeselect, |
| onAddSourceNode: attrs.onAddSourceNode, |
| onClearAllNodes, |
| onDuplicateNode: attrs.onDuplicateNode, |
| onAddOperationNode: (id, node) => attrs.onAddOperationNode(id, node), |
| onDeleteNode: attrs.onDeleteNode, |
| onConnectionRemove: attrs.onConnectionRemove, |
| onImport: attrs.onImport, |
| onExport: attrs.onExport, |
| }), |
| selectedNode && |
| m( |
| '.pf-qb-floating-controls', |
| !selectedNode.validate() && |
| m( |
| Popup, |
| { |
| trigger: m( |
| '.pf-qb-floating-warning', |
| m(Icon, { |
| icon: Icons.Warning, |
| filled: true, |
| className: 'pf-qb-warning-icon', |
| title: 'Click to see error details', |
| }), |
| ), |
| position: PopupPosition.BottomEnd, |
| showArrow: true, |
| }, |
| m( |
| '.pf-error-details', |
| selectedNode.state.issues?.getTitle() ?? 'No error details', |
| ), |
| ), |
| ), |
| m( |
| '.pf-qb-floating-controls-bottom', |
| attrs.onUndo && |
| m(Button, { |
| icon: Icons.Undo, |
| title: 'Undo (Ctrl+Z)', |
| onclick: attrs.onUndo, |
| disabled: !attrs.canUndo, |
| variant: ButtonVariant.Filled, |
| rounded: true, |
| iconFilled: true, |
| intent: Intent.Primary, |
| }), |
| attrs.onRedo && |
| m(Button, { |
| icon: Icons.Redo, |
| title: 'Redo (Ctrl+Shift+Z)', |
| onclick: attrs.onRedo, |
| disabled: !attrs.canRedo, |
| variant: ButtonVariant.Filled, |
| rounded: true, |
| iconFilled: true, |
| intent: Intent.Primary, |
| }), |
| ), |
| ), |
| m(ResizeHandle, { |
| direction: 'horizontal', |
| onResize: (deltaPx) => this.handleSidebarResize(deltaPx), |
| }), |
| m( |
| '.pf-qb-explorer', |
| { |
| style: { |
| width: this.isExplorerCollapsed |
| ? '0' |
| : `${this.sidebarWidth + (selectedNode ? 0 : SIDE_PANEL_WIDTH)}px`, |
| }, |
| }, |
| explorer, |
| ), |
| selectedNode && |
| m( |
| '.pf-qb-side-panel', |
| m(Button, { |
| icon: Icons.Info, |
| title: 'Info', |
| className: |
| this.selectedView === SelectedView.kInfo && |
| !this.isExplorerCollapsed |
| ? 'pf-active' |
| : '', |
| onclick: () => { |
| if ( |
| this.selectedView === SelectedView.kInfo && |
| !this.isExplorerCollapsed |
| ) { |
| this.isExplorerCollapsed = true; |
| } else { |
| this.selectedView = SelectedView.kInfo; |
| this.isExplorerCollapsed = false; |
| } |
| }, |
| }), |
| selectedNode.nodeSpecificModify() != null && |
| m(Button, { |
| icon: Icons.Edit, |
| title: 'Edit', |
| className: |
| this.selectedView === SelectedView.kModify && |
| !this.isExplorerCollapsed |
| ? 'pf-active' |
| : '', |
| onclick: () => { |
| if ( |
| this.selectedView === SelectedView.kModify && |
| !this.isExplorerCollapsed |
| ) { |
| this.isExplorerCollapsed = true; |
| } else { |
| this.selectedView = SelectedView.kModify; |
| this.isExplorerCollapsed = false; |
| } |
| }, |
| }), |
| m(Button, { |
| icon: 'code', |
| title: 'Result', |
| className: |
| this.selectedView === SelectedView.kResult && |
| !this.isExplorerCollapsed |
| ? 'pf-active' |
| : '', |
| onclick: () => { |
| if ( |
| this.selectedView === SelectedView.kResult && |
| !this.isExplorerCollapsed |
| ) { |
| this.isExplorerCollapsed = true; |
| } else { |
| this.selectedView = SelectedView.kResult; |
| this.isExplorerCollapsed = false; |
| } |
| }, |
| }), |
| ), |
| ); |
| } |
| |
| private resolveNode( |
| nodeId: string, |
| rootNodes: QueryNode[], |
| ): QueryNode | undefined { |
| const queue: QueryNode[] = [...rootNodes]; |
| const visited = new Set<string>(); |
| |
| while (queue.length > 0) { |
| const current = queue.shift()!; |
| if (visited.has(current.nodeId)) { |
| continue; |
| } |
| visited.add(current.nodeId); |
| |
| if (current.nodeId === nodeId) { |
| return current; |
| } |
| |
| queue.push(...current.nextNodes); |
| } |
| return undefined; |
| } |
| |
| /** |
| * Handles successful query execution by updating UI state. |
| * Called from both automatic execution (via NodeExplorer) and manual execution (via onExecute). |
| */ |
| private handleExecutionSuccess( |
| node: QueryNode, |
| result: { |
| tableName: string; |
| rowCount: number; |
| columns: string[]; |
| durationMs: number; |
| }, |
| ) { |
| const engine = this.queryExecutionService.getEngine(); |
| const query = this.query; |
| |
| this.response = { |
| query: isAQuery(query) ? queryToRun(query) : '', |
| totalRowCount: result.rowCount, |
| durationMs: result.durationMs, |
| columns: result.columns, |
| rows: [], |
| statementCount: 1, |
| statementWithOutputCount: 1, |
| lastStatementSql: isAQuery(query) ? query.sql : '', |
| }; |
| |
| this.dataSource = new SQLDataSource({ |
| engine, |
| sqlSchema: createSimpleSchema(result.tableName), |
| rootSchemaName: 'query', |
| }); |
| this.queryExecuted = true; |
| this.isQueryRunning = false; |
| |
| if (isAQuery(query)) { |
| this.setNodeIssuesFromResponse(node, query, this.response); |
| } |
| |
| if (node instanceof SqlSourceNode && this.response !== undefined) { |
| node.onQueryExecuted(this.response.columns); |
| } |
| |
| m.redraw(); |
| } |
| |
| /** |
| * Creates callbacks for processNode() when manually executing a query. |
| * Used by onExecute to avoid duplicating callback logic. |
| */ |
| private createManualExecutionCallbacks(node: QueryNode) { |
| return { |
| onAnalysisStart: () => { |
| this.isAnalyzing = true; |
| m.redraw(); |
| }, |
| onAnalysisComplete: (query: Query | Error | undefined) => { |
| this.query = query; |
| this.isAnalyzing = false; |
| m.redraw(); |
| }, |
| onExecutionStart: () => { |
| this.isQueryRunning = true; |
| this.queryExecuted = false; |
| m.redraw(); |
| }, |
| onExecutionSuccess: (result: { |
| tableName: string; |
| rowCount: number; |
| columns: string[]; |
| durationMs: number; |
| }) => { |
| this.handleExecutionSuccess(node, result); |
| }, |
| onExecutionError: (error: unknown) => { |
| this.handleQueryError(node, error); |
| this.isQueryRunning = false; |
| m.redraw(); |
| }, |
| }; |
| } |
| |
| private exportToTimeline(node: QueryNode) { |
| // Only export if we have a materialized table |
| const tableName = node.state.materializationTableName; |
| if (!tableName) { |
| console.warn('Cannot export to timeline: no materialized table'); |
| return; |
| } |
| |
| // Use the materialized table instead of re-running the original query |
| addQueryResultsTab( |
| this.trace, |
| { |
| query: `SELECT * FROM ${tableName}`, |
| title: 'Explore Query', |
| }, |
| 'explore_page', |
| ); |
| |
| // Navigate to the timeline page |
| this.trace.navigate('#!/viewer'); |
| } |
| |
| private setNodeIssuesFromResponse( |
| node: QueryNode, |
| query: Query, |
| response: QueryResponse, |
| ) { |
| const error = findErrors(query, response); |
| const warning = findWarnings(response, node); |
| const noDataWarning = |
| response.totalRowCount === 0 |
| ? new Error('Query returned no rows') |
| : undefined; |
| |
| if (error || warning || noDataWarning) { |
| if (!node.state.issues) { |
| node.state.issues = new NodeIssues(); |
| } |
| node.state.issues.queryError = error; |
| node.state.issues.responseError = warning; |
| node.state.issues.dataError = noDataWarning; |
| // Clear any previous execution error since we got a successful response |
| node.state.issues.clearExecutionError(); |
| } else { |
| node.state.issues = undefined; |
| } |
| } |
| |
| /** |
| * Resets the query execution state. |
| * Used when switching nodes or after query execution errors. |
| */ |
| private resetQueryState() { |
| this.dataSource = undefined; |
| this.response = undefined; |
| this.query = undefined; |
| this.queryExecuted = false; |
| // Clear any pending execution in the service |
| this.queryExecutionService.clearPendingExecution(); |
| } |
| |
| private handleQueryError(node: QueryNode, e: unknown) { |
| console.error('Failed to run query:', e); |
| // Clear response and data source but keep query so Retry can re-execute |
| this.dataSource = undefined; |
| this.response = undefined; |
| this.queryExecuted = false; |
| if (!node.state.issues) { |
| node.state.issues = new NodeIssues(); |
| } |
| // Use executionError (not queryError) so error persists across re-renders |
| // that trigger validate() - queryError gets cleared during validation |
| node.state.issues.executionError = |
| e instanceof Error ? e : new Error(String(e)); |
| } |
| } |