LCOV - code coverage report
Current view: top level - dashql-app/src/compute - analyze_table.ts Coverage Total Hit
Test: DashQL 915828b Lines: 100.0 % 182 182
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as arrow from 'apache-arrow';
       2              : 
       3              : import { DataFrame, generateTableName } from './data_frame.js';
       4              : import { COLUMN_AGGREGATION_TASK, SYSTEM_COLUMN_COMPUTATION_TASK, TABLE_AGGREGATION_TASK } from './computation_types.js';
       5              : import { COMPUTATION_FROM_QUERY_RESULT, ComputationAction, CREATED_DATA_FRAME } from './computation_state.js';
       6              : import { ColumnAggregationTask, TableAggregationTask, ORDINAL_COLUMN, STRING_COLUMN, LIST_COLUMN, ColumnGroup, SKIPPED_COLUMN, SystemColumnComputationTask, ROWNUMBER_COLUMN, ComputationStateVersion } from './computation_types.js';
       7              : import { Dispatch } from '../utils/variant.js';
       8              : import { LoggerLike } from '../platform/logger/logger.js';
       9              : import type { EmbeddedClientContext, EmbeddedComputeDatabase } from '../platform/database/embedded_database.js';
      10              : import { scheduleTask } from './computation_tasks.js';
      11              : import { createUniqueColumnName } from './arrow_columns.js';
      12              : 
      13            1 : const LOG_CTX = "compute";
      14              : 
      15              : /// The number of bins of an ordinal column
      16            1 : const BIN_COUNT = 16;
      17              : 
      18              : /// Options of the analysis of a table
      19              : export interface AnalyzeTableOptions {
      20              :     /// The client context of the queries of a table without a connection, e.g. of the connection overview
      21              :     clientContext?: EmbeddedClientContext | null;
      22              :     /// Compute the aggregates of the columns, e.g. false for a table that shows no column plots
      23              :     columnAggregates?: boolean;
      24              : }
      25              : 
      26              : /// Analyze a table.
      27              : ///
      28              : /// This function computes multiple summaries for displaying the data in the table grid component.
      29              : ///
      30              : /// It consists of multiple steps:
      31              : ///   1) We first global table aggregates.
      32              : ///      For each of the columns we compute minimum, maximum, counts and distinct counts depending on the column type.
      33              : ///   2) We then compute auxiliary system columns storing the input row number and (fractional) bin values in the domain.
      34              : ///   3) We then compute summary tables per column based on these bin values.
      35              : ///      (For example an aggregated histogram table for ordinal columns)
      36              : ///
      37              : /// On Cross-Filters:
      38              : ///   One option for fast cross-filters is to pre-compute summed area tables from Dominik's Falcon paper:
      39              : ///     https://osf.io/preprints/osf/szpqm_v1
      40              : ///   This paper proposes to precompute a summation table to assist brushing.
      41              : ///   The idea is the following:
      42              : ///     A) Aggregate counts for the cube of all cross-filter dimensions (N table columns -> 2^N fields).
      43              : ///        (A, B, C) -> grouping sets ((A, B, C), (A, B), (A, C), (B, C), (A), (B), (C), ())
      44              : ///     B) Compute comulative sums for the aggregates.
      45              : ///        When brushing, we want to compute the count between two boundaries, for which we need cumulative sums.
      46              : ///     C) Cross-filtering then becomes a O(1) lookup in this cube.
      47              : ///
      48              : ///   Problem:
      49              : ///     This is simple when doing this for ordinal columns only, where we control the dimension count.
      50              : ///     For categorical columns, we have the problem that we might want to cross-filter with a *specific* distinct value.
      51              : ///     Precomputing for that is infeasible with higher distinct counts, so we'll have to maintain a non-precomputed path anyway?
      52              : ///     Also, 2^N is not exactly cheap for very wide tables where the user would never visibly see the cross-filter.
      53              : ///
      54              : ///   Alternative:
      55              : ///     We do ad-hoc cross-filter computations on the pre-computed bin fields and only for what is currently visible (!).
      56              : ///     The upside here is that the bin count is dictated by what we can show in the UI and is thus usually small.
      57              : ///     Whenever a user updates a cross-filter (by brushing or selecting a distinct value), we just recompute the column summaries
      58              : ///     with the new set of cross-filters and update the UI.
      59            1 : export async function analyzeTable(tableId: number, table: arrow.Table, dispatch: Dispatch<ComputationAction>, database: EmbeddedComputeDatabase, logger: LoggerLike, connectionId: string | null = null, options: AnalyzeTableOptions = {}): Promise<void> {
      60              :     // Statements such as DDL can legitimately produce an Arrow table without columns. There is
      61              :     // nothing to summarize, and embedded databases reject importing a zero-column table.
      62            1 :     if (table.numCols === 0) {
      63            1 :         return;
      64              :     }
      65              : 
      66            1 :     let gridColumnGroups = buildGridColumnGroups(table!);
      67            1 :     const computeAbortCtrl = new AbortController();
      68            1 :     dispatch({
      69            1 :         type: COMPUTATION_FROM_QUERY_RESULT,
      70            1 :         value: [tableId, table!, gridColumnGroups, computeAbortCtrl, connectionId, options.clientContext ?? null]
      71            1 :     });
      72              : 
      73            1 :     const [numberedTable, rowNumberFieldName] = appendRowNumbers(table);
      74            1 :     const inputTableName = generateTableName("__input");
      75            1 :     let dataFrame = await DataFrame.fromArrowTable(database, numberedTable, inputTableName, options.clientContext ?? null);
      76            1 :     dispatch({
      77            1 :         type: CREATED_DATA_FRAME,
      78            1 :         value: [tableId, dataFrame]
      79            1 :     });
      80              : 
      81              :     // Compute table aggregates
      82            1 :     const tableAggregationTask: TableAggregationTask = {
      83            1 :         tableId,
      84            1 :         tableVersion: new ComputationStateVersion(0, 0),
      85            1 :         columnEntries: gridColumnGroups,
      86            1 :         inputDataFrame: dataFrame
      87            1 :     };
      88            1 :     const [tableAggregate, initialColumnGroups] = await scheduleTask(TABLE_AGGREGATION_TASK, tableAggregationTask, dispatch);
      89            1 :     gridColumnGroups = initialColumnGroups;
      90              : 
      91              :     // Precompute the system columns
      92            1 :     const precomputationTask: SystemColumnComputationTask = {
      93            1 :         tableId,
      94            1 :         tableVersion: new ComputationStateVersion(0, 0),
      95            1 :         columnEntries: gridColumnGroups,
      96            1 :         inputTable: table,
      97            1 :         inputDataFrame: dataFrame,
      98            1 :         rowNumberFieldName,
      99            1 :         tableAggregate
     100            1 :     };
     101            1 :     const [newTable, newDataFrame, updatedColumnGroups] = await scheduleTask(SYSTEM_COLUMN_COMPUTATION_TASK, precomputationTask, dispatch);
     102            1 :     gridColumnGroups = updatedColumnGroups;
     103              : 
     104              :     // Compute the column aggregates
     105            1 :     let colAggregateCount = 0;
     106            1 :     let colAggregateOrdinalCount = 0;
     107            1 :     let colAggregateStringCount = 0;
     108            1 :     let colAggregateSlowestDuration = 0;
     109            1 :     let colAggregateSlowestColumn: string | null = null;
     110            1 :     let colAggregatesBegin = performance.now();
     111            1 :     logger.info("Computing column aggregates", {
     112            1 :         "table": tableId.toString(),
     113            1 :         "rows": newTable.numRows.toString(),
     114            1 :         "columns": gridColumnGroups.length.toString(),
     115            1 :     }, LOG_CTX);
     116              :     // A table without column plots needs no column aggregates
     117            1 :     const aggregatedColumns = options.columnAggregates === false ? 0 : gridColumnGroups.length;
     118            1 :     for (let columnId = 0; columnId < aggregatedColumns; ++columnId) {
     119              :         // LIST columns (e.g. float32 embedding arrays) are not aggregated: their value
     120              :         // identifier / frequent-value / distinct-count computations are very expensive
     121              :         // (full-vector sort/hash) and buy nothing — no summary histogram is rendered for
     122              :         // them. We keep the LIST_COLUMN group but skip all of its embedded-database
     123              :         // aggregation work.
     124            1 :         const columnEntry = gridColumnGroups[columnId];
     125            1 :         if (columnEntry.type == SKIPPED_COLUMN || columnEntry.type == ROWNUMBER_COLUMN || columnEntry.type == LIST_COLUMN) {
     126            1 :             continue;
     127              :         }
     128            1 :         const columnAggregationTask: ColumnAggregationTask = {
     129            1 :             tableId,
     130            1 :             tableVersion: new ComputationStateVersion(0, 0),
     131            1 :             columnId,
     132            1 :             tableAggregate,
     133            1 :             columnEntry,
     134            1 :             inputDataFrame: newDataFrame,
     135            1 :         };
     136              :         // Measured around the dispatch, so this includes the scheduler round trip.
     137            1 :         const columnBegin = performance.now();
     138            1 :         await scheduleTask(COLUMN_AGGREGATION_TASK, columnAggregationTask, dispatch);
     139            1 :         const columnDuration = performance.now() - columnBegin;
     140            1 :         if (columnDuration > colAggregateSlowestDuration) {
     141            1 :             colAggregateSlowestDuration = columnDuration;
     142            1 :             colAggregateSlowestColumn = columnEntry.value.inputFieldName;
     143            1 :         }
     144            1 :         ++colAggregateCount;
     145            1 :         if (columnEntry.type == ORDINAL_COLUMN) {
     146            1 :             ++colAggregateOrdinalCount;
     147              :         } else {
     148            1 :             ++colAggregateStringCount;
     149              :         }
     150            1 :     }
     151            1 :     let colAggregatesEnd = performance.now();
     152              : 
     153            1 :     if (colAggregateCount > 0) {
     154            1 :         const duration = colAggregatesEnd - colAggregatesBegin;
     155            1 :         logger.info("Computed column aggregates", {
     156            1 :             "table": tableId.toString(),
     157            1 :             "rows": newTable.numRows.toString(),
     158            1 :             "aggregates": colAggregateCount.toString(),
     159            1 :             "ordinal": colAggregateOrdinalCount.toString(),
     160            1 :             "string": colAggregateStringCount.toString(),
     161            1 :             "duration": Math.floor(duration).toString(),
     162            1 :             "avgDuration": Math.floor(duration / colAggregateCount).toString(),
     163            1 :             "maxDuration": Math.floor(colAggregateSlowestDuration).toString(),
     164            1 :             "slowestColumn": colAggregateSlowestColumn ?? "",
     165            1 :         }, LOG_CTX);
     166            1 :     }
     167            1 : }
     168              : 
     169              : /// A table with the positions of its rows as an appended row-number column, from 1, and the name of the column.
     170              : /// The row numbers are appended before the import into the embedded database because Hyper numbers the rows of
     171              : /// row_number() OVER () in no particular order.
     172            1 : export function appendRowNumbers(table: arrow.Table): [arrow.Table, string] {
     173            1 :     const fieldName = createUniqueColumnName("_rownum", new Set(table.schema.fields.map(field => field.name)));
     174            1 :     const rowNumbers = BigInt64Array.from({ length: table.numRows }, (_, i) => BigInt(i + 1));
     175            1 :     return [table.assign(new arrow.Table({ [fieldName]: arrow.makeVector(rowNumbers) })), fieldName];
     176            1 : }
     177              : 
     178            1 : export function buildGridColumnGroups(table: arrow.Table): ColumnGroup[] {
     179            1 :     const columnGroups: ColumnGroup[] = [];
     180            1 :     for (let i = 0; i < table.schema.fields.length; ++i) {
     181            1 :         const field = table.schema.fields[i];
     182            1 :         switch (field.typeId) {
     183            1 :             case arrow.Type.Int:
     184            1 :             case arrow.Type.Int8:
     185            1 :             case arrow.Type.Int16:
     186            1 :             case arrow.Type.Int32:
     187            1 :             case arrow.Type.Int64:
     188            1 :             case arrow.Type.Uint8:
     189            1 :             case arrow.Type.Uint16:
     190            1 :             case arrow.Type.Uint32:
     191            1 :             case arrow.Type.Uint64:
     192            1 :             case arrow.Type.Float:
     193            1 :             case arrow.Type.Float16:
     194            1 :             case arrow.Type.Float32:
     195            1 :             case arrow.Type.Float64:
     196            1 :             case arrow.Type.Bool:
     197            1 :             case arrow.Type.Decimal:
     198            1 :             case arrow.Type.Date:
     199            1 :             case arrow.Type.DateDay:
     200            1 :             case arrow.Type.DateMillisecond:
     201            1 :             case arrow.Type.Time:
     202            1 :             case arrow.Type.TimeSecond:
     203            1 :             case arrow.Type.TimeMillisecond:
     204            1 :             case arrow.Type.TimeMicrosecond:
     205            1 :             case arrow.Type.TimeNanosecond:
     206            1 :             case arrow.Type.Timestamp:
     207            1 :             case arrow.Type.TimestampSecond:
     208            1 :             case arrow.Type.TimestampMillisecond:
     209            1 :             case arrow.Type.TimestampMicrosecond:
     210            1 :             case arrow.Type.TimestampNanosecond:
     211            1 :             case arrow.Type.DurationSecond:
     212            1 :             case arrow.Type.DurationMillisecond:
     213            1 :             case arrow.Type.DurationMicrosecond:
     214            1 :             case arrow.Type.DurationNanosecond:
     215            1 :                 columnGroups.push({
     216            1 :                     type: ORDINAL_COLUMN,
     217            1 :                     value: {
     218            1 :                         inputFieldName: field.name,
     219            1 :                         inputFieldType: field.type,
     220            1 :                         inputFieldNullable: field.nullable,
     221            1 :                         statsFields: null,
     222            1 :                         binFieldName: null,
     223            1 :                         binCount: BIN_COUNT
     224            1 :                     }
     225            1 :                 });
     226            1 :                 break;
     227            1 :             case arrow.Type.Utf8:
     228            1 :             case arrow.Type.LargeUtf8:
     229            1 :                 columnGroups.push({
     230            1 :                     type: STRING_COLUMN,
     231            1 :                     value: {
     232            1 :                         inputFieldName: field.name,
     233            1 :                         inputFieldType: field.type,
     234            1 :                         inputFieldNullable: field.nullable,
     235            1 :                         statsFields: null,
     236            1 :                         valueIdFieldName: null,
     237            1 :                     }
     238            1 :                 });
     239            1 :                 break;
     240            1 :             case arrow.Type.List:
     241            1 :             case arrow.Type.FixedSizeList:
     242            1 :                 columnGroups.push({
     243            1 :                     type: LIST_COLUMN,
     244            1 :                     value: {
     245            1 :                         inputFieldName: field.name,
     246            1 :                         inputFieldType: field.type,
     247            1 :                         inputFieldNullable: field.nullable,
     248            1 :                         statsFields: null,
     249            1 :                         valueIdFieldName: null,
     250            1 :                     }
     251            1 :                 });
     252            1 :                 break;
     253            1 :             default:
     254            1 :                 columnGroups.push({
     255            1 :                     type: SKIPPED_COLUMN,
     256            1 :                     value: {
     257            1 :                         inputFieldName: field.name,
     258            1 :                         inputFieldType: field.type,
     259            1 :                         inputFieldNullable: field.nullable,
     260            1 :                     }
     261            1 :                 });
     262              :                 break;
     263            1 :         }
     264            1 :     }
     265            1 :     return columnGroups;
     266            1 : }
        

Generated by: LCOV version 2.0-1