1// Copyright (C) 2024 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 {Filter} from './filters'; 16import {ColumnOrderClause, SqlColumn, SqlExpression} from './sql_column'; 17 18// The goal of this module is to generate a query statement from the list of columns, filters and order by clauses. 19// The main challenge is that the column definitions are independent, and the columns themselves can reference the same join multiple times: 20// 21// For example, in the following query `parent_slice_ts` and `parent_slice_dur` are both referencing the same join, but we want to include only one join in the final query. 22 23// SELECT 24// parent.ts AS parent_slice_ts, 25// parent.dur AS parent_slice_dur 26// FROM slice 27// LEFT JOIN slice AS parent ON slice.parent_id = parent.id 28 29// Normalised sql column, where the source table is resolved to a unique index. 30type NormalisedSqlColumn = 31 | { 32 kind: 'table_column'; 33 column: string; 34 // If |sourceTableId| is undefined, then the columnName comes from the primary table. 35 sourceTableId?: number; 36 } 37 | { 38 kind: 'expression'; 39 index: number; 40 op: (cols: string[]) => string; 41 columns: NormalisedSqlColumn[]; 42 }; 43 44// Normalised source table, where the join constraints are resolved to a normalised columns. 45type NormalisedSourceTable = { 46 table: string; 47 joinOn: {[key: string]: NormalisedSqlColumn}; 48 innerJoin: boolean; 49}; 50 51// Checks whether two normalised columns are equivalent. 52function normalisedSqlColumnsEqual( 53 a: NormalisedSqlColumn | undefined, 54 b: NormalisedSqlColumn | undefined, 55): boolean { 56 if (a === undefined) return false; 57 if (b === undefined) return false; 58 if (a.kind === 'table_column') { 59 if (b.kind !== 'table_column') return false; 60 return a.column === b.column && a.sourceTableId === b.sourceTableId; 61 } else { 62 if (b.kind !== 'expression') return false; 63 // For expressions, first check that the underlying columns are equal. 64 if (a.columns.length !== b.columns.length) return false; 65 for (let i = 0; i < a.columns.length; ++i) { 66 if (!normalisedSqlColumnsEqual(a.columns[i], b.columns[i])) return false; 67 } 68 // Subsitute the columns with dummy values to check if the expressions are equal. 69 const cols = Array.from({length: a.columns.length}, (_, i) => `__$${i}`); 70 return a.op(cols) === b.op(cols); 71 } 72} 73 74// Checks whether two join constraints are equal to allow deduplication of joins. 75function areJoinConstraintsEqual( 76 a: {[key: string]: NormalisedSqlColumn}, 77 b: {[key: string]: NormalisedSqlColumn}, 78): boolean { 79 if (Object.keys(a).length !== Object.keys(b).length) { 80 return false; 81 } 82 83 for (const key of Object.keys(a)) { 84 const aValue = a[key]; 85 const bValue = b[key]; 86 if (!normalisedSqlColumnsEqual(aValue, bValue)) return false; 87 } 88 return true; 89} 90 91// Class responsible for building a query and maintaing a list of normalised join tables. 92class QueryBuilder { 93 tables: NormalisedSourceTable[] = []; 94 tableAlias: string; 95 expressionIndex: number = 0; 96 97 constructor(tableName: string) { 98 this.tableAlias = `${tableName}_0`; 99 } 100 101 // Normalises a column, including adding if necessary the joins to the list of tables. 102 normalise(column: SqlColumn): NormalisedSqlColumn { 103 // Simple columns do not require any normalisation. 104 if (typeof column === 'string') { 105 return { 106 kind: 'table_column', 107 column: column, 108 }; 109 } 110 // Expressions require normalisation of the underlying columns. 111 if (column instanceof SqlExpression) { 112 return { 113 kind: 'expression', 114 index: this.expressionIndex++, 115 op: column.op, 116 columns: column.columns.map((column) => this.normalise(column)), 117 }; 118 } 119 // Otherwise, normalise join constraints. 120 const normalisedJoinOn: {[key: string]: NormalisedSqlColumn} = 121 Object.fromEntries( 122 Object.entries(column.source.joinOn).map(([key, value]) => [ 123 key, 124 this.normalise(value), 125 ]), 126 ); 127 128 // Check if this join is already present. 129 for (let i = 0; i < this.tables.length; ++i) { 130 const table = this.tables[i]; 131 if ( 132 table.table === column.source.table && 133 table.innerJoin === (column.source.innerJoin ?? false) && 134 areJoinConstraintsEqual(table.joinOn, normalisedJoinOn) 135 ) { 136 return { 137 kind: 'table_column', 138 column: column.column, 139 sourceTableId: i, 140 }; 141 } 142 } 143 144 // Otherwise, add a new join. 145 this.tables.push({ 146 table: column.source.table, 147 joinOn: normalisedJoinOn, 148 innerJoin: column.source.innerJoin ?? false, 149 }); 150 return { 151 kind: 'table_column', 152 column: column.column, 153 sourceTableId: this.tables.length - 1, 154 }; 155 } 156 157 // Prints a reference to a column, including properly disambiguated table alias. 158 printReference(column: NormalisedSqlColumn): string { 159 if (column.kind === 'expression') { 160 return column.op( 161 column.columns.map((column) => this.printReference(column)), 162 ); 163 } 164 if (column.sourceTableId === undefined) { 165 if (!/^[A-Za-z0-9_]*$/.test(column.column)) { 166 // If this is an expression, don't prefix it with the table name. 167 return column.column; 168 } 169 return `${this.tableAlias}.${column.column}`; 170 } 171 const table = this.tables[column.sourceTableId]; 172 // Dependent tables are 0-indexed, but we want to display them as 1-indexed to reserve 0 for the primary table. 173 return `${table.table}_${column.sourceTableId + 1}.${column.column}`; 174 } 175 176 printJoin(joinIndex: number): string { 177 const join = this.tables[joinIndex]; 178 const alias = `${join.table}_${joinIndex + 1}`; 179 const clauses = Object.entries(join.joinOn).map( 180 ([key, value]) => `${alias}.${key} = ${this.printReference(value)}`, 181 ); 182 // Join IDs are 0-indexed, but we want to display them as 1-indexed to reserve 0 for the primary table. 183 return `${join.innerJoin ? '' : 'LEFT '}JOIN ${join.table} AS ${alias} ON ${clauses.join(' AND ')}`; 184 } 185} 186 187// Returns a query fetching the columns from the table, with the specified filters and order by clauses. 188// keys of the `columns` object are the names of the columns in the result set. 189export function buildSqlQuery(args: { 190 table: string; 191 columns: {[key: string]: SqlColumn}; 192 prefix?: string; 193 filters?: Filter[]; 194 // List of columns to group by. Should be a subset of the keys of the `columns` object. 195 groupBy?: SqlColumn[]; 196 orderBy?: ColumnOrderClause[]; 197}): string { 198 const builder = new QueryBuilder(args.table); 199 200 const normalisedColumns = Object.fromEntries( 201 Object.entries(args.columns).map(([key, value]) => [ 202 key, 203 builder.normalise(value), 204 ]), 205 ); 206 const normalisedFilters = (args.filters || []).map((filter) => ({ 207 op: filter.op, 208 columns: filter.columns.map((column) => builder.normalise(column)), 209 })); 210 const normalisedOrderBy = (args.orderBy || []).map((orderBy) => ({ 211 order: orderBy.direction, 212 column: builder.normalise(orderBy.column), 213 })); 214 const normalisedGroupBy = (args.groupBy || []).map((column) => 215 builder.normalise(column), 216 ); 217 218 const formatFilter = (filter: { 219 op: (cols: string[]) => string; 220 columns: NormalisedSqlColumn[]; 221 }) => { 222 return filter.op( 223 filter.columns.map((column) => builder.printReference(column)), 224 ); 225 }; 226 227 const filterClause = 228 normalisedFilters.length === 0 229 ? '' 230 : `WHERE\n ${normalisedFilters.map(formatFilter).join('\n AND ')}`; 231 const joinClause = builder.tables 232 .map((_, index) => builder.printJoin(index)) 233 .join('\n'); 234 const groupBys = normalisedGroupBy.map((column) => 235 builder.printReference(column), 236 ); 237 const groupByClause = 238 args.groupBy === undefined ? '' : `GROUP BY\n ${groupBys.join(', ')}`; 239 const orderBys = normalisedOrderBy.map( 240 (orderBy) => `${builder.printReference(orderBy.column)} ${orderBy.order}`, 241 ); 242 const orderByClause = 243 normalisedOrderBy.length === 0 ? '' : `ORDER BY\n ${orderBys.join(', ')}`; 244 245 return ` 246 ${args.prefix === undefined ? '' : args.prefix} 247 SELECT 248 ${Object.entries(normalisedColumns) 249 .map(([key, value]) => `${builder.printReference(value)} AS ${key}`) 250 .join(',\n ')} 251 FROM ${args.table} AS ${builder.tableAlias} 252 ${joinClause} 253 ${filterClause} 254 ${groupByClause} 255 ${orderByClause} 256 `; 257} 258