diff --git a/src/core/classes/sqlitedb.ts b/src/core/classes/sqlitedb.ts index 0d5bce2df..649e81cc9 100644 --- a/src/core/classes/sqlitedb.ts +++ b/src/core/classes/sqlitedb.ts @@ -1169,6 +1169,8 @@ export class SQLiteDB { * @returns Spy methods. */ protected getDatabaseSpies(db: SQLiteObject): Partial { + const dbName = this.name; + return { async executeSql(statement, params) { const start = performance.now(); @@ -1180,6 +1182,7 @@ export class SQLiteDB { params, sql: statement, duration: performance.now() - start, + dbName, }); return result; @@ -1189,6 +1192,7 @@ export class SQLiteDB { error, sql: statement, duration: performance.now() - start, + dbName, }); throw error; @@ -1206,6 +1210,7 @@ export class SQLiteDB { CoreDB.logQuery({ sql, duration: performance.now() - start, + dbName, }); return result; @@ -1214,6 +1219,7 @@ export class SQLiteDB { sql, error, duration: performance.now() - start, + dbName, }); throw error; diff --git a/src/core/features/emulator/classes/sqlitedb.ts b/src/core/features/emulator/classes/sqlitedb.ts index 8b7f5c682..a761c6bd2 100644 --- a/src/core/features/emulator/classes/sqlitedb.ts +++ b/src/core/features/emulator/classes/sqlitedb.ts @@ -171,6 +171,8 @@ export class SQLiteDBMock extends SQLiteDB { * @inheritdoc */ protected getDatabaseSpies(db: SQLiteObject): Partial { + const dbName = this.name; + return { transaction: (callback) => db.transaction((transaction) => { const transactionSpy: DbTransaction = { @@ -185,6 +187,7 @@ export class SQLiteDBMock extends SQLiteDB { sql, params, duration: performance.now() - start, + dbName, }); return success?.(...args); @@ -195,6 +198,7 @@ export class SQLiteDBMock extends SQLiteDB { params, error: args[0], duration: performance.now() - start, + dbName, }); return error?.(...args); diff --git a/src/core/services/db.ts b/src/core/services/db.ts index 841f57aef..1d46b2ed3 100644 --- a/src/core/services/db.ts +++ b/src/core/services/db.ts @@ -20,6 +20,16 @@ import { CoreBrowser } from '@singletons/browser'; import { makeSingleton, SQLite, Platform } from '@singletons'; import { CoreAppProvider } from './app'; +const tableNameRegex = new RegExp([ + '^SELECT.*FROM ([^ ]+)', + '^INSERT.*INTO ([^ ]+)', + '^UPDATE ([^ ]+)', + '^DELETE FROM ([^ ]+)', + '^CREATE TABLE IF NOT EXISTS ([^ ]+)', + '^ALTER TABLE ([^ ]+)', + '^DROP TABLE IF EXISTS ([^ ]+)', +].join('|')); + /** * This service allows interacting with the local database to store and retrieve data. */ @@ -42,10 +52,11 @@ export class CoreDbProvider { /** * Print query history in console. * - * @param format Log format, with the following substitutions: :sql, :duration, and :result. + * @param format Log format, with the following substitutions: :dbname, :sql, :duration, and :result. */ - printHistory(format: string = ':sql | Duration: :duration | Result: :result'): void { - const substituteParams = ({ sql, params, duration, error }: CoreDbQueryLog) => format + printHistory(format: string = ':dbname | :sql | Duration: :duration | Result: :result'): void { + const substituteParams = ({ sql, params, duration, error, dbName }: CoreDbQueryLog) => format + .replace(':dbname', dbName) .replace(':sql', Object .values(params ?? []) .reduce((sql: string, param: string) => sql.replace('?', param) as string, sql) as string) @@ -56,6 +67,120 @@ export class CoreDbProvider { console.log(this.queryLogs.map(substituteParams).join('\n')); } + /** + * Get the table name from a SQL query. + * + * @param sql SQL query. + * @return Table name, undefined if not found. + */ + protected getTableNameFromSql(sql: string): string | undefined { + const matches = sql.match(tableNameRegex); + + return matches?.find((matchEntry, index) => index > 0 && !!matchEntry); + } + + /** + * Check if a value matches a certain filter. + * + * @param value Value. + * @param filter Filter. + * @return Whether the value matches the filter. + */ + protected valueMatchesFilter(value: string, filter?: RegExp | string): boolean { + if (typeof filter === 'string') { + return value === filter; + } else if (filter) { + return !!value.match(filter); + } + + return true; + } + + /** + * Build an object with the summary data for each db, table and statement. + * + * @param filters Filters to limit the data stored. + * @return Object with the summary data. + */ + protected buildStatementsSummary( + filters: TablesSummaryFilters = {}, + ): Record>> { + const statementsSummary: Record>> = {}; + + this.queryLogs.forEach(log => { + if (!this.valueMatchesFilter(log.dbName, filters.dbName)) { + return; + } + + const statement = log.sql.substring(0, log.sql.indexOf(' ')); + if (!statement) { + console.warn(`Statement not found from sql: ${log.sql}`); // eslint-disable-line no-console + + return; + } + + const tableName = this.getTableNameFromSql(log.sql); + if (!tableName) { + console.warn(`Table name not found from sql: ${log.sql}`); // eslint-disable-line no-console + + return; + } + + if (!this.valueMatchesFilter(tableName, filters.tableName)) { + return; + } + + statementsSummary[log.dbName] = statementsSummary[log.dbName] ?? {}; + statementsSummary[log.dbName][tableName] = statementsSummary[log.dbName][tableName] ?? {}; + statementsSummary[log.dbName][tableName][statement] = statementsSummary[log.dbName][tableName][statement] ?? { + count: 0, + duration: 0, + errors: 0, + }; + + statementsSummary[log.dbName][tableName][statement].count++; + statementsSummary[log.dbName][tableName][statement].duration += log.duration; + if (log.error) { + statementsSummary[log.dbName][tableName][statement].errors++; + } + }); + + return statementsSummary; + } + + /** + * Print summary of statements for several tables. + * + * @param filters Filters to limit the results printed. + * @param format Log format, with the following substitutions: :dbname, :table, :statement, :count, :duration and :errors. + */ + printTablesSummary( + filters: TablesSummaryFilters = {}, + format = ':dbname, :table, :statement, :count, :duration, :errors', + ): void { + const statementsSummary = this.buildStatementsSummary(filters); + + const substituteParams = (dbName: string, tableName: string, statementName: string) => format + .replace(':dbname', dbName) + .replace(':table', tableName) + .replace(':statement', statementName) + .replace(':count', String(statementsSummary[dbName][tableName][statementName].count)) + .replace(':duration', statementsSummary[dbName][tableName][statementName].duration + 'ms') + .replace(':errors', String(statementsSummary[dbName][tableName][statementName].errors)); + + // eslint-disable-next-line no-console + console.log( + Object.keys(statementsSummary) + .sort() + .map(dbName => Object.keys(statementsSummary[dbName]) + .sort() + .map(tableName => Object.keys(statementsSummary[dbName][tableName]) + .sort() + .map(statementName => substituteParams(dbName, tableName, statementName)) + .join('\n')).join('\n')).join('\n'), + ); + } + /** * Log a query. * @@ -65,6 +190,13 @@ export class CoreDbProvider { this.queryLogs.push(log); } + /** + * Clear stored logs. + */ + clearLogs(): void { + this.queryLogs = []; + } + /** * Get or create a database object. * @@ -125,8 +257,26 @@ export const CoreDB = makeSingleton(CoreDbProvider); * Database query log entry. */ export interface CoreDbQueryLog { + dbName: string; sql: string; duration: number; error?: Error; params?: unknown[]; } + +/** + * Summary about a certain DB statement. + */ +type CoreDbStatementSummary = { + count: number; + duration: number; + errors: number; +}; + +/** + * Filters to print tables summary. + */ +type TablesSummaryFilters = { + dbName?: RegExp | string; + tableName?: RegExp | string; +};