Merge pull request #3070 from NoelDeMartin/MOBILE-3971
MOBILE-3971: Improve config db performancemain
commit
e523272e5e
|
@ -40,6 +40,7 @@ for (const file of files) {
|
||||||
styling: [],
|
styling: [],
|
||||||
blocking: [],
|
blocking: [],
|
||||||
longTasks: [],
|
longTasks: [],
|
||||||
|
database: [],
|
||||||
networking: [],
|
networking: [],
|
||||||
};
|
};
|
||||||
performanceMeasures[performanceMeasure.name].duration.push(performanceMeasure.duration);
|
performanceMeasures[performanceMeasure.name].duration.push(performanceMeasure.duration);
|
||||||
|
@ -47,17 +48,19 @@ for (const file of files) {
|
||||||
performanceMeasures[performanceMeasure.name].styling.push(performanceMeasure.styling);
|
performanceMeasures[performanceMeasure.name].styling.push(performanceMeasure.styling);
|
||||||
performanceMeasures[performanceMeasure.name].blocking.push(performanceMeasure.blocking);
|
performanceMeasures[performanceMeasure.name].blocking.push(performanceMeasure.blocking);
|
||||||
performanceMeasures[performanceMeasure.name].longTasks.push(performanceMeasure.longTasks);
|
performanceMeasures[performanceMeasure.name].longTasks.push(performanceMeasure.longTasks);
|
||||||
|
performanceMeasures[performanceMeasure.name].database.push(performanceMeasure.database);
|
||||||
performanceMeasures[performanceMeasure.name].networking.push(performanceMeasure.networking);
|
performanceMeasures[performanceMeasure.name].networking.push(performanceMeasure.networking);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate averages
|
// Calculate averages
|
||||||
for (const [name, { duration, scripting, styling, blocking, longTasks, networking }] of Object.entries(performanceMeasures)) {
|
for (const [name, { duration, scripting, styling, blocking, longTasks, database, networking }] of Object.entries(performanceMeasures)) {
|
||||||
const totalRuns = duration.length;
|
const totalRuns = duration.length;
|
||||||
const averageDuration = Math.round(duration.reduce((total, duration) => total + duration) / totalRuns);
|
const averageDuration = Math.round(duration.reduce((total, duration) => total + duration) / totalRuns);
|
||||||
const averageScripting = Math.round(scripting.reduce((total, scripting) => total + scripting) / totalRuns);
|
const averageScripting = Math.round(scripting.reduce((total, scripting) => total + scripting) / totalRuns);
|
||||||
const averageStyling = Math.round(styling.reduce((total, styling) => total + styling) / totalRuns);
|
const averageStyling = Math.round(styling.reduce((total, styling) => total + styling) / totalRuns);
|
||||||
const averageBlocking = Math.round(blocking.reduce((total, blocking) => total + blocking) / totalRuns);
|
const averageBlocking = Math.round(blocking.reduce((total, blocking) => total + blocking) / totalRuns);
|
||||||
const averageLongTasks = Math.round(longTasks.reduce((total, longTasks) => total + longTasks) / totalRuns);
|
const averageLongTasks = Math.round(longTasks.reduce((total, longTasks) => total + longTasks) / totalRuns);
|
||||||
|
const averageDatabase = Math.round(database.reduce((total, database) => total + database) / totalRuns);
|
||||||
const averageNetworking = Math.round(networking.reduce((total, networking) => total + networking) / totalRuns);
|
const averageNetworking = Math.round(networking.reduce((total, networking) => total + networking) / totalRuns);
|
||||||
|
|
||||||
performanceMeasures[name] = {
|
performanceMeasures[name] = {
|
||||||
|
@ -66,6 +69,7 @@ for (const [name, { duration, scripting, styling, blocking, longTasks, networkin
|
||||||
'Styling': `${averageStyling}ms`,
|
'Styling': `${averageStyling}ms`,
|
||||||
'Blocking': `${averageBlocking}ms`,
|
'Blocking': `${averageBlocking}ms`,
|
||||||
'# Network requests': averageNetworking,
|
'# Network requests': averageNetworking,
|
||||||
|
'# DB Queries': averageDatabase,
|
||||||
'# Long Tasks': averageLongTasks,
|
'# Long Tasks': averageLongTasks,
|
||||||
'# runs': totalRuns,
|
'# runs': totalRuns,
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,194 @@
|
||||||
|
// (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 { SQLiteDB, SQLiteDBRecordValues } from './sqlitedb';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database table wrapper used to improve performance by caching all data in memory
|
||||||
|
* for faster read operations.
|
||||||
|
*/
|
||||||
|
export abstract class CoreDatabaseTable<
|
||||||
|
DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues,
|
||||||
|
PrimaryKeyColumns extends keyof DBRecord = 'id',
|
||||||
|
PrimaryKey extends GetPrimaryKey<DBRecord, PrimaryKeyColumns> = GetPrimaryKey<DBRecord, PrimaryKeyColumns>
|
||||||
|
> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an instance.
|
||||||
|
*
|
||||||
|
* @param db Database connection.
|
||||||
|
* @returns Instance.
|
||||||
|
*/
|
||||||
|
static async create<This extends AnyCoreDatabaseTable>(this: CoreDatabaseTableConstructor<This>, db: SQLiteDB): Promise<This> {
|
||||||
|
const instance = new this(db);
|
||||||
|
|
||||||
|
await instance.initialize();
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected db: SQLiteDB;
|
||||||
|
protected data: Record<string, DBRecord>;
|
||||||
|
protected primaryKeys: string[] = ['id'];
|
||||||
|
|
||||||
|
constructor(db: SQLiteDB) {
|
||||||
|
this.db = db;
|
||||||
|
this.data = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a record matching the given conditions.
|
||||||
|
*
|
||||||
|
* @param conditions Matching conditions.
|
||||||
|
* @returns Database record.
|
||||||
|
*/
|
||||||
|
find(conditions: Partial<DBRecord>): DBRecord | null {
|
||||||
|
return Object.values(this.data).find(record => this.recordMatches(record, conditions)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a record by its primary key.
|
||||||
|
*
|
||||||
|
* @param primaryKey Primary key.
|
||||||
|
* @returns Database record.
|
||||||
|
*/
|
||||||
|
findByPrimaryKey(primaryKey: PrimaryKey): DBRecord | null {
|
||||||
|
return this.data[this.serializePrimaryKey(primaryKey)] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a new record.
|
||||||
|
*
|
||||||
|
* @param record Database record.
|
||||||
|
*/
|
||||||
|
async insert(record: DBRecord): Promise<void> {
|
||||||
|
await this.db.insertRecord(this.table, record);
|
||||||
|
|
||||||
|
const primaryKey = this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record));
|
||||||
|
|
||||||
|
this.data[primaryKey] = record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete records matching the given conditions.
|
||||||
|
*
|
||||||
|
* @param conditions Matching conditions. If this argument is missing, all records will be deleted.
|
||||||
|
*/
|
||||||
|
async delete(conditions?: Partial<DBRecord>): Promise<void> {
|
||||||
|
if (!conditions) {
|
||||||
|
await this.db.deleteRecords(this.table);
|
||||||
|
|
||||||
|
this.data = {};
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.deleteRecords(this.table, conditions);
|
||||||
|
|
||||||
|
Object.entries(this.data).forEach(([id, record]) => {
|
||||||
|
if (!this.recordMatches(record, conditions)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete this.data[id];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a single record identified by its primary key.
|
||||||
|
*
|
||||||
|
* @param primaryKey Record primary key.
|
||||||
|
*/
|
||||||
|
async deleteByPrimaryKey(primaryKey: PrimaryKey): Promise<void> {
|
||||||
|
await this.db.deleteRecords(this.table, primaryKey);
|
||||||
|
|
||||||
|
delete this.data[this.serializePrimaryKey(primaryKey)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database table name.
|
||||||
|
*/
|
||||||
|
protected abstract get table(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize object by getting the current state of the database table.
|
||||||
|
*/
|
||||||
|
protected async initialize(): Promise<void> {
|
||||||
|
const records = await this.db.getRecords<DBRecord>(this.table);
|
||||||
|
|
||||||
|
this.data = records.reduce((data, record) => {
|
||||||
|
const primaryKey = this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record));
|
||||||
|
|
||||||
|
data[primaryKey] = record;
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an object with the columns representing the primary key of a database record.
|
||||||
|
*
|
||||||
|
* @param record Database record.
|
||||||
|
* @returns Primary key column-value pairs.
|
||||||
|
*/
|
||||||
|
protected getPrimaryKeyFromRecord(record: DBRecord): PrimaryKey {
|
||||||
|
return this.primaryKeys.reduce((primaryKey, column) => {
|
||||||
|
primaryKey[column] = record[column];
|
||||||
|
|
||||||
|
return primaryKey;
|
||||||
|
}, {} as Record<PrimaryKeyColumns, unknown>) as PrimaryKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a primary key with a string representation.
|
||||||
|
*
|
||||||
|
* @param primaryKey Database record primary key.
|
||||||
|
* @returns Serialized primary key.
|
||||||
|
*/
|
||||||
|
protected serializePrimaryKey(primaryKey: PrimaryKey): string {
|
||||||
|
return Object.values(primaryKey).map(value => String(value)).join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a given record matches the given conditions.
|
||||||
|
*
|
||||||
|
* @param record Database record.
|
||||||
|
* @param conditions Conditions.
|
||||||
|
* @returns Whether the record matches the conditions.
|
||||||
|
*/
|
||||||
|
protected recordMatches(record: DBRecord, conditions: Partial<DBRecord>): boolean {
|
||||||
|
return !Object.entries(conditions).some(([column, value]) => record[column] !== value);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic type to match against any concrete database table type.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type AnyCoreDatabaseTable = CoreDatabaseTable<SQLiteDBRecordValues, string, Record<string, any>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database table constructor.
|
||||||
|
*/
|
||||||
|
type CoreDatabaseTableConstructor<T extends AnyCoreDatabaseTable> = {
|
||||||
|
new (db: SQLiteDB): T;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer primary key type from database record and columns types.
|
||||||
|
*/
|
||||||
|
type GetPrimaryKey<DBRecord extends SQLiteDBRecordValues, PrimaryKeyColumns extends keyof DBRecord> = {
|
||||||
|
[column in PrimaryKeyColumns]: DBRecord[column];
|
||||||
|
};
|
|
@ -16,6 +16,7 @@ import { SQLiteObject } from '@ionic-native/sqlite/ngx';
|
||||||
|
|
||||||
import { SQLite, Platform } from '@singletons';
|
import { SQLite, Platform } from '@singletons';
|
||||||
import { CoreError } from '@classes/errors/error';
|
import { CoreError } from '@classes/errors/error';
|
||||||
|
import { CoreDB } from '@services/db';
|
||||||
|
|
||||||
type SQLiteDBColumnType = 'INTEGER' | 'REAL' | 'TEXT' | 'BLOB';
|
type SQLiteDBColumnType = 'INTEGER' | 'REAL' | 'TEXT' | 'BLOB';
|
||||||
|
|
||||||
|
@ -813,12 +814,15 @@ export class SQLiteDB {
|
||||||
* Initialize the database.
|
* Initialize the database.
|
||||||
*/
|
*/
|
||||||
init(): void {
|
init(): void {
|
||||||
this.promise = Platform.ready()
|
this.promise = this.createDatabase().then(db => {
|
||||||
.then(() => SQLite.create({
|
if (CoreDB.loggingEnabled()) {
|
||||||
name: this.name,
|
const spies = this.getDatabaseSpies(db);
|
||||||
location: 'default',
|
|
||||||
}))
|
db = new Proxy(db, {
|
||||||
.then((db: SQLiteObject) => {
|
get: (target, property, receiver) => spies[property] ?? Reflect.get(target, property, receiver),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.db = db;
|
this.db = db;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
@ -1147,6 +1151,50 @@ export class SQLiteDB {
|
||||||
return { sql, params };
|
return { sql, params };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a database connection.
|
||||||
|
*
|
||||||
|
* @returns Database.
|
||||||
|
*/
|
||||||
|
protected async createDatabase(): Promise<SQLiteObject> {
|
||||||
|
await Platform.ready();
|
||||||
|
|
||||||
|
return SQLite.create({ name: this.name, location: 'default' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get database spy methods to intercept database calls and track logging information.
|
||||||
|
*
|
||||||
|
* @param db Database to spy.
|
||||||
|
* @returns Spy methods.
|
||||||
|
*/
|
||||||
|
protected getDatabaseSpies(db: SQLiteObject): Partial<SQLiteObject> {
|
||||||
|
return {
|
||||||
|
executeSql(statement, params) {
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
return db.executeSql(statement, params).then(result => {
|
||||||
|
CoreDB.logQuery(statement, performance.now() - start, params);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
sqlBatch(statements) {
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
return db.sqlBatch(statements).then(result => {
|
||||||
|
const sql = Array.isArray(statements)
|
||||||
|
? statements.join(' | ')
|
||||||
|
: String(statements);
|
||||||
|
|
||||||
|
CoreDB.logQuery(sql, performance.now() - start);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SQLiteDBRecordValues = {
|
export type SQLiteDBRecordValues = {
|
||||||
|
|
|
@ -0,0 +1,124 @@
|
||||||
|
// (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 { mock } from '@/testing/utils';
|
||||||
|
import { CoreDatabaseTable } from '@classes/database-table';
|
||||||
|
import { SQLiteDB, SQLiteDBRecordValues } from '@classes/sqlitedb';
|
||||||
|
|
||||||
|
interface User extends SQLiteDBRecordValues {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
surname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UsersTable extends CoreDatabaseTable<User> {
|
||||||
|
|
||||||
|
protected table = 'users';
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CoreDatabaseTable', () => {
|
||||||
|
|
||||||
|
let records: User[];
|
||||||
|
let db: SQLiteDB;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
records = [];
|
||||||
|
db = mock<SQLiteDB>({
|
||||||
|
getRecords: async <T>() => records as unknown as T[],
|
||||||
|
deleteRecords: async () => 0,
|
||||||
|
insertRecord: async () => 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads all records on create', async () => {
|
||||||
|
await UsersTable.create(db);
|
||||||
|
|
||||||
|
expect(db.getRecords).toHaveBeenCalledWith('users');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds items', async () => {
|
||||||
|
const john = { id: 1, name: 'John', surname: 'Doe' };
|
||||||
|
const amy = { id: 2, name: 'Amy', surname: 'Doe' };
|
||||||
|
|
||||||
|
records.push(john);
|
||||||
|
records.push(amy);
|
||||||
|
|
||||||
|
const table = await UsersTable.create(db);
|
||||||
|
|
||||||
|
expect(table.findByPrimaryKey({ id: 1 })).toEqual(john);
|
||||||
|
expect(table.findByPrimaryKey({ id: 2 })).toEqual(amy);
|
||||||
|
expect(table.find({ surname: 'Doe', name: 'John' })).toEqual(john);
|
||||||
|
expect(table.find({ surname: 'Doe', name: 'Amy' })).toEqual(amy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts items', async () => {
|
||||||
|
// Arrange.
|
||||||
|
const john = { id: 1, name: 'John', surname: 'Doe' };
|
||||||
|
|
||||||
|
// Act.
|
||||||
|
const table = await UsersTable.create(db);
|
||||||
|
|
||||||
|
await table.insert(john);
|
||||||
|
|
||||||
|
// Assert.
|
||||||
|
expect(db.insertRecord).toHaveBeenCalledWith('users', john);
|
||||||
|
|
||||||
|
expect(table.findByPrimaryKey({ id: 1 })).toEqual(john);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes items', async () => {
|
||||||
|
// Arrange.
|
||||||
|
const john = { id: 1, name: 'John', surname: 'Doe' };
|
||||||
|
const amy = { id: 2, name: 'Amy', surname: 'Doe' };
|
||||||
|
const jane = { id: 3, name: 'Jane', surname: 'Smith' };
|
||||||
|
|
||||||
|
records.push(john);
|
||||||
|
records.push(amy);
|
||||||
|
records.push(jane);
|
||||||
|
|
||||||
|
// Act.
|
||||||
|
const table = await UsersTable.create(db);
|
||||||
|
|
||||||
|
await table.delete({ surname: 'Doe' });
|
||||||
|
|
||||||
|
// Assert.
|
||||||
|
expect(db.deleteRecords).toHaveBeenCalledWith('users', { surname: 'Doe' });
|
||||||
|
|
||||||
|
expect(table.findByPrimaryKey({ id: 1 })).toBeNull();
|
||||||
|
expect(table.findByPrimaryKey({ id: 2 })).toBeNull();
|
||||||
|
expect(table.findByPrimaryKey({ id: 3 })).toEqual(jane);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes items by primary key', async () => {
|
||||||
|
// Arrange.
|
||||||
|
const john = { id: 1, name: 'John', surname: 'Doe' };
|
||||||
|
const amy = { id: 2, name: 'Amy', surname: 'Doe' };
|
||||||
|
|
||||||
|
records.push(john);
|
||||||
|
records.push(amy);
|
||||||
|
|
||||||
|
// Act.
|
||||||
|
const table = await UsersTable.create(db);
|
||||||
|
|
||||||
|
await table.deleteByPrimaryKey({ id: 1 });
|
||||||
|
|
||||||
|
// Assert.
|
||||||
|
expect(db.deleteRecords).toHaveBeenCalledWith('users', { id: 1 });
|
||||||
|
|
||||||
|
expect(table.findByPrimaryKey({ id: 1 })).toBeNull();
|
||||||
|
expect(table.findByPrimaryKey({ id: 2 })).toEqual(amy);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
|
@ -15,6 +15,8 @@
|
||||||
/* tslint:disable:no-console */
|
/* tslint:disable:no-console */
|
||||||
|
|
||||||
import { SQLiteDB } from '@classes/sqlitedb';
|
import { SQLiteDB } from '@classes/sqlitedb';
|
||||||
|
import { DbTransaction, SQLiteObject } from '@ionic-native/sqlite/ngx';
|
||||||
|
import { CoreDB } from '@services/db';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class to mock the interaction with the SQLite database.
|
* Class to mock the interaction with the SQLite database.
|
||||||
|
@ -158,16 +160,6 @@ export class SQLiteDBMock extends SQLiteDB {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the database.
|
|
||||||
*/
|
|
||||||
init(): void {
|
|
||||||
// This DB is for desktop apps, so use a big size to be sure it isn't filled.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
this.db = (<any> window).openDatabase(this.name, '1.0', this.name, 500 * 1024 * 1024);
|
|
||||||
this.promise = Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the database. Only needed if it was closed before, a database is automatically opened when created.
|
* Open the database. Only needed if it was closed before, a database is automatically opened when created.
|
||||||
*
|
*
|
||||||
|
@ -178,4 +170,40 @@ export class SQLiteDBMock extends SQLiteDB {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
protected async createDatabase(): Promise<SQLiteObject> {
|
||||||
|
// This DB is for desktop apps, so use a big size to be sure it isn't filled.
|
||||||
|
return (window as unknown as WebSQLWindow).openDatabase(this.name, '1.0', this.name, 500 * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
protected getDatabaseSpies(db: SQLiteObject): Partial<SQLiteObject> {
|
||||||
|
return {
|
||||||
|
transaction: (callback) => db.transaction((transaction) => {
|
||||||
|
const transactionSpy: DbTransaction = {
|
||||||
|
executeSql(sql, params, success, error) {
|
||||||
|
const start = performance.now();
|
||||||
|
const resolve = callback => (...args) => {
|
||||||
|
CoreDB.logQuery(sql, performance.now() - start, params);
|
||||||
|
|
||||||
|
return callback(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
return transaction.executeSql(sql, params, resolve(success), resolve(error));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return callback(transactionSpy);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebSQLWindow extends Window {
|
||||||
|
openDatabase(name: string, version: string, displayName: string, estimatedSize: number): SQLiteObject;
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,12 +16,14 @@ import { ApplicationRef, NgZone as NgZoneService } from '@angular/core';
|
||||||
import { CorePushNotifications, CorePushNotificationsProvider } from '@features/pushnotifications/services/pushnotifications';
|
import { CorePushNotifications, CorePushNotificationsProvider } from '@features/pushnotifications/services/pushnotifications';
|
||||||
import { CoreApp, CoreAppProvider } from '@services/app';
|
import { CoreApp, CoreAppProvider } from '@services/app';
|
||||||
import { CoreCronDelegate, CoreCronDelegateService } from '@services/cron';
|
import { CoreCronDelegate, CoreCronDelegateService } from '@services/cron';
|
||||||
|
import { CoreDB, CoreDbProvider } from '@services/db';
|
||||||
import { CoreCustomURLSchemes, CoreCustomURLSchemesProvider } from '@services/urlschemes';
|
import { CoreCustomURLSchemes, CoreCustomURLSchemesProvider } from '@services/urlschemes';
|
||||||
import { Application, NgZone } from '@singletons';
|
import { Application, NgZone } from '@singletons';
|
||||||
|
|
||||||
type AutomatedTestsWindow = Window & {
|
type AutomatedTestsWindow = Window & {
|
||||||
appRef?: ApplicationRef;
|
appRef?: ApplicationRef;
|
||||||
appProvider?: CoreAppProvider;
|
appProvider?: CoreAppProvider;
|
||||||
|
dbProvider?: CoreDbProvider;
|
||||||
cronProvider?: CoreCronDelegateService;
|
cronProvider?: CoreCronDelegateService;
|
||||||
ngZone?: NgZoneService;
|
ngZone?: NgZoneService;
|
||||||
pushNotifications?: CorePushNotificationsProvider;
|
pushNotifications?: CorePushNotificationsProvider;
|
||||||
|
@ -31,6 +33,7 @@ type AutomatedTestsWindow = Window & {
|
||||||
function initializeAutomatedTestsWindow(window: AutomatedTestsWindow) {
|
function initializeAutomatedTestsWindow(window: AutomatedTestsWindow) {
|
||||||
window.appRef = Application.instance;
|
window.appRef = Application.instance;
|
||||||
window.appProvider = CoreApp.instance;
|
window.appProvider = CoreApp.instance;
|
||||||
|
window.dbProvider = CoreDB.instance;
|
||||||
window.cronProvider = CoreCronDelegate.instance;
|
window.cronProvider = CoreCronDelegate.instance;
|
||||||
window.ngZone = NgZone.instance;
|
window.ngZone = NgZone.instance;
|
||||||
window.pushNotifications = CorePushNotifications.instance;
|
window.pushNotifications = CorePushNotifications.instance;
|
||||||
|
|
|
@ -15,9 +15,10 @@
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
import { CoreApp } from '@services/app';
|
import { CoreApp } from '@services/app';
|
||||||
import { SQLiteDB } from '@classes/sqlitedb';
|
|
||||||
import { makeSingleton } from '@singletons';
|
import { makeSingleton } from '@singletons';
|
||||||
import { CONFIG_TABLE_NAME, APP_SCHEMA, ConfigDBEntry } from '@services/database/config';
|
import { CONFIG_TABLE_NAME, APP_SCHEMA, ConfigDBEntry } from '@services/database/config';
|
||||||
|
import { CoreDatabaseTable } from '@classes/database-table';
|
||||||
|
import { CorePromisedValue } from '@classes/promised-value';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory to provide access to dynamic and permanent config and settings.
|
* Factory to provide access to dynamic and permanent config and settings.
|
||||||
|
@ -26,11 +27,10 @@ import { CONFIG_TABLE_NAME, APP_SCHEMA, ConfigDBEntry } from '@services/database
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class CoreConfigProvider {
|
export class CoreConfigProvider {
|
||||||
|
|
||||||
protected appDB: Promise<SQLiteDB>;
|
protected dbTable: CorePromisedValue<CoreConfigTable>;
|
||||||
protected resolveAppDB!: (appDB: SQLiteDB) => void;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.appDB = new Promise(resolve => this.resolveAppDB = resolve);
|
this.dbTable = new CorePromisedValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -43,7 +43,10 @@ export class CoreConfigProvider {
|
||||||
// Ignore errors.
|
// Ignore errors.
|
||||||
}
|
}
|
||||||
|
|
||||||
this.resolveAppDB(CoreApp.getDB());
|
const db = CoreApp.getDB();
|
||||||
|
const table = await CoreConfigTable.create(db);
|
||||||
|
|
||||||
|
this.dbTable.resolve(table);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -53,9 +56,9 @@ export class CoreConfigProvider {
|
||||||
* @return Promise resolved when done.
|
* @return Promise resolved when done.
|
||||||
*/
|
*/
|
||||||
async delete(name: string): Promise<void> {
|
async delete(name: string): Promise<void> {
|
||||||
const db = await this.appDB;
|
const table = await this.dbTable;
|
||||||
|
|
||||||
await db.deleteRecords(CONFIG_TABLE_NAME, { name });
|
await table.deleteByPrimaryKey({ name });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -66,19 +69,18 @@ export class CoreConfigProvider {
|
||||||
* @return Resolves upon success along with the config data. Reject on failure.
|
* @return Resolves upon success along with the config data. Reject on failure.
|
||||||
*/
|
*/
|
||||||
async get<T>(name: string, defaultValue?: T): Promise<T> {
|
async get<T>(name: string, defaultValue?: T): Promise<T> {
|
||||||
const db = await this.appDB;
|
const table = await this.dbTable;
|
||||||
|
const record = table.findByPrimaryKey({ name });
|
||||||
|
|
||||||
try {
|
if (record !== null) {
|
||||||
const entry = await db.getRecord<ConfigDBEntry>(CONFIG_TABLE_NAME, { name });
|
return record.value;
|
||||||
|
}
|
||||||
|
|
||||||
return entry.value;
|
|
||||||
} catch (error) {
|
|
||||||
if (defaultValue !== undefined) {
|
if (defaultValue !== undefined) {
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw new Error(`Couldn't get config with name '${name}'`);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -89,11 +91,21 @@ export class CoreConfigProvider {
|
||||||
* @return Promise resolved when done.
|
* @return Promise resolved when done.
|
||||||
*/
|
*/
|
||||||
async set(name: string, value: number | string): Promise<void> {
|
async set(name: string, value: number | string): Promise<void> {
|
||||||
const db = await this.appDB;
|
const table = await this.dbTable;
|
||||||
|
|
||||||
await db.insertRecord(CONFIG_TABLE_NAME, { name, value });
|
await table.insert({ name, value });
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CoreConfig = makeSingleton(CoreConfigProvider);
|
export const CoreConfig = makeSingleton(CoreConfigProvider);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config database table.
|
||||||
|
*/
|
||||||
|
class CoreConfigTable extends CoreDatabaseTable<ConfigDBEntry, 'name'> {
|
||||||
|
|
||||||
|
protected table = CONFIG_TABLE_NAME;
|
||||||
|
protected primaryKeys = ['name'];
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { Injectable } from '@angular/core';
|
||||||
import { SQLiteDB } from '@classes/sqlitedb';
|
import { SQLiteDB } from '@classes/sqlitedb';
|
||||||
import { SQLiteDBMock } from '@features/emulator/classes/sqlitedb';
|
import { SQLiteDBMock } from '@features/emulator/classes/sqlitedb';
|
||||||
import { makeSingleton, SQLite, Platform } from '@singletons';
|
import { makeSingleton, SQLite, Platform } from '@singletons';
|
||||||
|
import { CoreAppProvider } from './app';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This service allows interacting with the local database to store and retrieve data.
|
* This service allows interacting with the local database to store and retrieve data.
|
||||||
|
@ -24,8 +25,29 @@ import { makeSingleton, SQLite, Platform } from '@singletons';
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class CoreDbProvider {
|
export class CoreDbProvider {
|
||||||
|
|
||||||
|
queryLogs: CoreDbQueryLog[] = [];
|
||||||
|
|
||||||
protected dbInstances: {[name: string]: SQLiteDB} = {};
|
protected dbInstances: {[name: string]: SQLiteDB} = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether database queries should be logged.
|
||||||
|
*
|
||||||
|
* @returns Whether queries should be logged.
|
||||||
|
*/
|
||||||
|
loggingEnabled(): boolean {
|
||||||
|
return CoreAppProvider.isAutomated();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log a query.
|
||||||
|
*
|
||||||
|
* @param sql Query SQL.
|
||||||
|
* @param params Query parameters.
|
||||||
|
*/
|
||||||
|
logQuery(sql: string, duration: number, params?: unknown[]): void {
|
||||||
|
this.queryLogs.push({ sql, duration, params });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a database object.
|
* Get or create a database object.
|
||||||
*
|
*
|
||||||
|
@ -81,3 +103,12 @@ export class CoreDbProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CoreDB = makeSingleton(CoreDbProvider);
|
export const CoreDB = makeSingleton(CoreDbProvider);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database query log entry.
|
||||||
|
*/
|
||||||
|
export interface CoreDbQueryLog {
|
||||||
|
sql: string;
|
||||||
|
duration: number;
|
||||||
|
params?: unknown[];
|
||||||
|
}
|
||||||
|
|
|
@ -133,12 +133,10 @@ export type WrapperComponentFixture<T> = ComponentFixture<WrapperComponent<T>>;
|
||||||
* @param overrides Object with the properties or methods to override, or a list of methods to override with an empty function.
|
* @param overrides Object with the properties or methods to override, or a list of methods to override with an empty function.
|
||||||
* @return Mock instance.
|
* @return Mock instance.
|
||||||
*/
|
*/
|
||||||
export function mock<T>(instance?: T, overrides?: string[] | Record<string, unknown>): T;
|
|
||||||
export function mock<T>(instance?: Partial<T>, overrides?: string[] | Record<string, unknown>): Partial<T>;
|
|
||||||
export function mock<T>(
|
export function mock<T>(
|
||||||
instance: T | Partial<T> = {},
|
instance: T | Partial<T> = {},
|
||||||
overrides: string[] | Record<string, unknown> = {},
|
overrides: string[] | Record<string, unknown> = {},
|
||||||
): T | Partial<T> {
|
): T {
|
||||||
// If overrides is an object, apply them to the instance.
|
// If overrides is an object, apply them to the instance.
|
||||||
if (!Array.isArray(overrides)) {
|
if (!Array.isArray(overrides)) {
|
||||||
Object.assign(instance, overrides);
|
Object.assign(instance, overrides);
|
||||||
|
@ -162,7 +160,7 @@ export function mock<T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return instance;
|
return instance as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mockSingleton<T>(singletonClass: CoreSingletonProxy<T>, instance: T): T;
|
export function mockSingleton<T>(singletonClass: CoreSingletonProxy<T>, instance: T): T;
|
||||||
|
|
|
@ -58,6 +58,16 @@ class performance_measure implements behat_app_listener {
|
||||||
*/
|
*/
|
||||||
public $blocking;
|
public $blocking;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
public $databaseStart;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
public $database;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var int
|
* @var int
|
||||||
*/
|
*/
|
||||||
|
@ -90,6 +100,7 @@ class performance_measure implements behat_app_listener {
|
||||||
$this->start = $this->now();
|
$this->start = $this->now();
|
||||||
|
|
||||||
$this->observeLongTasks();
|
$this->observeLongTasks();
|
||||||
|
$this->startDatabaseCount();
|
||||||
|
|
||||||
$this->behatAppUnsubscribe = behat_app::listen($this);
|
$this->behatAppUnsubscribe = behat_app::listen($this);
|
||||||
}
|
}
|
||||||
|
@ -107,6 +118,7 @@ class performance_measure implements behat_app_listener {
|
||||||
|
|
||||||
$this->analyseDuration();
|
$this->analyseDuration();
|
||||||
$this->analyseLongTasks();
|
$this->analyseLongTasks();
|
||||||
|
$this->analyseDatabaseUsage();
|
||||||
$this->analysePerformanceLogs();
|
$this->analysePerformanceLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -131,6 +143,7 @@ class performance_measure implements behat_app_listener {
|
||||||
'styling' => $this->styling,
|
'styling' => $this->styling,
|
||||||
'blocking' => $this->blocking,
|
'blocking' => $this->blocking,
|
||||||
'longTasks' => count($this->longTasks),
|
'longTasks' => count($this->longTasks),
|
||||||
|
'database' => $this->database,
|
||||||
'networking' => $this->networking,
|
'networking' => $this->networking,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -181,6 +194,17 @@ class performance_measure implements behat_app_listener {
|
||||||
");
|
");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record how many database queries have been logged so far.
|
||||||
|
*/
|
||||||
|
private function startDatabaseCount(): void {
|
||||||
|
try {
|
||||||
|
$this->databaseStart = $this->driver->evaluateScript('dbProvider.queryLogs.length') ?? 0;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->databaseStart = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flush Performance observer.
|
* Flush Performance observer.
|
||||||
*/
|
*/
|
||||||
|
@ -228,6 +252,13 @@ class performance_measure implements behat_app_listener {
|
||||||
$this->blocking = $blocking;
|
$this->blocking = $blocking;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyse database usage.
|
||||||
|
*/
|
||||||
|
private function analyseDatabaseUsage(): void {
|
||||||
|
$this->database = $this->driver->evaluateScript('dbProvider.queryLogs.length') - $this->databaseStart;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Analyse performance logs.
|
* Analyse performance logs.
|
||||||
*/
|
*/
|
||||||
|
|
Loading…
Reference in New Issue