xref: /aosp_15_r20/external/perfetto/ui/src/components/widgets/sql/details/details.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1// Copyright (C) 2023 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import m from 'mithril';
16import {Brand} from '../../../../base/brand';
17import {Time} from '../../../../base/time';
18import {exists} from '../../../../base/utils';
19import {raf} from '../../../../core/raf_scheduler';
20import {Engine} from '../../../../trace_processor/engine';
21import {Row} from '../../../../trace_processor/query_result';
22import {
23  SqlValue,
24  sqlValueToReadableString,
25} from '../../../../trace_processor/sql_utils';
26import {Arg, getArgs} from '../../../sql_utils/args';
27import {asArgSetId} from '../../../sql_utils/core_types';
28import {Anchor} from '../../../../widgets/anchor';
29import {renderError} from '../../../../widgets/error';
30import {SqlRef} from '../../../../widgets/sql_ref';
31import {Tree, TreeNode} from '../../../../widgets/tree';
32import {hasArgs, renderArguments} from '../../../details/slice_args';
33import {DurationWidget} from '../../../widgets/duration';
34import {Timestamp as TimestampWidget} from '../../../widgets/timestamp';
35import {sqlIdRegistry} from './sql_ref_renderer_registry';
36import {Trace} from '../../../../public/trace';
37
38// This file contains the helper to render the details tree (based on Tree
39// widget) for an object represented by a SQL row in some table. The user passes
40// a typed schema of the tree and this impl handles fetching and rendering.
41//
42// The following types are supported:
43// Containers:
44//  - dictionary (keys should be strings)
45//  - array
46// Primitive values:
47//  - number, string, timestamp, duration, interval and thread interval.
48//  - id into another sql table.
49//  - arg set id.
50//
51// For each primitive value, the user should specify a SQL expression (usually
52// just the column name). Each primitive value can be auto-skipped if the
53// underlying SQL value is null (skipIfNull). Each container can be auto-skipped
54// if empty (skipIfEmpty).
55//
56// Example of a schema:
57// {
58//  'Navigation ID': 'navigation_id',
59//  'beforeunload': SqlIdRef({
60//    source: 'beforeunload_slice_id',
61//    table: 'chrome_frame_tree_nodes.id',
62//   }),
63//   'initiator_origin': String({
64//      source: 'initiator_origin',
65//      skipIfNull: true,
66//   }),
67//   'committed_render_frame_host': {
68//     'Process ID' : 'committed_render_frame_host_process_id',
69//     'RFH ID': 'committed_render_frame_host_rfh_id',
70//   },
71//   'initial_render_frame_host': Dict({
72//     data: {
73//       'Process ID': 'committed_render_frame_host_process_id',
74//       'RFH ID': 'committed_render_frame_host_rfh_id',
75//     },
76//     preview: 'printf("id=%d:%d")', committed_render_frame_host_process_id,
77//     committed_render_frame_host_rfh_id)', skipIfEmpty: true,
78//   })
79// }
80
81// === Public API surface ===
82
83export namespace DetailsSchema {
84  // Create a dictionary object for the schema.
85  export function Dict(
86    args: {data: {[key: string]: ValueDesc}} & ContainerParams,
87  ): DictSchema {
88    return new DictSchema(args.data, {
89      skipIfEmpty: args.skipIfEmpty,
90    });
91  }
92
93  // Create an array object for the schema.
94  export function Arr(
95    args: {data: ValueDesc[]} & ContainerParams,
96  ): ArraySchema {
97    return new ArraySchema(args.data, {
98      skipIfEmpty: args.skipIfEmpty,
99    });
100  }
101
102  // Create an object representing a timestamp for the schema.
103  // |ts| — SQL expression (e.g. column name) for the timestamp.
104  export function Timestamp(
105    ts: string,
106    args?: ScalarValueParams,
107  ): ScalarValueSchema {
108    return new ScalarValueSchema('timestamp', ts, args);
109  }
110
111  // Create an object representing a duration for the schema.
112  // |dur| — SQL expression (e.g. column name) for the duration.
113  export function Duration(
114    dur: string,
115    args?: ScalarValueParams,
116  ): ScalarValueSchema {
117    return new ScalarValueSchema('duration', dur, args);
118  }
119
120  // Create an object representing a time interval (timestamp + duration)
121  // for the schema.
122  // |ts|, |dur| - SQL expressions (e.g. column names) for the timestamp
123  // and duration.
124  export function Interval(
125    ts: string,
126    dur: string,
127    args?: ScalarValueParams,
128  ): IntervalSchema {
129    return new IntervalSchema(ts, dur, args);
130  }
131
132  // Create an object representing a combination of time interval and thread for
133  // the schema.
134  // |ts|, |dur|, |utid| - SQL expressions (e.g. column names) for the
135  // timestamp, duration and unique thread id.
136  export function ThreadInterval(
137    ts: string,
138    dur: string,
139    utid: string,
140    args?: ScalarValueParams,
141  ): ThreadIntervalSchema {
142    return new ThreadIntervalSchema(ts, dur, utid, args);
143  }
144
145  // Create an object representing a reference to an arg set for the schema.
146  // |argSetId| - SQL expression (e.g. column name) for the arg set id.
147  export function ArgSetId(
148    argSetId: string,
149    args?: ScalarValueParams,
150  ): ScalarValueSchema {
151    return new ScalarValueSchema('arg_set_id', argSetId, args);
152  }
153
154  // Create an object representing a SQL value for the schema.
155  // |value| - SQL expression (e.g. column name) for the value.
156  export function Value(
157    value: string,
158    args?: ScalarValueParams,
159  ): ScalarValueSchema {
160    return new ScalarValueSchema('value', value, args);
161  }
162
163  // Create an object representing string-rendered-as-url for the schema.
164  // |value| - SQL expression (e.g. column name) for the value.
165  export function URLValue(
166    value: string,
167    args?: ScalarValueParams,
168  ): ScalarValueSchema {
169    return new ScalarValueSchema('url', value, args);
170  }
171
172  export function Boolean(
173    value: string,
174    args?: ScalarValueParams,
175  ): ScalarValueSchema {
176    return new ScalarValueSchema('boolean', value, args);
177  }
178
179  // Create an object representing a reference to a SQL table row in the schema.
180  // |table| - name of the table.
181  // |id| - SQL expression (e.g. column name) for the id.
182  export function SqlIdRef(
183    table: string,
184    id: string,
185    args?: ScalarValueParams,
186  ): SqlIdRefSchema {
187    return new SqlIdRefSchema(table, id, args);
188  }
189} // namespace DetailsSchema
190
191// Params which apply to scalar values (i.e. all non-dicts and non-arrays).
192type ScalarValueParams = {
193  skipIfNull?: boolean;
194};
195
196// Params which apply to containers (dicts and arrays).
197type ContainerParams = {
198  skipIfEmpty?: boolean;
199};
200
201// Definition of a node in the schema.
202export type ValueDesc =
203  | DictSchema
204  | ArraySchema
205  | ScalarValueSchema
206  | IntervalSchema
207  | ThreadIntervalSchema
208  | SqlIdRefSchema
209  | string
210  | ValueDesc[]
211  | {[key: string]: ValueDesc};
212
213// Class responsible for fetching the data and rendering the data.
214export class Details {
215  constructor(
216    private trace: Trace,
217    private sqlTable: string,
218    private id: number,
219    schema: {[key: string]: ValueDesc},
220  ) {
221    this.dataController = new DataController(
222      trace,
223      sqlTable,
224      id,
225      sqlIdRegistry,
226    );
227
228    this.resolvedSchema = {
229      kind: 'dict',
230      data: Object.fromEntries(
231        Object.entries(schema).map(([key, value]) => [
232          key,
233          resolve(value, this.dataController),
234        ]),
235      ),
236    };
237    this.dataController.fetch();
238  }
239
240  isLoading() {
241    return this.dataController.data === undefined;
242  }
243
244  render(): m.Children {
245    if (this.dataController.data === undefined) {
246      return m('h2', 'Loading');
247    }
248    const nodes = [];
249    for (const [key, value] of Object.entries(this.resolvedSchema.data)) {
250      nodes.push(
251        renderValue(
252          this.trace,
253          key,
254          value,
255          this.dataController.data,
256          this.dataController.sqlIdRefRenderers,
257        ),
258      );
259    }
260    nodes.push(
261      m(TreeNode, {
262        left: 'SQL ID',
263        right: m(SqlRef, {
264          table: this.sqlTable,
265          id: this.id,
266        }),
267      }),
268    );
269    return m(Tree, nodes);
270  }
271
272  private dataController: DataController;
273  private resolvedSchema: ResolvedDict;
274}
275
276// Type corresponding to a value which can be rendered as a part of the tree:
277// basically, it's TreeNode component without its left part.
278export type RenderedValue = {
279  // The value that should be rendered as the right part of the corresponding
280  // TreeNode.
281  value: m.Children;
282  // Values that should be rendered as the children of the corresponding
283  // TreeNode.
284  children?: m.Children;
285};
286
287// Type describing how render an id into a given table, split into
288// async `fetch` step for fetching data and sync `render` step for generating
289// the vdom.
290export type SqlIdRefRenderer = {
291  fetch: (engine: Engine, id: bigint) => Promise<{} | undefined>;
292  render: (data: {}) => RenderedValue;
293};
294
295// === Impl details ===
296
297// Resolved index into the list of columns / expression to fetch.
298type ExpressionIndex = Brand<number, 'expression_index'>;
299// Arg sets and SQL references require a separate query to fetch the data and
300// therefore are tracked separately.
301type ArgSetIndex = Brand<number, 'arg_set_id_index'>;
302type SqlIdRefIndex = Brand<number, 'sql_id_ref'>;
303
304// Description is passed by the user and then the data is resolved into
305// "resolved" versions of the types. Description focuses on the end-user
306// ergonomics, while "Resolved" optimises for internal processing.
307
308// Description of a dict in the schema.
309class DictSchema {
310  constructor(
311    public data: {[key: string]: ValueDesc},
312    public params?: ContainerParams,
313  ) {}
314}
315
316// Resolved version of a dict.
317type ResolvedDict = {
318  kind: 'dict';
319  data: {[key: string]: ResolvedValue};
320} & ContainerParams;
321
322// Description of an array in the schema.
323class ArraySchema {
324  constructor(
325    public data: ValueDesc[],
326    public params?: ContainerParams,
327  ) {}
328}
329
330// Resolved version of an array.
331type ResolvedArray = {
332  kind: 'array';
333  data: ResolvedValue[];
334} & ContainerParams;
335
336// Schema for all simple scalar values (ones that need to fetch only one value
337// from SQL).
338class ScalarValueSchema {
339  constructor(
340    public kind:
341      | 'timestamp'
342      | 'duration'
343      | 'arg_set_id'
344      | 'value'
345      | 'url'
346      | 'boolean',
347    public sourceExpression: string,
348    public params?: ScalarValueParams,
349  ) {}
350}
351
352// Resolved version of simple scalar values.
353type ResolvedScalarValue = {
354  kind: 'timestamp' | 'duration' | 'value' | 'url' | 'boolean';
355  source: ExpressionIndex;
356} & ScalarValueParams;
357
358// Resolved version of arg set.
359type ResolvedArgSet = {
360  kind: 'arg_set_id';
361  source: ArgSetIndex;
362} & ScalarValueParams;
363
364// Schema for a time interval (ts, dur pair).
365class IntervalSchema {
366  constructor(
367    public ts: string,
368    public dur: string,
369    public params?: ScalarValueParams,
370  ) {}
371}
372
373// Resolved version of a time interval.
374type ResolvedInterval = {
375  kind: 'interval';
376  ts: ExpressionIndex;
377  dur: ExpressionIndex;
378} & ScalarValueParams;
379
380// Schema for a time interval for a given thread (ts, dur, utid triple).
381class ThreadIntervalSchema {
382  constructor(
383    public ts: string,
384    public dur: string,
385    public utid: string,
386    public params?: ScalarValueParams,
387  ) {}
388}
389
390// Resolved version of a time interval for a given thread.
391type ResolvedThreadInterval = {
392  kind: 'thread_interval';
393  ts: ExpressionIndex;
394  dur: ExpressionIndex;
395  utid: ExpressionIndex;
396} & ScalarValueParams;
397
398// Schema for a reference to a SQL table row.
399class SqlIdRefSchema {
400  constructor(
401    public table: string,
402    public id: string,
403    public params?: ScalarValueParams,
404  ) {}
405}
406
407type ResolvedSqlIdRef = {
408  kind: 'sql_id_ref';
409  ref: SqlIdRefIndex;
410} & ScalarValueParams;
411
412type ResolvedValue =
413  | ResolvedDict
414  | ResolvedArray
415  | ResolvedScalarValue
416  | ResolvedArgSet
417  | ResolvedInterval
418  | ResolvedThreadInterval
419  | ResolvedSqlIdRef;
420
421// Helper class to store the error messages while fetching the data.
422class Err {
423  constructor(public message: string) {}
424}
425
426// Fetched data from SQL which is needed to render object according to the given
427// schema.
428interface Data {
429  // Source of the expressions that were fetched.
430  valueExpressions: string[];
431  // Fetched values.
432  values: SqlValue[];
433
434  // Source statements for the arg sets.
435  argSetExpressions: string[];
436  // Fetched arg sets.
437  argSets: (Arg[] | Err)[];
438
439  // Source statements for the SQL references.
440  sqlIdRefs: {tableName: string; idExpression: string}[];
441  // Fetched data for the SQL references.
442  sqlIdRefData: (
443    | {
444        data: {};
445        id: bigint | null;
446      }
447    | Err
448  )[];
449}
450
451// Class responsible for collecting the description of the data to fetch and
452// fetching it.
453class DataController {
454  // List of expressions to fetch. Resolved values will have indexes into this
455  // list.
456  expressions: string[] = [];
457  // List of arg sets to fetch. Arg set ids are fetched first (together with
458  // other scalar values as a part of the `expressions` list) and then the arg
459  // sets themselves are fetched.
460  argSets: ExpressionIndex[] = [];
461  // List of SQL references to fetch. SQL reference ids are fetched first
462  // (together with other scalar values as a part of the `expressions` list) and
463  // then the SQL references themselves are fetched.
464  sqlIdRefs: {id: ExpressionIndex; tableName: string}[] = [];
465
466  // Fetched data.
467  data?: Data;
468
469  constructor(
470    private trace: Trace,
471    private sqlTable: string,
472    private id: number,
473    public sqlIdRefRenderers: {[table: string]: SqlIdRefRenderer},
474  ) {}
475
476  // Fetch the data. `expressions` and other lists must be populated first by
477  // resolving the schema.
478  async fetch() {
479    const data: Data = {
480      valueExpressions: this.expressions,
481      values: [],
482      argSetExpressions: this.argSets.map((index) => this.expressions[index]),
483      argSets: [],
484      sqlIdRefs: this.sqlIdRefs.map((ref) => ({
485        tableName: ref.tableName,
486        idExpression: this.expressions[ref.id],
487      })),
488      sqlIdRefData: [],
489    };
490
491    // Helper to generate the labels for the expressions.
492    const label = (index: number) => `col_${index}`;
493
494    // Fetch the scalar values for the basic expressions.
495    const row: Row = (
496      await this.trace.engine.query(`
497      SELECT
498        ${this.expressions
499          .map((value, index) => `${value} as ${label(index)}`)
500          .join(',\n')}
501      FROM ${this.sqlTable}
502      WHERE id = ${this.id}
503    `)
504    ).firstRow({});
505    for (let i = 0; i < this.expressions.length; ++i) {
506      data.values.push(row[label(i)]);
507    }
508
509    // Fetch the arg sets based on the fetched arg set ids.
510    for (const argSetIndex of this.argSets) {
511      const argSetId = data.values[argSetIndex];
512      if (argSetId === null) {
513        data.argSets.push([]);
514      } else if (typeof argSetId !== 'number' && typeof argSetId !== 'bigint') {
515        data.argSets.push(
516          new Err(
517            `Incorrect type for arg set ${
518              data.argSetExpressions[argSetIndex]
519            }: expected a number, got ${typeof argSetId} instead}`,
520          ),
521        );
522      } else {
523        data.argSets.push(
524          await getArgs(this.trace.engine, asArgSetId(Number(argSetId))),
525        );
526      }
527    }
528
529    // Fetch the data for SQL references based on fetched ids.
530    for (const ref of this.sqlIdRefs) {
531      const renderer = this.sqlIdRefRenderers[ref.tableName];
532      if (renderer === undefined) {
533        data.sqlIdRefData.push(new Err(`Unknown table ${ref.tableName}`));
534        continue;
535      }
536      const id = data.values[ref.id];
537      if (id === null) {
538        data.sqlIdRefData.push({data: {}, id});
539        continue;
540      } else if (typeof id !== 'bigint') {
541        data.sqlIdRefData.push(
542          new Err(
543            `Incorrect type for SQL reference ${
544              data.valueExpressions[ref.id]
545            }: expected a bigint, got ${typeof id} instead}`,
546          ),
547        );
548        continue;
549      }
550      const refData = await renderer.fetch(this.trace.engine, id);
551      if (refData === undefined) {
552        data.sqlIdRefData.push(
553          new Err(
554            `Failed to fetch the data with id ${id} for table ${ref.tableName}`,
555          ),
556        );
557        continue;
558      }
559      data.sqlIdRefData.push({data: refData, id});
560    }
561
562    this.data = data;
563    raf.scheduleFullRedraw();
564  }
565
566  // Add a given expression to the list of expressions to fetch and return its
567  // index.
568  addExpression(expr: string): ExpressionIndex {
569    const result = this.expressions.length;
570    this.expressions.push(expr);
571    return result as ExpressionIndex;
572  }
573
574  // Add a given arg set to the list of arg sets to fetch and return its index.
575  addArgSet(expr: string): ArgSetIndex {
576    const result = this.argSets.length;
577    this.argSets.push(this.addExpression(expr));
578    return result as ArgSetIndex;
579  }
580
581  // Add a given SQL reference to the list of SQL references to fetch and return
582  // its index.
583  addSqlIdRef(tableName: string, idExpr: string): SqlIdRefIndex {
584    const result = this.sqlIdRefs.length;
585    this.sqlIdRefs.push({
586      tableName,
587      id: this.addExpression(idExpr),
588    });
589    return result as SqlIdRefIndex;
590  }
591}
592
593// Resolve a given schema into a resolved version, normalising the schema and
594// computing the list of data to fetch.
595function resolve(schema: ValueDesc, data: DataController): ResolvedValue {
596  if (typeof schema === 'string') {
597    return {
598      kind: 'value',
599      source: data.addExpression(schema),
600    };
601  }
602  if (Array.isArray(schema)) {
603    return {
604      kind: 'array',
605      data: schema.map((x) => resolve(x, data)),
606    };
607  }
608  if (schema instanceof ArraySchema) {
609    return {
610      kind: 'array',
611      data: schema.data.map((x) => resolve(x, data)),
612      ...schema.params,
613    };
614  }
615  if (schema instanceof ScalarValueSchema) {
616    if (schema.kind === 'arg_set_id') {
617      return {
618        kind: schema.kind,
619        source: data.addArgSet(schema.sourceExpression),
620        ...schema.params,
621      };
622    } else {
623      return {
624        kind: schema.kind,
625        source: data.addExpression(schema.sourceExpression),
626        ...schema.params,
627      };
628    }
629  }
630  if (schema instanceof IntervalSchema) {
631    return {
632      kind: 'interval',
633      ts: data.addExpression(schema.ts),
634      dur: data.addExpression(schema.dur),
635      ...schema.params,
636    };
637  }
638  if (schema instanceof ThreadIntervalSchema) {
639    return {
640      kind: 'thread_interval',
641      ts: data.addExpression(schema.ts),
642      dur: data.addExpression(schema.dur),
643      utid: data.addExpression(schema.utid),
644      ...schema.params,
645    };
646  }
647  if (schema instanceof SqlIdRefSchema) {
648    return {
649      kind: 'sql_id_ref',
650      ref: data.addSqlIdRef(schema.table, schema.id),
651      ...schema.params,
652    };
653  }
654  if (schema instanceof DictSchema) {
655    return {
656      kind: 'dict',
657      data: Object.fromEntries(
658        Object.entries(schema.data).map(([key, value]) => [
659          key,
660          resolve(value, data),
661        ]),
662      ),
663      ...schema.params,
664    };
665  }
666  return {
667    kind: 'dict',
668    data: Object.fromEntries(
669      Object.entries(schema).map(([key, value]) => [key, resolve(value, data)]),
670    ),
671  };
672}
673
674// Generate the vdom for a given value using the fetched `data`.
675function renderValue(
676  trace: Trace,
677  key: string,
678  value: ResolvedValue,
679  data: Data,
680  sqlIdRefRenderers: {[table: string]: SqlIdRefRenderer},
681): m.Children {
682  switch (value.kind) {
683    case 'value':
684      if (data.values[value.source] === null && value.skipIfNull) return null;
685      return m(TreeNode, {
686        left: key,
687        right: sqlValueToReadableString(data.values[value.source]),
688      });
689    case 'url': {
690      const url = data.values[value.source];
691      let rhs: m.Children;
692      if (url === null) {
693        if (value.skipIfNull) return null;
694        rhs = renderNull();
695      } else if (typeof url !== 'string') {
696        rhs = renderError(
697          `Incorrect type for URL ${
698            data.valueExpressions[value.source]
699          }: expected string, got ${typeof url}`,
700        );
701      } else {
702        rhs = m(
703          Anchor,
704          {href: url, target: '_blank', icon: 'open_in_new'},
705          url,
706        );
707      }
708      return m(TreeNode, {
709        left: key,
710        right: rhs,
711      });
712    }
713    case 'boolean': {
714      const bool = data.values[value.source];
715      if (bool === null && value.skipIfNull) return null;
716      let rhs: m.Child;
717      if (typeof bool !== 'bigint' && typeof bool !== 'number') {
718        rhs = renderError(
719          `Incorrect type for boolean ${
720            data.valueExpressions[value.source]
721          }: expected bigint or number, got ${typeof bool}`,
722        );
723      } else {
724        rhs = bool ? 'true' : 'false';
725      }
726      return m(TreeNode, {left: key, right: rhs});
727    }
728    case 'timestamp': {
729      const ts = data.values[value.source];
730      let rhs: m.Child;
731      if (ts === null) {
732        if (value.skipIfNull) return null;
733        rhs = m('i', 'NULL');
734      } else if (typeof ts !== 'bigint') {
735        rhs = renderError(
736          `Incorrect type for timestamp ${
737            data.valueExpressions[value.source]
738          }: expected bigint, got ${typeof ts}`,
739        );
740      } else {
741        rhs = m(TimestampWidget, {
742          ts: Time.fromRaw(ts),
743        });
744      }
745      return m(TreeNode, {
746        left: key,
747        right: rhs,
748      });
749    }
750    case 'duration': {
751      const dur = data.values[value.source];
752      return m(TreeNode, {
753        left: key,
754        right:
755          typeof dur === 'bigint' &&
756          m(DurationWidget, {
757            dur,
758          }),
759      });
760    }
761    case 'interval':
762    case 'thread_interval': {
763      const dur = data.values[value.dur];
764      return m(TreeNode, {
765        left: key,
766        right:
767          typeof dur === 'bigint' &&
768          m(DurationWidget, {
769            dur,
770          }),
771      });
772    }
773    case 'sql_id_ref':
774      const ref = data.sqlIdRefs[value.ref];
775      const refData = data.sqlIdRefData[value.ref];
776      let rhs: m.Children;
777      let children: m.Children;
778      if (refData instanceof Err) {
779        rhs = renderError(refData.message);
780      } else if (refData.id === null && value.skipIfNull === true) {
781        rhs = renderNull();
782      } else {
783        const renderer = sqlIdRefRenderers[ref.tableName];
784        if (renderer === undefined) {
785          rhs = renderError(
786            `Unknown table ${ref.tableName} (${ref.tableName}[${refData.id}])`,
787          );
788        } else {
789          const rendered = renderer.render(refData.data);
790          rhs = rendered.value;
791          children = rendered.children;
792        }
793      }
794      return m(
795        TreeNode,
796        {
797          left: key,
798          right: rhs,
799        },
800        children,
801      );
802    case 'arg_set_id':
803      const args = data.argSets[value.source];
804      if (args instanceof Err) {
805        return renderError(args.message);
806      }
807      return (
808        hasArgs(args) &&
809        m(
810          TreeNode,
811          {
812            left: key,
813          },
814          renderArguments(trace, args),
815        )
816      );
817    case 'array': {
818      const children: m.Children[] = [];
819      for (const child of value.data) {
820        const renderedChild = renderValue(
821          trace,
822          `[${children.length}]`,
823          child,
824          data,
825          sqlIdRefRenderers,
826        );
827        if (exists(renderedChild)) {
828          children.push(renderedChild);
829        }
830      }
831      if (children.length === 0 && value.skipIfEmpty) {
832        return null;
833      }
834      return m(
835        TreeNode,
836        {
837          left: key,
838        },
839        children,
840      );
841    }
842    case 'dict': {
843      const children: m.Children[] = [];
844      for (const [key, val] of Object.entries(value.data)) {
845        const child = renderValue(trace, key, val, data, sqlIdRefRenderers);
846        if (exists(child)) {
847          children.push(child);
848        }
849      }
850      if (children.length === 0 && value.skipIfEmpty) {
851        return null;
852      }
853      return m(
854        TreeNode,
855        {
856          left: key,
857        },
858        children,
859      );
860    }
861  }
862}
863
864function renderNull(): m.Children {
865  return m('i', 'NULL');
866}
867