LCOV - code coverage report
Current view: top level - dashql-app/src/session/dataflow - dataflow_graph.ts Coverage Total Hit
Test: DashQL 915828b Lines: 87.7 % 171 150
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as core from '@ankoh/dashql-core';
       2              : 
       3              : import { scriptDisplayName } from '../scripts/script_types.js';
       4              : import { orderedSessionScripts, sessionAnalysisSignature, type SessionScripts } from '../scripts/session_scripts.js';
       5              : import { readSessionInputType, sessionPlanRequest, type SessionInputType } from '../inputs/session_inputs.js';
       6              : 
       7            1 : const StatementType = core.buffers.parser.StatementType;
       8              : 
       9              : /// What carries the data of a dataflow edge
      10            1 : export enum DataflowEdgeKind {
      11              :     /// A table parameter inlines the terminal query of the referenced script
      12            1 :     Inline = 'inline',
      13              :     /// A table parameter scans the materialized result of a script of another connection
      14            1 :     Scan = 'scan',
      15              :     /// A table parameter with errors
      16            1 :     Invalid = 'invalid',
      17              :     /// A statement reads or writes a relation that another statement created or wrote
      18            1 :     Table = 'table',
      19              :     /// A statement uses an input, a named scalar parameter
      20            1 :     Input = 'input',
      21            1 : }
      22              : 
      23              : /// A statement of a script on the canvas
      24              : export interface DataflowStatement {
      25              :     statementId: number;
      26              :     statementType: core.buffers.parser.StatementType;
      27              :     /// The relation that the statement creates, writes or drops
      28              :     target: string | null;
      29              :     /// The text of the statement, in UTF-16 code units of the analyzed text
      30              :     from: number;
      31              :     to: number;
      32              :     /// The label of the statement row: the target, or the first line of the statement
      33              :     label: string;
      34              : }
      35              : 
      36              : /// A script on the canvas
      37              : export interface DataflowScript {
      38              :     scriptKey: number;
      39              :     fileName: string;
      40              :     connectionId: string;
      41              :     statements: DataflowStatement[];
      42              :     /// Are the statements those of the current text? A script that is being edited keeps its last statements.
      43              :     current: boolean;
      44              : }
      45              : 
      46              : /// An input of the session on the canvas, a named scalar parameter
      47              : export interface DataflowInput {
      48              :     name: string;
      49              :     type: SessionInputType;
      50              :     /// The value of the session, null if no script has an `@param` tag of the input
      51              :     value: string | null;
      52              : }
      53              : 
      54              : /// The script key of the source port of an input edge, the input of the edge names its source
      55              : export const INPUT_PORT_SCRIPT_KEY = -1;
      56              : 
      57              : /// An end of an edge
      58              : export interface DataflowPort {
      59              :     scriptKey: number;
      60              :     statementId: number;
      61              : }
      62              : 
      63              : /// The flow of data from one statement to another
      64              : export interface DataflowEdge {
      65              :     /// A key that is unique within the graph
      66              :     key: string;
      67              :     from: DataflowPort;
      68              :     to: DataflowPort;
      69              :     kind: DataflowEdgeKind;
      70              :     /// The parameter name of a reference, the relation name of a table edge, the name of the input of an input edge
      71              :     name: string;
      72              :     /// The input that an input edge starts at, null for the other edges
      73              :     input: string | null;
      74              : }
      75              : 
      76              : /// The dataflow of a session
      77              : export interface DataflowGraph {
      78              :     /// The scripts in session order
      79              :     scripts: DataflowScript[];
      80              :     /// The inputs in the order of their first use
      81              :     inputs: DataflowInput[];
      82              :     edges: DataflowEdge[];
      83              : }
      84              : 
      85            1 : const EMPTY_GRAPH: DataflowGraph = { scripts: [], inputs: [], edges: [] };
      86            1 : const NO_STATEMENTS: DataflowStatement[] = [];
      87              : 
      88              : /// The short name of a statement type, e.g. `CREATE` of `CREATE_TABLE_AS`
      89            1 : export function statementTypeLabel(type: core.buffers.parser.StatementType): string {
      90            1 :     switch (type) {
      91            1 :         case StatementType.SELECT: return 'SELECT';
      92            1 :         case StatementType.INSERT: return 'INSERT';
      93            0 :         case StatementType.DELETE: return 'DELETE';
      94            1 :         case StatementType.CREATE_TABLE:
      95            1 :         case StatementType.CREATE_TABLE_AS:
      96            1 :         case StatementType.CREATE_VIEW:
      97            1 :         case StatementType.SELECT_INTO:
      98            1 :             return 'CREATE';
      99            0 :         case StatementType.DROP_TABLE:
     100            1 :         case StatementType.DROP_VIEW:
     101            1 :             return 'DROP';
     102            1 :         default: {
     103            1 :             const name = StatementType[type] ?? 'SQL';
     104            1 :             return name.split('_')[0];
     105              :         }
     106            1 :     }
     107            1 : }
     108              : 
     109              : /// The label of a statement row
     110            1 : function statementLabel(text: string, target: string | null): string {
     111            1 :     if (target != null && target.length > 0) return target;
     112            1 :     const line = text.split('\n').map(l => l.trim()).find(l => l.length > 0) ?? '';
     113            1 :     return line;
     114            1 : }
     115              : 
     116              : /// The edge kind of a core edge
     117            1 : function edgeKind(kind: core.buffers.execution.DataflowEdgeKind): DataflowEdgeKind {
     118            1 :     switch (kind) {
     119            1 :         case core.buffers.execution.DataflowEdgeKind.INLINE_REFERENCE: return DataflowEdgeKind.Inline;
     120            0 :         case core.buffers.execution.DataflowEdgeKind.SCAN_REFERENCE: return DataflowEdgeKind.Scan;
     121            0 :         case core.buffers.execution.DataflowEdgeKind.INVALID_REFERENCE: return DataflowEdgeKind.Invalid;
     122            0 :         case core.buffers.execution.DataflowEdgeKind.INPUT: return DataflowEdgeKind.Input;
     123            1 :         default: return DataflowEdgeKind.Table;
     124            1 :     }
     125            1 : }
     126              : 
     127            1 : function edgeKey(from: DataflowPort, to: DataflowPort, kind: DataflowEdgeKind, name: string): string {
     128            1 :     if (kind === DataflowEdgeKind.Input) return `:${name}>${to.scriptKey}.${to.statementId}:${kind}`;
     129            1 :     return `${from.scriptKey}.${from.statementId}>${to.scriptKey}.${to.statementId}:${kind}:${name}`;
     130            1 : }
     131              : 
     132              : /// Derive the dataflow of the scripts of a session with the core. A script without a current analysis has no
     133              : /// statements and no edges.
     134            1 : function computeDataflowGraph(session: SessionScripts): DataflowGraph {
     135            1 :     const scripts = orderedSessionScripts(session);
     136            1 :     if (scripts.length === 0) return EMPTY_GRAPH;
     137            1 :     const dataflow: core.buffers.execution.SessionDataflowT = session.instance.deriveSessionDataflow(
     138            1 :         sessionPlanRequest(scripts, scripts.map(script => script.fileName), session),
     139            1 :     ).unpack();
     140            1 :     const out: DataflowScript[] = scripts.map((script, index) => {
     141            1 :         const derived = dataflow.scripts[index];
     142            1 :         const analyzed = derived?.analyzed ?? false;
     143            1 :         const text = analyzed ? script.scriptSession.getText() : '';
     144            1 :         const statements = !analyzed ? NO_STATEMENTS : derived.statements.map((statement, statementId) => {
     145            1 :             const from = statement.textSpan?.offset ?? 0;
     146            1 :             const to = from + (statement.textSpan?.length ?? 0);
     147            1 :             const target = statement.target != null ? String(statement.target) : null;
     148            1 :             return {
     149            1 :                 statementId,
     150            1 :                 statementType: statement.statementType,
     151            1 :                 target,
     152            1 :                 from,
     153            1 :                 to,
     154            1 :                 label: statementLabel(text.slice(from, to), target),
     155            1 :             };
     156            1 :         });
     157            1 :         return {
     158            1 :             scriptKey: script.scriptKey,
     159            1 :             fileName: script.fileName,
     160            1 :             connectionId: script.connection.connectionId,
     161            1 :             statements,
     162            1 :             current: analyzed,
     163            1 :         };
     164            1 :     });
     165            1 :     const inputs: DataflowInput[] = dataflow.inputs.map(input => ({
     166            0 :         name: String(input.name ?? ''),
     167            0 :         type: readSessionInputType(input.sqlType, input.typeSource),
     168            0 :         value: input.hasValue ? String(input.value ?? '') : null,
     169            1 :     }));
     170            1 :     const edges: DataflowEdge[] = dataflow.edges.flatMap(edge => {
     171              :         // The canvas draws no attachments yet
     172            1 :         if (edge.kind === core.buffers.execution.DataflowEdgeKind.ATTACHMENT) return [];
     173            1 :         const kind = edgeKind(edge.kind);
     174            1 :         const toScript = scripts[edge.toScript];
     175            1 :         const source = kind === DataflowEdgeKind.Input ? inputs[edge.fromScript] : null;
     176            1 :         const fromScript = kind === DataflowEdgeKind.Input ? null : scripts[edge.fromScript];
     177            1 :         if (toScript == null || (source == null && fromScript == null)) return [];
     178            1 :         const from = fromScript != null
     179            1 :             ? { scriptKey: fromScript.scriptKey, statementId: edge.fromStatement }
     180            0 :             : { scriptKey: INPUT_PORT_SCRIPT_KEY, statementId: 0 };
     181            1 :         const to = { scriptKey: toScript.scriptKey, statementId: edge.toStatement };
     182            1 :         const name = String(edge.name ?? '');
     183            1 :         return [{ key: edgeKey(from, to, kind, name), from, to, kind, name, input: source?.name ?? null }];
     184            1 :     });
     185            1 :     return { scripts: out, inputs, edges };
     186            1 : }
     187              : 
     188            1 : function sameStatements(l: DataflowStatement[], r: DataflowStatement[]): boolean {
     189            1 :     return l.length === r.length && l.every((s, i) => {
     190            1 :         const o = r[i];
     191            1 :         return s.statementType === o.statementType && s.target === o.target && s.from === o.from && s.to === o.to && s.label === o.label;
     192            1 :     });
     193            1 : }
     194              : 
     195            1 : function sameScript(l: DataflowScript, r: DataflowScript): boolean {
     196            1 :     return l.scriptKey === r.scriptKey && l.fileName === r.fileName && l.connectionId === r.connectionId
     197            1 :         && l.current === r.current && sameStatements(l.statements, r.statements);
     198            1 : }
     199              : 
     200              : /// Merge a new graph into the previous one. A script without a current analysis keeps its last statements and the
     201              : /// edges that start or end at it, as long as the other end still exists, until it is analyzed again. Scripts and
     202              : /// edges that did not change keep their identity, as does the graph if nothing changed.
     203            1 : export function mergeDataflowGraph(previous: DataflowGraph | null, next: DataflowGraph): DataflowGraph {
     204            1 :     if (previous == null) return next;
     205            1 :     const before = new Map(previous.scripts.map(s => [s.scriptKey, s]));
     206            1 :     const scripts = next.scripts.map(script => {
     207            1 :         const last = before.get(script.scriptKey);
     208            1 :         if (last == null) return script;
     209            1 :         const merged = !script.current && last.statements.length > 0
     210            1 :             ? { ...script, statements: last.statements, current: false }
     211            1 :             : script;
     212            1 :         return sameScript(merged, last) ? last : merged;
     213            1 :     });
     214            1 :     const stale = new Set(scripts.filter(s => !s.current && s.statements.length > 0).map(s => s.scriptKey));
     215            1 :     const present = new Map(scripts.map(s => [s.scriptKey, s]));
     216            1 :     const exists = (port: DataflowPort) => (present.get(port.scriptKey)?.statements.length ?? 0) > port.statementId;
     217            1 :     const edgesBefore = new Map(previous.edges.map(e => [e.key, e]));
     218            1 :     const edges: DataflowEdge[] = next.edges.map(e => edgesBefore.get(e.key) ?? e);
     219            1 :     const keys = new Set(edges.map(e => e.key));
     220            1 :     for (const edge of previous.edges) {
     221            1 :         if (keys.has(edge.key)) continue;
     222            1 :         if ((stale.has(edge.from.scriptKey) || stale.has(edge.to.scriptKey)) && (edge.input != null || exists(edge.from)) && exists(edge.to)) {
     223            1 :             edges.push(edge);
     224            1 :             keys.add(edge.key);
     225            1 :         }
     226            1 :     }
     227              :     // The inputs of a script that is being edited stay with its edges
     228            1 :     const inputsBefore = new Map(previous.inputs.map(i => [i.name, i]));
     229            1 :     const inputs: DataflowInput[] = next.inputs.map(input => {
     230            0 :         const last = inputsBefore.get(input.name);
     231            0 :         return last != null && JSON.stringify(last) === JSON.stringify(input) ? last : input;
     232            1 :     });
     233            1 :     const names = new Set(inputs.map(i => i.name));
     234            1 :     for (const edge of edges) {
     235            1 :         if (edge.input != null && !names.has(edge.input)) {
     236            0 :             const last = inputsBefore.get(edge.input);
     237            0 :             if (last != null) {
     238            0 :                 inputs.push(last);
     239            0 :                 names.add(last.name);
     240            0 :             }
     241            0 :         }
     242              :     }
     243            1 :     const unchanged = scripts.length === previous.scripts.length && scripts.every((s, i) => s === previous.scripts[i])
     244            1 :         && inputs.length === previous.inputs.length && inputs.every((input, i) => input === previous.inputs[i])
     245            1 :         && edges.length === previous.edges.length && edges.every(e => edgesBefore.get(e.key) === e);
     246            1 :     return unchanged ? previous : { scripts, inputs, edges };
     247            1 : }
     248              : 
     249              : interface CachedGraph {
     250              :     signature: string;
     251              :     graph: DataflowGraph;
     252              : }
     253              : 
     254              : /// The last dataflow of every session, by session id
     255            1 : const GRAPHS = new Map<string, CachedGraph>();
     256              : 
     257              : /// The dataflow of the scripts of a session. Derives it again only when a name, a connection or an analysis of a
     258              : /// script changed, see `mergeDataflowGraph` for what stays.
     259            1 : export function deriveDataflowGraph(session: SessionScripts): DataflowGraph {
     260              :     // Sessions without the core, e.g. of tests, have no dataflow
     261            1 :     if (session.scriptOrder == null || session.scriptOrder.length === 0 || typeof session.instance?.deriveSessionDataflow !== 'function') {
     262            0 :         return EMPTY_GRAPH;
     263              :     }
     264            1 :     const signature = sessionAnalysisSignature(session);
     265            1 :     const cached = GRAPHS.get(session.sessionId);
     266            1 :     if (cached?.signature === signature) return cached.graph;
     267            1 :     let graph: DataflowGraph;
     268            1 :     try {
     269            1 :         graph = mergeDataflowGraph(cached?.graph ?? null, computeDataflowGraph(session));
     270            1 :     } catch (e) {
     271            0 :         console.warn('cannot derive the dataflow of the session', e);
     272            0 :         return cached?.graph ?? EMPTY_GRAPH;
     273            0 :     }
     274            1 :     GRAPHS.set(session.sessionId, { signature, graph });
     275            1 :     return graph;
     276            1 : }
     277              : 
     278              : /// Forget the dataflow of a session
     279            1 : export function forgetDataflowGraph(sessionId: string): void {
     280            1 :     GRAPHS.delete(sessionId);
     281            1 : }
        

Generated by: LCOV version 2.0-1