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