| // Copyright (C) 2024 The Android Open Source Project | |
| // | |
| // Licensed under the Apache License, Version 2.0 (the "License"); | |
| // you may not use this file except in compliance with the License. | |
| // You may obtain a copy of the License at | |
| // | |
| // http://www.apache.org/licenses/LICENSE-2.0 | |
| // | |
| // Unless required by applicable law or agreed to in writing, software | |
| // distributed under the License is distributed on an "AS IS" BASIS, | |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| // See the License for the specific language governing permissions and | |
| // limitations under the License. | |
| import {assertUnreachable} from '../base/assert'; | |
| import {getOrCreate} from '../base/utils'; | |
| import { | |
| checkExtends, | |
| NUM, | |
| type SpecType, | |
| type SqlValue, | |
| unionTypes, | |
| UNKNOWN, | |
| } from './query_result'; | |
| import {sqlValueToSqliteString} from './sql_utils'; | |
| /** | |
| * A dataset defines a set of rows in TraceProcessor and a schema of the | |
| * resultant columns. Dataset implementations describe how to get the data in | |
| * different ways - e.g. 'source' datasets define a dataset as a table name (or | |
| * select statement) + filters, whereas a 'union' dataset defines a dataset as | |
| * the union of other datasets. | |
| * | |
| * The idea is that users can build arbitrarily complex trees of datasets, then | |
| * call `query()` which automatically optimizes the dataset and produces an | |
| * optimal select statement. The `optimize()` method can also be called manually | |
| * if you need a reference to the optimized dataset. | |
| * | |
| * Users can also use the `schema` property and `implements()` to get and test | |
| * the schema of a given dataset. | |
| */ | |
| export interface Dataset<T extends DatasetSchema = DatasetSchema> { | |
| /** | |
| * Get or calculate the resultant schema of this dataset. | |
| */ | |
| readonly schema: T; | |
| /** | |
| * Produce an optimized query for this dataset. | |
| * | |
| * This automatically optimizes the dataset before generating the query to | |
| * ensure the most efficient SQL is produced. | |
| * | |
| * @param schema - The schema to use for extracting columns - if undefined, | |
| * the most specific possible schema is evaluated from the dataset first and | |
| * used instead. | |
| */ | |
| query(schema?: DatasetSchema): string; | |
| /** | |
| * Returns true if this dataset implements a given schema. | |
| * | |
| * @param schema - The schema to test against. | |
| */ | |
| implements<T extends DatasetSchema>(schema: T): this is Dataset<T>; | |
| } | |
| /** | |
| * Defines a list of columns and types that define the shape of the data | |
| * represented by a dataset. Uses the same type as the query result spec | |
| * so that dataset schemas can be passed directly to iter()/decodeColumns(). | |
| */ | |
| export type DatasetSchema = SpecType; | |
| /** | |
| * A filter used to express that a column must equal a value. | |
| */ | |
| interface EqFilter { | |
| readonly col: string; | |
| readonly eq: SqlValue; | |
| } | |
| /** | |
| * A filter used to express that column must be one of a set of values. | |
| */ | |
| interface InFilter { | |
| readonly col: string; | |
| readonly in: ReadonlyArray<SqlValue>; | |
| } | |
| /** | |
| * Union of all filter types. | |
| */ | |
| type Filter = EqFilter | InFilter; | |
| /** | |
| * Defines a join to be applied to the dataset. | |
| */ | |
| interface Join { | |
| /** | |
| * The SQL join expression, including the table name and join condition. | |
| * Example: 'thread USING (utid)' or 'process ON slice.upid = process.id' | |
| */ | |
| readonly from: string; | |
| /** | |
| * Optional hint indicating this is a unique (1:1) join. This can be used | |
| * for query optimization. | |
| */ | |
| readonly unique?: boolean; | |
| } | |
| /** | |
| * Defines a column selection with an optional join reference. | |
| */ | |
| interface SelectColumn { | |
| /** | |
| * The SQL expression or column name to select. | |
| */ | |
| readonly expr: string; | |
| /** | |
| * Optional join identifier that this column references. | |
| * This helps track which join a column comes from for optimization. | |
| */ | |
| readonly join?: string; | |
| } | |
| /** | |
| * Column selection can be either a simple string (expression/column name) | |
| * or an object with expr and optional join reference. | |
| */ | |
| type SelectValue = string | SelectColumn; | |
| /** | |
| * Named arguments for a SourceDataset. | |
| */ | |
| interface SourceDatasetConfig<T extends DatasetSchema> { | |
| readonly src: string; | |
| readonly schema: T; | |
| readonly filter?: Filter; | |
| /** | |
| * Optional column mappings from schema column names to source expressions. | |
| * Each value can be: | |
| * - A string: simple column name or expression | |
| * - An object: {expr: 'column', join: 'join-id'} to reference a specific join | |
| * Example: {id: 'id', name: {expr: 'name', join: 'thread'}} | |
| */ | |
| readonly select?: Readonly<Record<keyof T, SelectValue>>; | |
| /** | |
| * Optional joins to apply to the dataset. Each join is identified by a | |
| * unique key and contains the join expression and optional optimization hints. | |
| * Example: {thread: {from: 'thread USING (utid)', unique: true}} | |
| */ | |
| readonly joins?: Readonly<Record<string, Join>>; | |
| } | |
| /** | |
| * Defines a dataset with a source SQL select statement of table name, a | |
| * schema describing the columns, and an optional filter. | |
| */ | |
| export class SourceDataset< | |
| T extends DatasetSchema = DatasetSchema, | |
| > implements Dataset<T> { | |
| readonly src: string; | |
| readonly schema: T; | |
| readonly filter?: Filter; | |
| readonly select?: Readonly<Record<keyof T, SelectValue>>; | |
| readonly joins?: Readonly<Record<string, Join>>; | |
| constructor(config: SourceDatasetConfig<T>) { | |
| this.src = config.src; | |
| this.schema = config.schema; | |
| this.filter = config.filter; | |
| this.select = config.select; | |
| this.joins = config.joins; | |
| } | |
| query(schema?: DatasetSchema) { | |
| schema = schema ?? this.schema; | |
| const colNames = Object.keys(schema); | |
| // Track which joins are referenced in select statements | |
| const referencedJoins = new Set<string>(); | |
| // Build the SELECT clause with column mappings if provided | |
| const selectCols = colNames.map((col) => { | |
| const selectValue = this.select?.[col as keyof T]; | |
| if (selectValue === undefined) { | |
| return col; | |
| } | |
| // Extract the expression and join reference | |
| let expr: string; | |
| if (typeof selectValue === 'string') { | |
| expr = selectValue; | |
| } else { | |
| expr = selectValue.expr; | |
| // Track the join reference if specified | |
| if (selectValue.join) { | |
| referencedJoins.add(selectValue.join); | |
| } | |
| } | |
| // Only add AS clause if the expression differs from the column name | |
| if (expr !== col) { | |
| return `${expr} AS ${col}`; | |
| } | |
| return col; | |
| }); | |
| // Build the FROM clause with joins if provided | |
| // Only include joins that are either: | |
| // 1. Referenced in a select statement, OR | |
| // 2. Not marked as unique (i.e., unique is false or undefined) | |
| let fromClause = `(${this.src})`; | |
| if (this.joins) { | |
| for (const [joinId, join] of Object.entries(this.joins)) { | |
| // Skip unique joins that aren't referenced | |
| if (join.unique && !referencedJoins.has(joinId)) { | |
| continue; | |
| } | |
| // Insert the alias (joinId) after the table name | |
| // e.g., "thread USING (utid)" becomes "thread AS thread USING (utid)" | |
| const spaceIndex = join.from.indexOf(' '); | |
| let joinClause: string; | |
| if (spaceIndex === -1) { | |
| // No space found, just the table name | |
| joinClause = `${join.from} AS ${joinId}`; | |
| } else { | |
| // Insert alias after first word (table name) | |
| const tableName = join.from.substring(0, spaceIndex); | |
| const rest = join.from.substring(spaceIndex); | |
| joinClause = `${tableName} AS ${joinId}${rest}`; | |
| } | |
| fromClause += ` JOIN ${joinClause}`; | |
| } | |
| } | |
| const colList = selectCols.join(',\n'); | |
| const selectSql = `SELECT | |
| ${indent(colList, 2)} | |
| FROM ${fromClause}`; | |
| const filterSql = this.filterQuery(); | |
| if (filterSql === undefined) { | |
| return selectSql; | |
| } | |
| return `${selectSql} | |
| WHERE ${filterSql}`; | |
| } | |
| implements<T extends DatasetSchema>(required: T): this is Dataset<T> { | |
| return Object.entries(required).every(([name, required]) => { | |
| return name in this.schema && checkExtends(required, this.schema[name]); | |
| }); | |
| } | |
| // Convert filter to a SQL expression (without the where clause), or undefined | |
| // if we have no filter. | |
| private filterQuery() { | |
| if (!this.filter) return undefined; | |
| if ('eq' in this.filter) { | |
| return `${this.filter.col} = ${sqlValueToSqliteString(this.filter.eq)}`; | |
| } else if ('in' in this.filter) { | |
| return `${this.filter.col} IN (${sqlValueToSqliteString(this.filter.in)})`; | |
| } else { | |
| assertUnreachable(this.filter); | |
| } | |
| } | |
| } | |
| /** | |
| * Maximum number of sub-queries to include in a single union statement | |
| * to avoid hitting SQLite limits. | |
| * See: https://www.sqlite.org/limits.html#max_compound_select | |
| */ | |
| const MAX_SUBQUERIES_PER_UNION = 500; | |
| /** | |
| * A dataset that represents the union of multiple datasets. | |
| */ | |
| export class UnionDataset< | |
| T extends DatasetSchema = DatasetSchema, | |
| > implements Dataset<T> { | |
| /** | |
| * This factory method creates a new union dataset but retains the specific | |
| * types of the input datasets. It's a factory function because it's not | |
| * possible to do this with a constructor. | |
| * | |
| * @param datasets - The datasets to union together. | |
| * @returns - A new union dataset representing the union of the input | |
| * datasets. | |
| */ | |
| static create<T extends readonly Dataset[]>( | |
| datasets: T, | |
| ): UnionDataset<T[number]['schema']> { | |
| return new UnionDataset(datasets); | |
| } | |
| private constructor(readonly union: ReadonlyArray<Dataset>) {} | |
| get schema(): T { | |
| // Find the minimal set of columns that are supported by all datasets of | |
| // the union | |
| let unionSchema: SpecType | undefined = undefined; | |
| this.union.forEach((ds) => { | |
| const dsSchema = ds.schema; | |
| if (unionSchema === undefined) { | |
| // First time just use this one | |
| unionSchema = dsSchema; | |
| } else { | |
| const newSch: SpecType = {}; | |
| for (const [key, value] of Object.entries(unionSchema)) { | |
| if (key in dsSchema) { | |
| const commonType = unionTypes(value, dsSchema[key]); | |
| if (commonType !== undefined) { | |
| newSch[key] = commonType; | |
| } | |
| } | |
| } | |
| unionSchema = newSch; | |
| } | |
| }); | |
| const result = unionSchema ?? {}; | |
| return result as T; | |
| } | |
| query(schema?: DatasetSchema): string { | |
| const querySchema = schema ?? this.schema; | |
| // Flatten the entire union tree and extract all datasets | |
| const allDatasets = this.flattenUnion(); | |
| // Group SourceDatasets by src and merge them | |
| const combinedSrcSets = new Map<string, SourceDataset[]>(); | |
| const otherDatasets: Dataset[] = []; | |
| for (const dataset of allDatasets) { | |
| if (dataset instanceof SourceDataset) { | |
| const key = `${dataset.src}|${getJoinSignature(dataset)}`; | |
| const set = getOrCreate(combinedSrcSets, key, () => []); | |
| set.push(dataset); | |
| } else { | |
| // Non-source datasets (shouldn't happen after flattening, but handle it) | |
| otherDatasets.push(dataset); | |
| } | |
| } | |
| // Merge SourceDatasets with the same src | |
| const mergedDatasets: Dataset[] = []; | |
| for (const srcGroup of combinedSrcSets.values()) { | |
| if (srcGroup.length === 1) { | |
| mergedDatasets.push(srcGroup[0]); | |
| } else { | |
| // Combine schema across all members in the union | |
| const combinedSchema = srcGroup.reduce((acc, e) => { | |
| Object.assign(acc, e.schema); | |
| return acc; | |
| }, {} as DatasetSchema); | |
| // Merge select mappings - take the first one we find for each column | |
| const combinedSelect: Record<string, SelectValue> = {}; | |
| for (const dataset of srcGroup) { | |
| if (dataset.select) { | |
| for (const [col, selectValue] of Object.entries(dataset.select)) { | |
| if (!(col in combinedSelect)) { | |
| combinedSelect[col] = selectValue; | |
| } | |
| } | |
| } | |
| } | |
| // Merge joins - collect all unique joins | |
| const combinedJoins: Record<string, Join> = {}; | |
| for (const dataset of srcGroup) { | |
| if (dataset.joins) { | |
| Object.assign(combinedJoins, dataset.joins); | |
| } | |
| } | |
| // Merge filters for the same src | |
| const inFilters: InFilter[] = []; | |
| for (const {filter} of srcGroup) { | |
| if (filter) { | |
| if ('eq' in filter) { | |
| inFilters.push({col: filter.col, in: [filter.eq]}); | |
| } else { | |
| inFilters.push(filter); | |
| } | |
| } | |
| } | |
| const mergedFilter = mergeFilters(inFilters); | |
| mergedDatasets.push( | |
| new SourceDataset({ | |
| src: srcGroup[0].src, | |
| schema: combinedSchema, | |
| filter: mergedFilter, | |
| select: | |
| Object.keys(combinedSelect).length > 0 | |
| ? combinedSelect | |
| : undefined, | |
| joins: | |
| Object.keys(combinedJoins).length > 0 ? combinedJoins : undefined, | |
| }), | |
| ); | |
| } | |
| } | |
| mergedDatasets.push(...otherDatasets); | |
| // If we merged everything into a single dataset, use its query directly | |
| // Pass the querySchema to enable column and join elimination | |
| if (mergedDatasets.length === 1) { | |
| return mergedDatasets[0].query(querySchema); | |
| } | |
| // Generate union query from merged datasets | |
| // Pass querySchema to each dataset to enable column and join elimination | |
| const subQueries = mergedDatasets.map((dataset) => | |
| dataset.query(querySchema), | |
| ); | |
| // If we have a small number of sub-queries, just use a single union all. | |
| if (subQueries.length <= MAX_SUBQUERIES_PER_UNION) { | |
| return subQueries.join('\nUNION ALL\n'); | |
| } | |
| // Handle large number of sub-queries by batching into multiple CTEs. | |
| let sql = 'WITH\n'; | |
| const cteNames: string[] = []; | |
| // Create CTEs for batches of sub-queries | |
| for (let i = 0; i < subQueries.length; i += MAX_SUBQUERIES_PER_UNION) { | |
| const batch = subQueries.slice(i, i + MAX_SUBQUERIES_PER_UNION); | |
| const cteName = `union_batch_${Math.floor(i / MAX_SUBQUERIES_PER_UNION)}`; | |
| cteNames.push(cteName); | |
| sql += `${cteName} AS ( | |
| ${indent(batch.join('\nUNION ALL\n'), 2)} | |
| )`; | |
| // Add comma unless this is the last CTE. | |
| if (i + MAX_SUBQUERIES_PER_UNION < subQueries.length) { | |
| sql += ',\n'; | |
| } | |
| } | |
| const cols = Object.keys(querySchema).join(',\n'); | |
| // Union all the CTEs together in the final query. | |
| sql += '\n'; | |
| sql += cteNames | |
| .map( | |
| (name) => `SELECT | |
| ${indent(cols, 2)} | |
| FROM ${name}`, | |
| ) | |
| .join('\nUNION ALL\n'); | |
| return sql; | |
| } | |
| /** | |
| * Recursively flatten this union tree, extracting all leaf datasets. | |
| * Nested UnionDatasets are recursively flattened. | |
| */ | |
| private flattenUnion(): Dataset[] { | |
| const result: Dataset[] = []; | |
| for (const dataset of this.union) { | |
| if (dataset instanceof UnionDataset) { | |
| // Recursively flatten nested unions | |
| result.push(...dataset.flattenUnion()); | |
| } else { | |
| // Leaf dataset (SourceDataset or other) | |
| result.push(dataset); | |
| } | |
| } | |
| return result; | |
| } | |
| implements<T extends DatasetSchema>(required: T): this is Dataset<T> { | |
| return Object.entries(required).every(([name, required]) => { | |
| return name in this.schema && checkExtends(required, this.schema[name]); | |
| }); | |
| } | |
| } | |
| function getJoinSignature(dataset: SourceDataset): string { | |
| if (!dataset.joins) { | |
| return ''; | |
| } | |
| return Object.keys(dataset.joins).sort().join(','); | |
| } | |
| function mergeFilters(filters: InFilter[]): InFilter | undefined { | |
| if (filters.length === 0) return undefined; | |
| const col = filters[0].col; | |
| const values = new Set(filters.flatMap((filter) => filter.in)); | |
| return {col, in: Array.from(values)}; | |
| } | |
| function indent(str: string, spaces: number): string { | |
| const padding = ' '.repeat(spaces); | |
| return str | |
| .split('\n') | |
| .map((line) => padding + line) | |
| .join('\n'); | |
| } | |
| const lineageSchema = {__groupid: NUM, __partition: UNKNOWN}; | |
| /** | |
| * Internal structure for tracking partition maps in UnionDatasetWithLineage. | |
| */ | |
| interface PartitionMapWithUnfiltered<T> { | |
| /** For each partition column, map values to source items */ | |
| columns: Map<string, Map<SqlValue, Set<T>>>; | |
| /** Items without filters that should match all rows */ | |
| unfilteredInputs: Set<T>; | |
| } | |
| /** | |
| * A union dataset that automatically adds __groupid and __partition columns | |
| * for lineage tracking. This allows resolving which source dataset(s) each | |
| * row came from after querying. | |
| * | |
| * @example | |
| * ```ts | |
| * const union = UnionDatasetWithLineage.create([ds1, ds2, ds3]); | |
| * const result = await engine.query(union.query()); | |
| * const iter = result.iter(union.schema); | |
| * | |
| * for (; iter.valid(); iter.next()) { | |
| * const sourceDatasets = union.resolveLineage(iter); | |
| * console.log('Row came from:', sourceDatasets, iter.a, iter.b); | |
| * } | |
| * ``` | |
| */ | |
| export class UnionDatasetWithLineage< | |
| T extends DatasetSchema, | |
| > implements Dataset<T> { | |
| readonly sourceDatasets: ReadonlyArray<Dataset>; | |
| private readonly sourceGroupArray: Array<[string, Array<{dataset: Dataset}>]>; | |
| private readonly partitionMaps: Map< | |
| string, | |
| PartitionMapWithUnfiltered<Dataset> | |
| >; | |
| static create<T extends readonly Dataset[]>( | |
| datasets: T, | |
| ): UnionDatasetWithLineage<T[number]['schema'] & typeof lineageSchema> { | |
| return new UnionDatasetWithLineage(datasets); | |
| } | |
| private constructor(readonly datasets: ReadonlyArray<Dataset>) { | |
| this.sourceDatasets = datasets; | |
| // Group datasets by src (for SourceDatasets) | |
| const sourceGroups = new Map<string, Array<{dataset: Dataset}>>(); | |
| for (const dataset of datasets) { | |
| if (dataset instanceof SourceDataset) { | |
| const key = `${dataset.src}|${getJoinSignature(dataset)}`; | |
| const group = sourceGroups.get(key) ?? []; | |
| group.push({dataset}); | |
| sourceGroups.set(key, group); | |
| } else { | |
| // Non-SourceDataset: treat as its own group with a unique key | |
| const key = `__other_${sourceGroups.size}`; | |
| sourceGroups.set(key, [{dataset}]); | |
| } | |
| } | |
| this.sourceGroupArray = Array.from(sourceGroups.entries()); | |
| // Build partition maps for each group | |
| this.partitionMaps = new Map(); | |
| for (const [src, group] of this.sourceGroupArray) { | |
| this.partitionMaps.set(src, this.buildPartitionMap(group)); | |
| } | |
| } | |
| get schema(): T { | |
| // Compute union schema from all datasets | |
| let unionSchema: SpecType | undefined = undefined; | |
| this.sourceDatasets.forEach((ds) => { | |
| const dsSchema = ds.schema; | |
| if (unionSchema === undefined) { | |
| unionSchema = dsSchema; | |
| } else { | |
| const newSch: SpecType = {}; | |
| for (const [key, value] of Object.entries(unionSchema)) { | |
| if (key in dsSchema) { | |
| const commonType = unionTypes(value, dsSchema[key]); | |
| if (commonType !== undefined) { | |
| newSch[key] = commonType; | |
| } | |
| } | |
| } | |
| unionSchema = newSch; | |
| } | |
| }); | |
| // Add lineage columns to the schema | |
| const result = {...(unionSchema ?? {}), ...lineageSchema}; | |
| return result as unknown as T; | |
| } | |
| query(schema?: DatasetSchema): string { | |
| const querySchema = schema ?? this.schema; | |
| // Remove lineage columns from the requested schema to get base columns | |
| const baseSchemaEntries = Object.entries(querySchema).filter( | |
| ([key]) => key !== '__groupid' && key !== '__partition', | |
| ); | |
| const baseSchema: DatasetSchema = Object.fromEntries(baseSchemaEntries); | |
| const resultColNames = Object.keys(baseSchema); | |
| // Build per-group queries with __groupid and __partition columns | |
| const groupQueries: string[] = []; | |
| for (let groupId = 0; groupId < this.sourceGroupArray.length; groupId++) { | |
| const [src, group] = this.sourceGroupArray[groupId]; | |
| const partitionMap = this.partitionMaps.get(src)!; | |
| const partitionColumns = Array.from(partitionMap.columns.keys()); | |
| // Build union dataset for this group | |
| const datasets = group.map((g) => g.dataset); | |
| const unionDataset = UnionDataset.create(datasets); | |
| // Build query schema: base columns + partition columns | |
| const groupQuerySchema: DatasetSchema = { | |
| ...baseSchema, | |
| ...Object.fromEntries(partitionColumns.map((col) => [col, NUM])), | |
| }; | |
| const baseQuery = unionDataset.query(groupQuerySchema); | |
| // Normalize partition to single __partition column (use first partition col or NULL) | |
| const partitionExpr = | |
| partitionColumns.length > 0 ? partitionColumns[0] : 'NULL'; | |
| // Select result columns + groupid + partition | |
| const selectCols = resultColNames.join(',\n'); | |
| groupQueries.push( | |
| `SELECT | |
| ${indent(selectCols, 2)}, | |
| ${groupId} AS __groupid, | |
| ${partitionExpr} AS __partition | |
| FROM ( | |
| ${indent(baseQuery, 2)} | |
| )`, | |
| ); | |
| } | |
| // Handle empty case | |
| if (groupQueries.length === 0) { | |
| const cols = resultColNames.join(',\n'); | |
| return `SELECT | |
| ${indent(cols, 2)}, | |
| 0 AS __groupid, | |
| NULL AS __partition | |
| WHERE FALSE`; | |
| } | |
| // Single group - no need for UNION | |
| if (groupQueries.length === 1) { | |
| return groupQueries[0]; | |
| } | |
| // Small number of groups - use simple UNION ALL | |
| if (groupQueries.length <= MAX_SUBQUERIES_PER_UNION) { | |
| return groupQueries.join('\nUNION ALL\n'); | |
| } | |
| // Large number of groups - use CTEs to avoid SQLite limits | |
| const ctes: string[] = []; | |
| const cteNames: string[] = []; | |
| for (let i = 0; i < groupQueries.length; i += MAX_SUBQUERIES_PER_UNION) { | |
| const chunk = groupQueries.slice(i, i + MAX_SUBQUERIES_PER_UNION); | |
| const cteName = `_lineage_chunk_${Math.floor(i / MAX_SUBQUERIES_PER_UNION)}`; | |
| cteNames.push(cteName); | |
| ctes.push(`${cteName} AS ( | |
| ${indent(chunk.join('\nUNION ALL\n'), 2)} | |
| )`); | |
| } | |
| const cteSection = `WITH ${ctes.join(',\n')}`; | |
| const finalUnion = cteNames | |
| .map((name) => `SELECT * FROM ${name}`) | |
| .join('\nUNION ALL\n'); | |
| return `${cteSection}\n${finalUnion}`; | |
| } | |
| implements<T extends DatasetSchema>(schema: T): this is Dataset<T> { | |
| return Object.entries(schema).every(([name, required]) => { | |
| return name in this.schema && checkExtends(required, this.schema[name]); | |
| }); | |
| } | |
| /** | |
| * Resolve which source dataset(s) a row came from using its lineage columns. | |
| * | |
| * @param row - A row object containing __groupid and __partition values | |
| * @returns The source dataset(s) that produced this row | |
| */ | |
| resolveLineage(row: { | |
| __groupid: number; | |
| __partition: SqlValue; | |
| }): readonly Dataset[] { | |
| const groupId = row.__groupid as number; | |
| const partitionValue = row.__partition; | |
| if (groupId < 0 || groupId >= this.sourceGroupArray.length) { | |
| return []; | |
| } | |
| const [src, group] = this.sourceGroupArray[groupId]; | |
| const partitionMap = this.partitionMaps.get(src)!; | |
| const results: Dataset[] = []; | |
| // No partition columns - all datasets in group match | |
| if (partitionMap.columns.size === 0) { | |
| return group.map((g) => g.dataset); | |
| } | |
| // Find matching datasets via partition value | |
| for (const [_colName, valueMap] of partitionMap.columns.entries()) { | |
| const matchingDatasets = | |
| valueMap.get(partitionValue) ?? valueMap.get(Number(partitionValue)); | |
| if (matchingDatasets) { | |
| results.push(...matchingDatasets); | |
| break; | |
| } | |
| } | |
| // Add unfiltered datasets - they match all rows | |
| for (const unfilteredDataset of partitionMap.unfilteredInputs) { | |
| results.push(unfilteredDataset); | |
| } | |
| return results; | |
| } | |
| /** | |
| * Build partition map for a group of datasets. | |
| * Maps partition column values back to source datasets for O(1) lineage lookup. | |
| */ | |
| private buildPartitionMap( | |
| group: Array<{dataset: Dataset}>, | |
| ): PartitionMapWithUnfiltered<Dataset> { | |
| const partitionMap: PartitionMapWithUnfiltered<Dataset> = { | |
| columns: new Map(), | |
| unfilteredInputs: new Set(), | |
| }; | |
| for (const {dataset} of group) { | |
| if (!(dataset instanceof SourceDataset)) { | |
| // Non-SourceDataset: treat as unfiltered | |
| partitionMap.unfilteredInputs.add(dataset); | |
| continue; | |
| } | |
| if (!dataset.filter) { | |
| // Track datasets without filters - they match all rows | |
| partitionMap.unfilteredInputs.add(dataset); | |
| continue; | |
| } | |
| const colName = dataset.filter.col; | |
| const valueMap = partitionMap.columns.get(colName) ?? new Map(); | |
| partitionMap.columns.set(colName, valueMap); | |
| const values = | |
| 'eq' in dataset.filter ? [dataset.filter.eq] : dataset.filter.in; | |
| for (const value of values) { | |
| const sourceSet = valueMap.get(value) ?? new Set(); | |
| sourceSet.add(dataset); | |
| valueMap.set(value, sourceSet); | |
| } | |
| } | |
| return partitionMap; | |
| } | |
| } | |
| // async function _testUnionDatasetWithLineage(engine: Engine) { | |
| // const ds1 = new SourceDataset({ | |
| // src: 'table1', | |
| // schema: {a: NUM, b: STR}, | |
| // filter: { | |
| // col: 'd', | |
| // eq: 10, | |
| // }, | |
| // }); | |
| // const ds2 = new SourceDataset({ | |
| // src: 'table2', | |
| // schema: {a: NUM, b: STR, c: NUM}, | |
| // filter: { | |
| // col: 'd', | |
| // in: [1, 2, 3], | |
| // }, | |
| // }); | |
| // const union = UnionDatasetWithLineage.create([ds1, ds2]); | |
| // const result = await engine.query( | |
| // `SELECT * FROM (${union.query()}) WHERE b LIKE '%test%'`, | |
| // ); | |
| // const iter = result.iter(union.schema); | |
| // for (; iter.valid(); iter.next()) { | |
| // const sourceDatasets = union.resolveLineage(iter); | |
| // console.log('Found search result: ', sourceDatasets, iter.a, iter.b); | |
| // } | |
| // } |