• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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