2024-02-14 09:06:19 +01:00

368 lines
12 KiB
TypeScript

// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { Injectable } from '@angular/core';
import { SQLiteDB } from '@classes/sqlitedb';
import { CoreBrowser } from '@singletons/browser';
import { SQLite, makeSingleton } from '@singletons';
import { CorePlatform } from '@services/platform';
import { SQLiteObject } from '@awesome-cordova-plugins/sqlite/ngx';
import { asyncInstance } from '@/core/utils/async-instance';
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.
*/
@Injectable({ providedIn: 'root' })
export class CoreDbProvider {
queryLogs: CoreDbQueryLog[] = [];
protected dbInstances: {[name: string]: SQLiteDB} = {};
/**
* Check whether database queries should be logged.
*
* @returns Whether queries should be logged.
*/
loggingEnabled(): boolean {
return CoreBrowser.hasDevelopmentSetting('DBLoggingEnabled');
}
/**
* Print query history in console.
*
* @param format Log format, with the following substitutions: :dbname, :sql, :duration, and :result.
*/
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)
.replace(':duration', `${Math.round(duration).toString().padStart(4, '0')}ms`)
.replace(':result', error?.message ?? 'Success');
// eslint-disable-next-line no-console
console.log(this.queryLogs.map(substituteParams).join('\n'));
}
/**
* Get the table name from a SQL query.
*
* @param sql SQL query.
* @returns 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.
* @returns 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.
* @returns Object with the summary data.
*/
protected buildStatementsSummary(
filters: TablesSummaryFilters = {},
): Record<string, Record<string, Record<string, CoreDbStatementSummary>>> {
const statementsSummary: Record<string, Record<string, Record<string, CoreDbStatementSummary>>> = {};
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.toFixed(2) + '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.
*
* @param log Query log.
*/
logQuery(log: CoreDbQueryLog): void {
this.queryLogs.push(log);
}
/**
* Clear stored logs.
*/
clearLogs(): void {
this.queryLogs = [];
}
/**
* Get or create a database object.
*
* The database objects are cached statically.
*
* @param name DB name.
* @param forceNew True if it should always create a new instance.
* @returns DB.
*/
getDB(name: string, forceNew?: boolean): SQLiteDB {
if (this.dbInstances[name] === undefined || forceNew) {
let db = this.createDatabase(name);
if (this.loggingEnabled()) {
const spies = this.getDatabaseSpies(name, db);
db = new Proxy(db, {
get: (target, property, receiver) => spies[property] ?? Reflect.get(target, property, receiver),
}) as unknown as SQLiteObject;
}
this.dbInstances[name] = new SQLiteDB(name, db);
}
return this.dbInstances[name];
}
/**
* Create database connection.
*
* @param name Database name.
* @returns Database connection.
*/
protected createDatabase(name: string): SQLiteObject {
// Ideally, this method would return a Promise instead of resorting to Duck typing;
// but doing so would mean that the getDB() method should also return a promise.
// Given that it is heavily used throughout the app, we want to avoid it for now.
return asyncInstance(async () => {
await CorePlatform.ready();
return SQLite.create({ name, location: 'default' });
});
}
/**
* Delete a DB.
*
* @param name DB name.
*/
async deleteDB(name: string): Promise<void> {
if (this.dbInstances[name] !== undefined) {
await this.dbInstances[name].close();
delete this.dbInstances[name];
}
await this.deleteDatabase(name);
}
/**
* Delete database.
*
* @param name Database name.
*/
protected async deleteDatabase(name: string): Promise<void> {
await SQLite.deleteDatabase({
name,
location: 'default',
});
}
/**
* Get database spy methods to intercept database calls and track logging information.
*
* @param dbName Database name.
* @param db Database to spy.
* @returns Spy methods.
*/
protected getDatabaseSpies(dbName: string, db: SQLiteObject): Partial<SQLiteObject> {
return {
async executeSql(statement, params) {
const start = performance.now();
try {
const result = await db.executeSql(statement, params);
CoreDB.logQuery({
params,
sql: statement,
duration: performance.now() - start,
dbName,
});
return result;
} catch (error) {
CoreDB.logQuery({
params,
error,
sql: statement,
duration: performance.now() - start,
dbName,
});
throw error;
}
},
async sqlBatch(statements) {
const start = performance.now();
const sql = Array.isArray(statements)
? statements.join(' | ')
: String(statements);
try {
const result = await db.sqlBatch(statements);
CoreDB.logQuery({
sql,
duration: performance.now() - start,
dbName,
});
return result;
} catch (error) {
CoreDB.logQuery({
sql,
error,
duration: performance.now() - start,
dbName,
});
throw error;
}
},
};
}
}
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;
};