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