From 808a242cbcd462b9c275711a145a0426dcd306ce Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 3 Feb 2022 12:35:16 +0100 Subject: [PATCH 01/10] MOBILE-3977 core: Refactor database table wrapper Decouple caching behaviour into subclasses using a configurable proxy --- src/core/classes/database-table.ts | 194 -------------- .../classes/database/database-table-proxy.ts | 187 ++++++++++++++ src/core/classes/database/database-table.ts | 240 ++++++++++++++++++ .../classes/database/eager-database-table.ts | 168 ++++++++++++ src/core/classes/sqlitedb.ts | 2 +- src/core/classes/tests/database-table.test.ts | 204 ++++++++++++--- src/core/services/config.ts | 54 ++-- 7 files changed, 785 insertions(+), 264 deletions(-) delete mode 100644 src/core/classes/database-table.ts create mode 100644 src/core/classes/database/database-table-proxy.ts create mode 100644 src/core/classes/database/database-table.ts create mode 100644 src/core/classes/database/eager-database-table.ts diff --git a/src/core/classes/database-table.ts b/src/core/classes/database-table.ts deleted file mode 100644 index 5e6ea32ee..000000000 --- a/src/core/classes/database-table.ts +++ /dev/null @@ -1,194 +0,0 @@ -// (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 = GetPrimaryKey -> { - - /** - * Create an instance. - * - * @param db Database connection. - * @returns Instance. - */ - static async create(this: CoreDatabaseTableConstructor, db: SQLiteDB): Promise { - const instance = new this(db); - - await instance.initialize(); - - return instance; - } - - protected db: SQLiteDB; - protected data: Record; - 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 | 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 { - 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): Promise { - 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 { - 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 { - const records = await this.db.getRecords(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) 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): 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>; - -/** - * Database table constructor. - */ -type CoreDatabaseTableConstructor = { - new (db: SQLiteDB): T; -}; - -/** - * Infer primary key type from database record and columns types. - */ -type GetPrimaryKey = { - [column in PrimaryKeyColumns]: DBRecord[column]; -}; diff --git a/src/core/classes/database/database-table-proxy.ts b/src/core/classes/database/database-table-proxy.ts new file mode 100644 index 000000000..8338df188 --- /dev/null +++ b/src/core/classes/database/database-table-proxy.ts @@ -0,0 +1,187 @@ +// (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 { CorePromisedValue } from '@classes/promised-value'; +import { SQLiteDB, SQLiteDBRecordValues } from '@classes/sqlitedb'; +import { CoreDatabaseReducer, CoreDatabaseTable, CoreDatabaseConditions, GetDBRecordPrimaryKey } from './database-table'; +import { CoreEagerDatabaseTable } from './eager-database-table'; + +/** + * Database table proxy used to route database interactions through different implementations. + * + * This class allows using a database wrapper with different optimization strategies that can be changed at runtime. + */ +export class CoreDatabaseTableProxy< + DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues, + PrimaryKeyColumn extends keyof DBRecord = 'id', + PrimaryKey extends GetDBRecordPrimaryKey = GetDBRecordPrimaryKey +> extends CoreDatabaseTable { + + protected config: CoreDatabaseConfiguration; + protected target: CorePromisedValue> = new CorePromisedValue(); + + constructor( + config: Partial, + database: SQLiteDB, + tableName: string, + primaryKeyColumns?: PrimaryKeyColumn[], + ) { + super(database, tableName, primaryKeyColumns); + + this.config = { ...this.getConfigDefaults(), ...config }; + } + + /** + * @inheritdoc + */ + async initialize(): Promise { + const target = this.createTarget(); + + await target.initialize(); + + this.target.resolve(target); + } + + /** + * @inheritdoc + */ + async all(conditions?: Partial): Promise { + const target = await this.target; + + return target.all(conditions); + } + + /** + * @inheritdoc + */ + async find(conditions: Partial): Promise { + const target = await this.target; + + return target.find(conditions); + } + + /** + * @inheritdoc + */ + async findByPrimaryKey(primaryKey: PrimaryKey): Promise { + const target = await this.target; + + return target.findByPrimaryKey(primaryKey); + } + + /** + * @inheritdoc + */ + async reduce(reducer: CoreDatabaseReducer, conditions?: CoreDatabaseConditions): Promise { + const target = await this.target; + + return target.reduce(reducer, conditions); + } + + /** + * @inheritdoc + */ + async insert(record: DBRecord): Promise { + const target = await this.target; + + return target.insert(record); + } + + /** + * @inheritdoc + */ + async update(updates: Partial, conditions?: Partial): Promise { + const target = await this.target; + + return target.update(updates, conditions); + } + + /** + * @inheritdoc + */ + async updateWhere(updates: Partial, conditions: CoreDatabaseConditions): Promise { + const target = await this.target; + + return target.updateWhere(updates, conditions); + } + + /** + * @inheritdoc + */ + async delete(conditions?: Partial): Promise { + const target = await this.target; + + return target.delete(conditions); + } + + /** + * @inheritdoc + */ + async deleteByPrimaryKey(primaryKey: PrimaryKey): Promise { + const target = await this.target; + + return target.deleteByPrimaryKey(primaryKey); + } + + /** + * Get default configuration values. + * + * @returns Config defaults. + */ + protected getConfigDefaults(): CoreDatabaseConfiguration { + return { + cachingStrategy: CoreDatabaseCachingStrategy.None, + }; + } + + /** + * Create proxy target. + * + * @returns Target instance. + */ + protected createTarget(): CoreDatabaseTable { + return this.createTable(this.config.cachingStrategy); + } + + /** + * Create a database table using the given caching strategy. + * + * @param cachingStrategy Caching strategy. + * @returns Database table. + */ + protected createTable(cachingStrategy: CoreDatabaseCachingStrategy): CoreDatabaseTable { + switch (cachingStrategy) { + case CoreDatabaseCachingStrategy.Eager: + return new CoreEagerDatabaseTable(this.database, this.tableName, this.primaryKeyColumns); + case CoreDatabaseCachingStrategy.None: + return new CoreDatabaseTable(this.database, this.tableName, this.primaryKeyColumns); + } + } + +} + +/** + * Database proxy configuration. + */ +export interface CoreDatabaseConfiguration { + cachingStrategy: CoreDatabaseCachingStrategy; +} + +/** + * Database caching strategies. + */ +export enum CoreDatabaseCachingStrategy { + Eager = 'eager', + None = 'none', +} diff --git a/src/core/classes/database/database-table.ts b/src/core/classes/database/database-table.ts new file mode 100644 index 000000000..2bb34ff89 --- /dev/null +++ b/src/core/classes/database/database-table.ts @@ -0,0 +1,240 @@ +// (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, SQLiteDBRecordValue, SQLiteDBRecordValues } from '@classes/sqlitedb'; + +/** + * Wrapper used to interact with a database table. + */ +export class CoreDatabaseTable< + DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues, + PrimaryKeyColumn extends keyof DBRecord = 'id', + PrimaryKey extends GetDBRecordPrimaryKey = GetDBRecordPrimaryKey +> { + + protected database: SQLiteDB; + protected tableName: string; + protected primaryKeyColumns: PrimaryKeyColumn[]; + + constructor(database: SQLiteDB, tableName: string, primaryKeyColumns?: PrimaryKeyColumn[]) { + this.database = database; + this.tableName = tableName; + this.primaryKeyColumns = primaryKeyColumns ?? ['id'] as PrimaryKeyColumn[]; + } + + /** + * Get database connection. + * + * @returns Database connection. + */ + getDatabase(): SQLiteDB { + return this.database; + } + + /** + * Get table name. + * + * @returns Table name. + */ + getTableName(): string { + return this.tableName; + } + + /** + * Get primary key columns. + * + * @returns Primary key columns. + */ + getPrimaryKeyColumns(): PrimaryKeyColumn[] { + return this.primaryKeyColumns.slice(0); + } + + /** + * Initialize. + */ + async initialize(): Promise { + // Nothing to initialize by default, override this method if necessary. + } + + /** + * Destroy. + */ + async destroy(): Promise { + // Nothing to destroy by default, override this method if necessary. + } + + /** + * Get records matching the given conditions. + * + * @param conditions Matching conditions. If this argument is missing, all records in the table will be returned. + * @returns Database records. + */ + all(conditions?: Partial): Promise { + return conditions + ? this.database.getRecords(this.tableName, conditions) + : this.database.getAllRecords(this.tableName); + } + + /** + * Find one record matching the given conditions. + * + * @param conditions Matching conditions. + * @returns Database record. + */ + find(conditions: Partial): Promise { + return this.database.getRecord(this.tableName, conditions); + } + + /** + * Find one record by its primary key. + * + * @param primaryKey Primary key. + * @returns Database record. + */ + findByPrimaryKey(primaryKey: PrimaryKey): Promise { + return this.database.getRecord(this.tableName, primaryKey); + } + + /** + * Reduce some records into a single value. + * + * @param reducer Reducer functions in SQL and JavaScript. + * @param conditions Matching conditions in SQL and JavaScript. If this argument is missing, all records in the table + * will be used. + * @returns Reduced value. + */ + reduce(reducer: CoreDatabaseReducer, conditions?: CoreDatabaseConditions): Promise { + return this.database.getFieldSql( + `SELECT ${reducer.sql} FROM ${this.tableName} ${conditions?.sql ?? ''}`, + conditions?.sqlParams, + ) as unknown as Promise; + } + + /** + * Insert a new record. + * + * @param record Database record. + */ + async insert(record: DBRecord): Promise { + await this.database.insertRecord(this.tableName, record); + } + + /** + * Update records matching the given conditions. + * + * @param updates Record updates. + * @param conditions Matching conditions. If this argument is missing, all records will be updated. + */ + async update(updates: Partial, conditions?: Partial): Promise { + await this.database.updateRecords(this.tableName, updates, conditions); + } + + /** + * Update records matching the given conditions. + * + * This method should be used when it's necessary to apply complex conditions; the simple `update` + * method should be favored otherwise for better performance. + * + * @param updates Record updates. + * @param conditions Matching conditions in SQL and JavaScript. + */ + async updateWhere(updates: Partial, conditions: CoreDatabaseConditions): Promise { + await this.database.updateRecordsWhere(this.tableName, updates, conditions.sql, conditions.sqlParams); + } + + /** + * Delete records matching the given conditions. + * + * @param conditions Matching conditions. If this argument is missing, all records will be deleted. + */ + async delete(conditions?: Partial): Promise { + conditions + ? await this.database.deleteRecords(this.tableName, conditions) + : await this.database.deleteRecords(this.tableName); + } + + /** + * Delete a single record identified by its primary key. + * + * @param primaryKey Record primary key. + */ + async deleteByPrimaryKey(primaryKey: PrimaryKey): Promise { + await this.database.deleteRecords(this.tableName, primaryKey); + } + + /** + * Get the primary key from a database record. + * + * @param record Database record. + * @returns Primary key. + */ + protected getPrimaryKeyFromRecord(record: DBRecord): PrimaryKey { + return this.primaryKeyColumns.reduce((primaryKey, column) => { + primaryKey[column] = record[column]; + + return primaryKey; + }, {} as Record) as PrimaryKey; + } + + /** + * Serialize a primary key with a string representation. + * + * @param primaryKey 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 Matching conditions. + * @returns Whether the record matches the conditions. + */ + protected recordMatches(record: DBRecord, conditions: Partial): boolean { + return !Object.entries(conditions).some(([column, value]) => record[column] !== value); + } + +} + +/** + * Infer primary key type from database record and primary key column types. + */ +export type GetDBRecordPrimaryKey = { + [column in PrimaryKeyColumn]: DBRecord[column]; +}; + +/** + * Reducer used to accumulate a value from multiple records both in SQL and JavaScript. + * + * Both operations should be equivalent. + */ +export type CoreDatabaseReducer = { + sql: string; + js: (previousValue: T, record: DBRecord) => T; + jsInitialValue: T; +}; + +/** + * Conditions to match database records both in SQL and JavaScript. + * + * Both conditions should be equivalent. + */ +export type CoreDatabaseConditions = { + sql: string; + sqlParams?: SQLiteDBRecordValue[]; + js: (record: DBRecord) => boolean; +}; diff --git a/src/core/classes/database/eager-database-table.ts b/src/core/classes/database/eager-database-table.ts new file mode 100644 index 000000000..7cc3cf53c --- /dev/null +++ b/src/core/classes/database/eager-database-table.ts @@ -0,0 +1,168 @@ +// (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 { CoreError } from '@classes/errors/error'; +import { SQLiteDBRecordValues } from '@classes/sqlitedb'; +import { CoreDatabaseTable, CoreDatabaseConditions, GetDBRecordPrimaryKey, CoreDatabaseReducer } from './database-table'; + +/** + * Wrapper used to improve performance by caching all the records for faster read operations. + * + * This implementation works best for tables that don't have a lot of records and are read often; for tables with too many + * records use CoreLazyDatabaseTable instead. + */ +export class CoreEagerDatabaseTable< + DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues, + PrimaryKeyColumn extends keyof DBRecord = 'id', + PrimaryKey extends GetDBRecordPrimaryKey = GetDBRecordPrimaryKey +> extends CoreDatabaseTable { + + protected records: Record = {}; + + /** + * @inheritdoc + */ + async initialize(): Promise { + const records = await super.all(); + + this.records = records.reduce((data, record) => { + const primaryKey = this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record)); + + data[primaryKey] = record; + + return data; + }, {}); + } + + /** + * @inheritdoc + */ + async all(conditions?: Partial): Promise { + const records = Object.values(this.records); + + return conditions + ? records.filter(record => this.recordMatches(record, conditions)) + : records; + } + + /** + * @inheritdoc + */ + async find(conditions: Partial): Promise { + const record = Object.values(this.records).find(record => this.recordMatches(record, conditions)) ?? null; + + if (record === null) { + throw new CoreError('No records found.'); + } + + return record; + } + + /** + * @inheritdoc + */ + async findByPrimaryKey(primaryKey: PrimaryKey): Promise { + const record = this.records[this.serializePrimaryKey(primaryKey)] ?? null; + + if (record === null) { + throw new CoreError('No records found.'); + } + + return record; + } + + /** + * @inheritdoc + */ + async reduce(reducer: CoreDatabaseReducer, conditions?: CoreDatabaseConditions): Promise { + return Object + .values(this.records) + .reduce( + (result, record) => (!conditions || conditions.js(record)) ? reducer.js(result, record) : result, + reducer.jsInitialValue, + ); + } + + /** + * @inheritdoc + */ + async insert(record: DBRecord): Promise { + await super.insert(record); + + const primaryKey = this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record)); + + this.records[primaryKey] = record; + } + + /** + * @inheritdoc + */ + async update(updates: Partial, conditions?: Partial): Promise { + await super.update(updates, conditions); + + for (const record of Object.values(this.records)) { + if (conditions && !this.recordMatches(record, conditions)) { + continue; + } + + Object.assign(record, updates); + } + } + + /** + * @inheritdoc + */ + async updateWhere(updates: Partial, conditions: CoreDatabaseConditions): Promise { + await super.updateWhere(updates, conditions); + + for (const record of Object.values(this.records)) { + if (!conditions.js(record)) { + continue; + } + + Object.assign(record, updates); + } + } + + /** + * @inheritdoc + */ + async delete(conditions?: Partial): Promise { + await super.delete(conditions); + + if (!conditions) { + this.records = {}; + + return; + } + + Object.entries(this.records).forEach(([id, record]) => { + if (!this.recordMatches(record, conditions)) { + return; + } + + delete this.records[id]; + }); + } + + /** + * @inheritdoc + */ + async deleteByPrimaryKey(primaryKey: PrimaryKey): Promise { + await super.deleteByPrimaryKey(primaryKey); + + delete this.records[this.serializePrimaryKey(primaryKey)]; + } + +} diff --git a/src/core/classes/sqlitedb.ts b/src/core/classes/sqlitedb.ts index 615a3eb7f..915ea9ffb 100644 --- a/src/core/classes/sqlitedb.ts +++ b/src/core/classes/sqlitedb.ts @@ -1206,4 +1206,4 @@ export type SQLiteDBQueryParams = { params: SQLiteDBRecordValue[]; }; -type SQLiteDBRecordValue = number | string; +export type SQLiteDBRecordValue = number | string; diff --git a/src/core/classes/tests/database-table.test.ts b/src/core/classes/tests/database-table.test.ts index 92e6d7ec2..f87589fc7 100644 --- a/src/core/classes/tests/database-table.test.ts +++ b/src/core/classes/tests/database-table.test.ts @@ -13,7 +13,11 @@ // limitations under the License. import { mock } from '@/testing/utils'; -import { CoreDatabaseTable } from '@classes/database-table'; +import { + CoreDatabaseCachingStrategy, + CoreDatabaseConfiguration, + CoreDatabaseTableProxy, +} from '@classes/database/database-table-proxy'; import { SQLiteDB, SQLiteDBRecordValues } from '@classes/sqlitedb'; interface User extends SQLiteDBRecordValues { @@ -22,30 +26,60 @@ interface User extends SQLiteDBRecordValues { surname: string; } -class UsersTable extends CoreDatabaseTable { - - protected table = 'users'; - +function userMatches(user: User, conditions: Partial) { + return !Object.entries(conditions).some(([column, value]) => user[column] !== value); } -describe('CoreDatabaseTable', () => { +function prepareStubs(config: Partial = {}): [User[], SQLiteDB, CoreDatabaseTableProxy] { + const records: User[] = []; + const database = mock({ + getRecord: async (_, conditions) => { + const record = records.find(record => userMatches(record, conditions)); + + if (!record) { + throw new Error(); + } + + return record as unknown as T; + }, + getRecords: async (_, conditions) => records.filter(record => userMatches(record, conditions)) as unknown as T[], + getAllRecords: async () => records as unknown as T[], + deleteRecords: async (_, conditions) => { + const usersToDelete: User[] = []; + + for (const user of records) { + if (conditions && !userMatches(user, conditions)) { + continue; + } + + usersToDelete.push(user); + } + + for (const user of usersToDelete) { + records.splice(records.indexOf(user), 1); + } + + return usersToDelete.length; + }, + insertRecord: async (_, user: User) => records.push(user) && 1, + }); + const table = new CoreDatabaseTableProxy(config, database, 'users'); + + return [records, database, table]; +} + +describe('CoreDatabaseTable with eager caching', () => { let records: User[]; - let db: SQLiteDB; + let database: SQLiteDB; + let table: CoreDatabaseTableProxy; - beforeEach(() => { - records = []; - db = mock({ - getRecords: async () => records as unknown as T[], - deleteRecords: async () => 0, - insertRecord: async () => 0, - }); - }); + beforeEach(() => [records, database, table] = prepareStubs({ cachingStrategy: CoreDatabaseCachingStrategy.Eager })); - it('reads all records on create', async () => { - await UsersTable.create(db); + it('reads all records on initialization', async () => { + await table.initialize(); - expect(db.getRecords).toHaveBeenCalledWith('users'); + expect(database.getAllRecords).toHaveBeenCalledWith('users'); }); it('finds items', async () => { @@ -55,27 +89,29 @@ describe('CoreDatabaseTable', () => { records.push(john); records.push(amy); - const table = await UsersTable.create(db); + await table.initialize(); - 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); + await expect(table.findByPrimaryKey({ id: 1 })).resolves.toEqual(john); + await expect(table.findByPrimaryKey({ id: 2 })).resolves.toEqual(amy); + await expect(table.find({ surname: 'Doe', name: 'John' })).resolves.toEqual(john); + await expect(table.find({ surname: 'Doe', name: 'Amy' })).resolves.toEqual(amy); + + expect(database.getRecord).not.toHaveBeenCalled(); }); it('inserts items', async () => { // Arrange. const john = { id: 1, name: 'John', surname: 'Doe' }; - // Act. - const table = await UsersTable.create(db); + await table.initialize(); + // Act. await table.insert(john); // Assert. - expect(db.insertRecord).toHaveBeenCalledWith('users', john); + expect(database.insertRecord).toHaveBeenCalledWith('users', john); - expect(table.findByPrimaryKey({ id: 1 })).toEqual(john); + await expect(table.findByPrimaryKey({ id: 1 })).resolves.toEqual(john); }); it('deletes items', async () => { @@ -88,17 +124,17 @@ describe('CoreDatabaseTable', () => { records.push(amy); records.push(jane); - // Act. - const table = await UsersTable.create(db); + await table.initialize(); + // Act. await table.delete({ surname: 'Doe' }); // Assert. - expect(db.deleteRecords).toHaveBeenCalledWith('users', { surname: 'Doe' }); + expect(database.deleteRecords).toHaveBeenCalledWith('users', { surname: 'Doe' }); - expect(table.findByPrimaryKey({ id: 1 })).toBeNull(); - expect(table.findByPrimaryKey({ id: 2 })).toBeNull(); - expect(table.findByPrimaryKey({ id: 3 })).toEqual(jane); + await expect(table.findByPrimaryKey({ id: 1 })).rejects.toThrow(); + await expect(table.findByPrimaryKey({ id: 2 })).rejects.toThrow(); + await expect(table.findByPrimaryKey({ id: 3 })).resolves.toEqual(jane); }); it('deletes items by primary key', async () => { @@ -109,16 +145,108 @@ describe('CoreDatabaseTable', () => { records.push(john); records.push(amy); - // Act. - const table = await UsersTable.create(db); + await table.initialize(); + // Act. await table.deleteByPrimaryKey({ id: 1 }); // Assert. - expect(db.deleteRecords).toHaveBeenCalledWith('users', { id: 1 }); + expect(database.deleteRecords).toHaveBeenCalledWith('users', { id: 1 }); - expect(table.findByPrimaryKey({ id: 1 })).toBeNull(); - expect(table.findByPrimaryKey({ id: 2 })).toEqual(amy); + await expect(table.findByPrimaryKey({ id: 1 })).rejects.toThrow(); + await expect(table.findByPrimaryKey({ id: 2 })).resolves.toEqual(amy); + }); + +}); + +describe('CoreDatabaseTable with no caching', () => { + + let records: User[]; + let database: SQLiteDB; + let table: CoreDatabaseTableProxy; + + beforeEach(() => [records, database, table] = prepareStubs({ cachingStrategy: CoreDatabaseCachingStrategy.None })); + + it('reads no records on initialization', async () => { + await table.initialize(); + + expect(database.getRecords).not.toHaveBeenCalled(); + expect(database.getAllRecords).not.toHaveBeenCalled(); + }); + + 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); + + await table.initialize(); + + await expect(table.findByPrimaryKey({ id: 1 })).resolves.toEqual(john); + await expect(table.findByPrimaryKey({ id: 2 })).resolves.toEqual(amy); + await expect(table.find({ surname: 'Doe', name: 'John' })).resolves.toEqual(john); + await expect(table.find({ surname: 'Doe', name: 'Amy' })).resolves.toEqual(amy); + + expect(database.getRecord).toHaveBeenCalledTimes(4); + }); + + it('inserts items', async () => { + // Arrange. + const john = { id: 1, name: 'John', surname: 'Doe' }; + + await table.initialize(); + + // Act. + await table.insert(john); + + // Assert. + expect(database.insertRecord).toHaveBeenCalledWith('users', john); + + await expect(table.findByPrimaryKey({ id: 1 })).resolves.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); + + await table.initialize(); + + // Act. + await table.delete({ surname: 'Doe' }); + + // Assert. + expect(database.deleteRecords).toHaveBeenCalledWith('users', { surname: 'Doe' }); + + await expect(table.findByPrimaryKey({ id: 1 })).rejects.toThrow(); + await expect(table.findByPrimaryKey({ id: 2 })).rejects.toThrow(); + await expect(table.findByPrimaryKey({ id: 3 })).resolves.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); + + await table.initialize(); + + // Act. + await table.deleteByPrimaryKey({ id: 1 }); + + // Assert. + expect(database.deleteRecords).toHaveBeenCalledWith('users', { id: 1 }); + + await expect(table.findByPrimaryKey({ id: 1 })).rejects.toThrow(); + await expect(table.findByPrimaryKey({ id: 2 })).resolves.toEqual(amy); }); }); diff --git a/src/core/services/config.ts b/src/core/services/config.ts index ee49af16d..7b52fcd3e 100644 --- a/src/core/services/config.ts +++ b/src/core/services/config.ts @@ -13,11 +13,11 @@ // limitations under the License. import { Injectable } from '@angular/core'; - +import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy'; import { CoreApp } from '@services/app'; +import { APP_SCHEMA, ConfigDBEntry, CONFIG_TABLE_NAME } from '@services/database/config'; import { makeSingleton } from '@singletons'; -import { CONFIG_TABLE_NAME, APP_SCHEMA, ConfigDBEntry } from '@services/database/config'; -import { CoreDatabaseTable } from '@classes/database-table'; +import { CoreDatabaseTable } from '@classes/database/database-table'; import { CorePromisedValue } from '@classes/promised-value'; /** @@ -27,11 +27,7 @@ import { CorePromisedValue } from '@classes/promised-value'; @Injectable({ providedIn: 'root' }) export class CoreConfigProvider { - protected dbTable: CorePromisedValue; - - constructor() { - this.dbTable = new CorePromisedValue(); - } + protected table: CorePromisedValue> = new CorePromisedValue(); /** * Initialize database. @@ -43,10 +39,16 @@ export class CoreConfigProvider { // Ignore errors. } - const db = CoreApp.getDB(); - const table = await CoreConfigTable.create(db); + const table = new CoreDatabaseTableProxy( + { cachingStrategy: CoreDatabaseCachingStrategy.Eager }, + CoreApp.getDB(), + CONFIG_TABLE_NAME, + ['name'], + ); - this.dbTable.resolve(table); + await table.initialize(); + + this.table.resolve(table); } /** @@ -56,7 +58,7 @@ export class CoreConfigProvider { * @return Promise resolved when done. */ async delete(name: string): Promise { - const table = await this.dbTable; + const table = await this.table; await table.deleteByPrimaryKey({ name }); } @@ -69,18 +71,18 @@ export class CoreConfigProvider { * @return Resolves upon success along with the config data. Reject on failure. */ async get(name: string, defaultValue?: T): Promise { - const table = await this.dbTable; - const record = table.findByPrimaryKey({ name }); + try { + const table = await this.table; + const record = await table.findByPrimaryKey({ name }); - if (record !== null) { return record.value; - } + } catch (error) { + if (defaultValue !== undefined) { + return defaultValue; + } - if (defaultValue !== undefined) { - return defaultValue; + throw error; } - - throw new Error(`Couldn't get config with name '${name}'`); } /** @@ -91,7 +93,7 @@ export class CoreConfigProvider { * @return Promise resolved when done. */ async set(name: string, value: number | string): Promise { - const table = await this.dbTable; + const table = await this.table; await table.insert({ name, value }); } @@ -99,13 +101,3 @@ export class CoreConfigProvider { } export const CoreConfig = makeSingleton(CoreConfigProvider); - -/** - * Config database table. - */ -class CoreConfigTable extends CoreDatabaseTable { - - protected table = CONFIG_TABLE_NAME; - protected primaryKeys = ['name']; - -} From 7c834281ce462484425408f9d165be31376a61fa Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 3 Feb 2022 13:23:08 +0100 Subject: [PATCH 02/10] MOBILE-3977 filepool: Optimize files table --- .eslintrc.js | 1 + .../classes/database/database-table-proxy.ts | 4 + .../classes/database/lazy-database-table.ts | 141 +++++++++++ src/core/classes/tests/database-table.test.ts | 237 ++++++++---------- src/core/services/filepool.ts | 115 ++++++--- 5 files changed, 337 insertions(+), 161 deletions(-) create mode 100644 src/core/classes/database/lazy-database-table.ts diff --git a/.eslintrc.js b/.eslintrc.js index 1b4ee6949..deda33df6 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -271,6 +271,7 @@ testsConfig['rules']['padded-blocks'] = [ switches: 'never', }, ]; +testsConfig['rules']['jest/expect-expect'] = 'off'; testsConfig['plugins'].push('jest'); testsConfig['extends'].push('plugin:jest/recommended'); diff --git a/src/core/classes/database/database-table-proxy.ts b/src/core/classes/database/database-table-proxy.ts index 8338df188..393e07ac4 100644 --- a/src/core/classes/database/database-table-proxy.ts +++ b/src/core/classes/database/database-table-proxy.ts @@ -16,6 +16,7 @@ import { CorePromisedValue } from '@classes/promised-value'; import { SQLiteDB, SQLiteDBRecordValues } from '@classes/sqlitedb'; import { CoreDatabaseReducer, CoreDatabaseTable, CoreDatabaseConditions, GetDBRecordPrimaryKey } from './database-table'; import { CoreEagerDatabaseTable } from './eager-database-table'; +import { CoreLazyDatabaseTable } from './lazy-database-table'; /** * Database table proxy used to route database interactions through different implementations. @@ -164,6 +165,8 @@ export class CoreDatabaseTableProxy< switch (cachingStrategy) { case CoreDatabaseCachingStrategy.Eager: return new CoreEagerDatabaseTable(this.database, this.tableName, this.primaryKeyColumns); + case CoreDatabaseCachingStrategy.Lazy: + return new CoreLazyDatabaseTable(this.database, this.tableName, this.primaryKeyColumns); case CoreDatabaseCachingStrategy.None: return new CoreDatabaseTable(this.database, this.tableName, this.primaryKeyColumns); } @@ -183,5 +186,6 @@ export interface CoreDatabaseConfiguration { */ export enum CoreDatabaseCachingStrategy { Eager = 'eager', + Lazy = 'lazy', None = 'none', } diff --git a/src/core/classes/database/lazy-database-table.ts b/src/core/classes/database/lazy-database-table.ts new file mode 100644 index 000000000..cf64623a7 --- /dev/null +++ b/src/core/classes/database/lazy-database-table.ts @@ -0,0 +1,141 @@ +// (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 { CoreError } from '@classes/errors/error'; +import { SQLiteDBRecordValues } from '@classes/sqlitedb'; +import { CoreDatabaseTable, CoreDatabaseConditions, GetDBRecordPrimaryKey } from './database-table'; + +/** + * Wrapper used to improve performance by caching records that are used often for faster read operations. + * + * This implementation works best for tables that have a lot of records and are read often; for tables with a few records use + * CoreEagerDatabaseTable instead. + */ +export class CoreLazyDatabaseTable< + DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues, + PrimaryKeyColumn extends keyof DBRecord = 'id', + PrimaryKey extends GetDBRecordPrimaryKey = GetDBRecordPrimaryKey +> extends CoreDatabaseTable { + + protected records: Record = {}; + + /** + * @inheritdoc + */ + async find(conditions: Partial): Promise { + let record: DBRecord | null = + Object.values(this.records).find(record => record && this.recordMatches(record, conditions)) ?? null; + + if (!record) { + record = await super.find(conditions); + + this.records[this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record))] = record; + } + + return record; + } + + /** + * @inheritdoc + */ + async findByPrimaryKey(primaryKey: PrimaryKey): Promise { + const serializePrimaryKey = this.serializePrimaryKey(primaryKey); + + if (!(serializePrimaryKey in this.records)) { + try { + const record = await super.findByPrimaryKey(primaryKey); + + this.records[serializePrimaryKey] = record; + + return record; + } catch (error) { + this.records[serializePrimaryKey] = null; + + throw error; + } + } + + const record = this.records[serializePrimaryKey]; + + if (!record) { + throw new CoreError('No records found.'); + } + + return record; + } + + /** + * @inheritdoc + */ + async insert(record: DBRecord): Promise { + await super.insert(record); + + this.records[this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record))] = record; + } + + /** + * @inheritdoc + */ + async update(updates: Partial, conditions?: Partial): Promise { + await super.update(updates, conditions); + + for (const record of Object.values(this.records)) { + if (!record || (conditions && !this.recordMatches(record, conditions))) { + continue; + } + + Object.assign(record, updates); + } + } + + /** + * @inheritdoc + */ + async updateWhere(updates: Partial, conditions: CoreDatabaseConditions): Promise { + await super.updateWhere(updates, conditions); + + for (const record of Object.values(this.records)) { + if (!record || !conditions.js(record)) { + continue; + } + + Object.assign(record, updates); + } + } + + /** + * @inheritdoc + */ + async delete(conditions?: Partial): Promise { + await super.delete(conditions); + + for (const [primaryKey, record] of Object.entries(this.records)) { + if (!record || (conditions && !this.recordMatches(record, conditions))) { + continue; + } + + this.records[primaryKey] = null; + } + } + + /** + * @inheritdoc + */ + async deleteByPrimaryKey(primaryKey: PrimaryKey): Promise { + await super.deleteByPrimaryKey(primaryKey); + + this.records[this.serializePrimaryKey(primaryKey)] = null; + } + +} diff --git a/src/core/classes/tests/database-table.test.ts b/src/core/classes/tests/database-table.test.ts index f87589fc7..4d02603d2 100644 --- a/src/core/classes/tests/database-table.test.ts +++ b/src/core/classes/tests/database-table.test.ts @@ -13,6 +13,7 @@ // limitations under the License. import { mock } from '@/testing/utils'; +import { CoreDatabaseTable } from '@classes/database/database-table'; import { CoreDatabaseCachingStrategy, CoreDatabaseConfiguration, @@ -30,7 +31,7 @@ function userMatches(user: User, conditions: Partial) { return !Object.entries(conditions).some(([column, value]) => user[column] !== value); } -function prepareStubs(config: Partial = {}): [User[], SQLiteDB, CoreDatabaseTableProxy] { +function prepareStubs(config: Partial = {}): [User[], SQLiteDB, CoreDatabaseTable] { const records: User[] = []; const database = mock({ getRecord: async (_, conditions) => { @@ -68,11 +69,84 @@ function prepareStubs(config: Partial = {}): [User[], return [records, database, table]; } +async function testFindItems(records: User[], table: CoreDatabaseTable) { + const john = { id: 1, name: 'John', surname: 'Doe' }; + const amy = { id: 2, name: 'Amy', surname: 'Doe' }; + + records.push(john); + records.push(amy); + + await table.initialize(); + + await expect(table.findByPrimaryKey({ id: 1 })).resolves.toEqual(john); + await expect(table.findByPrimaryKey({ id: 2 })).resolves.toEqual(amy); + await expect(table.find({ surname: 'Doe', name: 'John' })).resolves.toEqual(john); + await expect(table.find({ surname: 'Doe', name: 'Amy' })).resolves.toEqual(amy); +} + +async function testInsertItems(records: User[], database: SQLiteDB, table: CoreDatabaseTable) { + // Arrange. + const john = { id: 1, name: 'John', surname: 'Doe' }; + + await table.initialize(); + + // Act. + await table.insert(john); + + // Assert. + expect(database.insertRecord).toHaveBeenCalledWith('users', john); + + await expect(table.findByPrimaryKey({ id: 1 })).resolves.toEqual(john); +} + +async function testDeleteItems(records: User[], database: SQLiteDB, table: CoreDatabaseTable) { + // 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); + + await table.initialize(); + + // Act. + await table.delete({ surname: 'Doe' }); + + // Assert. + expect(database.deleteRecords).toHaveBeenCalledWith('users', { surname: 'Doe' }); + + await expect(table.findByPrimaryKey({ id: 1 })).rejects.toThrow(); + await expect(table.findByPrimaryKey({ id: 2 })).rejects.toThrow(); + await expect(table.findByPrimaryKey({ id: 3 })).resolves.toEqual(jane); +} + +async function testDeleteItemsByPrimaryKey(records: User[], database: SQLiteDB, table: CoreDatabaseTable) { + // Arrange. + const john = { id: 1, name: 'John', surname: 'Doe' }; + const amy = { id: 2, name: 'Amy', surname: 'Doe' }; + + records.push(john); + records.push(amy); + + await table.initialize(); + + // Act. + await table.deleteByPrimaryKey({ id: 1 }); + + // Assert. + expect(database.deleteRecords).toHaveBeenCalledWith('users', { id: 1 }); + + await expect(table.findByPrimaryKey({ id: 1 })).rejects.toThrow(); + await expect(table.findByPrimaryKey({ id: 2 })).resolves.toEqual(amy); +} + describe('CoreDatabaseTable with eager caching', () => { let records: User[]; let database: SQLiteDB; - let table: CoreDatabaseTableProxy; + let table: CoreDatabaseTable; beforeEach(() => [records, database, table] = prepareStubs({ cachingStrategy: CoreDatabaseCachingStrategy.Eager })); @@ -83,79 +157,41 @@ describe('CoreDatabaseTable with eager caching', () => { }); 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); - - await table.initialize(); - - await expect(table.findByPrimaryKey({ id: 1 })).resolves.toEqual(john); - await expect(table.findByPrimaryKey({ id: 2 })).resolves.toEqual(amy); - await expect(table.find({ surname: 'Doe', name: 'John' })).resolves.toEqual(john); - await expect(table.find({ surname: 'Doe', name: 'Amy' })).resolves.toEqual(amy); + await testFindItems(records, table); expect(database.getRecord).not.toHaveBeenCalled(); }); - it('inserts items', async () => { - // Arrange. - const john = { id: 1, name: 'John', surname: 'Doe' }; + it('inserts items', () => testInsertItems(records, database, table)); + it('deletes items', () => testDeleteItems(records, database, table)); + it('deletes items by primary key', () => testDeleteItemsByPrimaryKey(records, database, table)); +}); + +describe('CoreDatabaseTable with lazy caching', () => { + + let records: User[]; + let database: SQLiteDB; + let table: CoreDatabaseTable; + + beforeEach(() => [records, database, table] = prepareStubs({ cachingStrategy: CoreDatabaseCachingStrategy.Lazy })); + + it('reads no records on initialization', async () => { await table.initialize(); - // Act. - await table.insert(john); - - // Assert. - expect(database.insertRecord).toHaveBeenCalledWith('users', john); - - await expect(table.findByPrimaryKey({ id: 1 })).resolves.toEqual(john); + expect(database.getRecords).not.toHaveBeenCalled(); + expect(database.getAllRecords).not.toHaveBeenCalled(); }); - 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' }; + it('finds items', async () => { + await testFindItems(records, table); - records.push(john); - records.push(amy); - records.push(jane); - - await table.initialize(); - - // Act. - await table.delete({ surname: 'Doe' }); - - // Assert. - expect(database.deleteRecords).toHaveBeenCalledWith('users', { surname: 'Doe' }); - - await expect(table.findByPrimaryKey({ id: 1 })).rejects.toThrow(); - await expect(table.findByPrimaryKey({ id: 2 })).rejects.toThrow(); - await expect(table.findByPrimaryKey({ id: 3 })).resolves.toEqual(jane); + expect(database.getRecord).toHaveBeenCalledTimes(2); }); - 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); - - await table.initialize(); - - // Act. - await table.deleteByPrimaryKey({ id: 1 }); - - // Assert. - expect(database.deleteRecords).toHaveBeenCalledWith('users', { id: 1 }); - - await expect(table.findByPrimaryKey({ id: 1 })).rejects.toThrow(); - await expect(table.findByPrimaryKey({ id: 2 })).resolves.toEqual(amy); - }); + it('inserts items', () => testInsertItems(records, database, table)); + it('deletes items', () => testDeleteItems(records, database, table)); + it('deletes items by primary key', () => testDeleteItemsByPrimaryKey(records, database, table)); }); @@ -163,7 +199,7 @@ describe('CoreDatabaseTable with no caching', () => { let records: User[]; let database: SQLiteDB; - let table: CoreDatabaseTableProxy; + let table: CoreDatabaseTable; beforeEach(() => [records, database, table] = prepareStubs({ cachingStrategy: CoreDatabaseCachingStrategy.None })); @@ -175,78 +211,13 @@ describe('CoreDatabaseTable with no caching', () => { }); 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); - - await table.initialize(); - - await expect(table.findByPrimaryKey({ id: 1 })).resolves.toEqual(john); - await expect(table.findByPrimaryKey({ id: 2 })).resolves.toEqual(amy); - await expect(table.find({ surname: 'Doe', name: 'John' })).resolves.toEqual(john); - await expect(table.find({ surname: 'Doe', name: 'Amy' })).resolves.toEqual(amy); + await testFindItems(records, table); expect(database.getRecord).toHaveBeenCalledTimes(4); }); - it('inserts items', async () => { - // Arrange. - const john = { id: 1, name: 'John', surname: 'Doe' }; - - await table.initialize(); - - // Act. - await table.insert(john); - - // Assert. - expect(database.insertRecord).toHaveBeenCalledWith('users', john); - - await expect(table.findByPrimaryKey({ id: 1 })).resolves.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); - - await table.initialize(); - - // Act. - await table.delete({ surname: 'Doe' }); - - // Assert. - expect(database.deleteRecords).toHaveBeenCalledWith('users', { surname: 'Doe' }); - - await expect(table.findByPrimaryKey({ id: 1 })).rejects.toThrow(); - await expect(table.findByPrimaryKey({ id: 2 })).rejects.toThrow(); - await expect(table.findByPrimaryKey({ id: 3 })).resolves.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); - - await table.initialize(); - - // Act. - await table.deleteByPrimaryKey({ id: 1 }); - - // Assert. - expect(database.deleteRecords).toHaveBeenCalledWith('users', { id: 1 }); - - await expect(table.findByPrimaryKey({ id: 1 })).rejects.toThrow(); - await expect(table.findByPrimaryKey({ id: 2 })).resolves.toEqual(amy); - }); + it('inserts items', () => testInsertItems(records, database, table)); + it('deletes items', () => testDeleteItems(records, database, table)); + it('deletes items by primary key', () => testDeleteItemsByPrimaryKey(records, database, table)); }); diff --git a/src/core/services/filepool.ts b/src/core/services/filepool.ts index 99d5ea855..e3f5bab52 100644 --- a/src/core/services/filepool.ts +++ b/src/core/services/filepool.ts @@ -48,6 +48,9 @@ import { } from '@services/database/filepool'; import { CoreFileHelper } from './file-helper'; import { CoreUrl } from '@singletons/url'; +import { CorePromisedValue } from '@classes/promised-value'; +import { CoreDatabaseTable } from '@classes/database/database-table'; +import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy'; /* * Factory for handling downloading files and retrieve downloaded files. @@ -72,9 +75,13 @@ export class CoreFilepoolProvider { protected static readonly ERR_FS_OR_NETWORK_UNAVAILABLE = 'CoreFilepoolError:ERR_FS_OR_NETWORK_UNAVAILABLE'; protected static readonly ERR_QUEUE_ON_PAUSE = 'CoreFilepoolError:ERR_QUEUE_ON_PAUSE'; - protected static readonly FILE_UPDATE_UNKNOWN_WHERE_CLAUSE = + protected static readonly FILE_IS_UNKNOWN_SQL = 'isexternalfile = 1 OR ((revision IS NULL OR revision = 0) AND (timemodified IS NULL OR timemodified = 0))'; + protected static readonly FILE_IS_UNKNOWN_JS = + ({ isexternalfile, revision, timemodified }: CoreFilepoolFileEntry): boolean => + isexternalfile === 1 || ((revision === null || revision === 0) && (timemodified === null || timemodified === 0)); + protected logger: CoreLogger; protected queueState = CoreFilepoolProvider.QUEUE_PAUSED; protected urlAttributes: RegExp[] = [ @@ -94,6 +101,7 @@ export class CoreFilepoolProvider { // Variables for DB. protected appDB: Promise; protected resolveAppDB!: (appDB: SQLiteDB) => void; + protected filesTables: Record>> = {}; constructor() { this.appDB = new Promise(resolve => this.resolveAppDB = resolve); @@ -114,6 +122,18 @@ export class CoreFilepoolProvider { NgZone.run(() => this.checkQueueProcessing()); }); }); + + CoreEvents.on(CoreEvents.SITE_DELETED, async ({ siteId }) => { + if (!siteId || !(siteId in this.filesTables)) { + return; + } + + const filesTable = await this.filesTables[siteId]; + + delete this.filesTables[siteId]; + + await filesTable.destroy(); + }); } /** @@ -129,6 +149,33 @@ export class CoreFilepoolProvider { this.resolveAppDB(CoreApp.getDB()); } + /** + * Get files table. + * + * @param siteId Site id. + * @returns Files table. + */ + async getFilesTable(siteId?: string): Promise> { + siteId = siteId ?? CoreSites.getCurrentSiteId(); + + if (!(siteId in this.filesTables)) { + const filesTable = this.filesTables[siteId] = new CorePromisedValue(); + const database = await CoreSites.getSiteDb(siteId); + const table = new CoreDatabaseTableProxy( + { cachingStrategy: CoreDatabaseCachingStrategy.Lazy }, + database, + FILES_TABLE_NAME, + ['fileId'], + ); + + await table.initialize(); + + filesTable.resolve(table); + } + + return this.filesTables[siteId]; + } + /** * Link a file with a component. * @@ -215,9 +262,9 @@ export class CoreFilepoolProvider { ...data, }; - const db = await CoreSites.getSiteDb(siteId); + const filesTable = await this.getFilesTable(siteId); - await db.insertRecord(FILES_TABLE_NAME, record); + await filesTable.insert(record); } /** @@ -558,13 +605,14 @@ export class CoreFilepoolProvider { */ async clearFilepool(siteId: string): Promise { const db = await CoreSites.getSiteDb(siteId); + const filesTable = await this.getFilesTable(siteId); // Read the data first to be able to notify the deletions. - const filesEntries = await db.getAllRecords(FILES_TABLE_NAME); + const filesEntries = await filesTable.all(); const filesLinks = await db.getAllRecords(LINKS_TABLE_NAME); await Promise.all([ - db.deleteRecords(FILES_TABLE_NAME), + filesTable.delete(), db.deleteRecords(LINKS_TABLE_NAME), ]); @@ -1119,13 +1167,14 @@ export class CoreFilepoolProvider { } const db = await CoreSites.getSiteDb(siteId); + const filesTable = await this.getFilesTable(siteId); const extension = CoreMimetypeUtils.getFileExtension(entry.path); if (!extension) { // Files does not have extension. Invalidate file (stale = true). // Minor problem: file will remain in the filesystem once downloaded again. this.logger.debug('Staled file with no extension ' + entry.fileId); - await db.updateRecords(FILES_TABLE_NAME, { stale: 1 }, { fileId: entry.fileId }); + await filesTable.update({ stale: 1 }, { fileId: entry.fileId }); return; } @@ -1135,7 +1184,7 @@ export class CoreFilepoolProvider { entry.fileId = CoreMimetypeUtils.removeExtension(fileId); entry.extension = extension; - await db.updateRecords(FILES_TABLE_NAME, entry, { fileId }); + await filesTable.update(entry, { fileId }); if (entry.fileId == fileId) { // File ID hasn't changed, we're done. this.logger.debug('Removed extesion ' + extension + ' from file ' + entry.fileId); @@ -1396,15 +1445,13 @@ export class CoreFilepoolProvider { */ async getFilesByComponent(siteId: string, component: string, componentId?: string | number): Promise { const db = await CoreSites.getSiteDb(siteId); + const filesTable = await this.getFilesTable(siteId); const items = await this.getComponentFiles(db, component, componentId); const files: CoreFilepoolFileEntry[] = []; await Promise.all(items.map(async (item) => { try { - const fileEntry = await db.getRecord( - FILES_TABLE_NAME, - { fileId: item.fileId }, - ); + const fileEntry = await filesTable.findByPrimaryKey({ fileId: item.fileId }); if (!fileEntry) { return; @@ -2137,14 +2184,9 @@ export class CoreFilepoolProvider { * @return Resolved with file object from DB on success, rejected otherwise. */ protected async hasFileInPool(siteId: string, fileId: string): Promise { - const db = await CoreSites.getSiteDb(siteId); - const entry = await db.getRecord(FILES_TABLE_NAME, { fileId }); + const filesTable = await this.getFilesTable(siteId); - if (entry === undefined) { - throw new CoreError('File not found in filepool.'); - } - - return entry; + return filesTable.findByPrimaryKey({ fileId }); } /** @@ -2176,11 +2218,17 @@ export class CoreFilepoolProvider { * @return Resolved on success. */ async invalidateAllFiles(siteId: string, onlyUnknown: boolean = true): Promise { - const db = await CoreSites.getSiteDb(siteId); + const filesTable = await this.getFilesTable(siteId); - const where = onlyUnknown ? CoreFilepoolProvider.FILE_UPDATE_UNKNOWN_WHERE_CLAUSE : undefined; - - await db.updateRecordsWhere(FILES_TABLE_NAME, { stale: 1 }, where); + onlyUnknown + ? await filesTable.updateWhere( + { stale: 1 }, + { + sql: CoreFilepoolProvider.FILE_IS_UNKNOWN_SQL, + js: CoreFilepoolProvider.FILE_IS_UNKNOWN_JS, + }, + ) + : await filesTable.update({ stale: 1 }); } /** @@ -2199,9 +2247,9 @@ export class CoreFilepoolProvider { const file = await this.fixPluginfileURL(siteId, fileUrl); const fileId = this.getFileIdByUrl(CoreFileHelper.getFileUrl(file)); - const db = await CoreSites.getSiteDb(siteId); + const filesTable = await this.getFilesTable(siteId); - await db.updateRecords(FILES_TABLE_NAME, { stale: 1 }, { fileId }); + await filesTable.update({ stale: 1 }, { fileId }); } /** @@ -2221,7 +2269,7 @@ export class CoreFilepoolProvider { onlyUnknown: boolean = true, ): Promise { const db = await CoreSites.getSiteDb(siteId); - + const filesTable = await this.getFilesTable(siteId); const items = await this.getComponentFiles(db, component, componentId); if (!items.length) { @@ -2236,10 +2284,19 @@ export class CoreFilepoolProvider { whereAndParams.sql = 'fileId ' + whereAndParams.sql; if (onlyUnknown) { - whereAndParams.sql += ' AND (' + CoreFilepoolProvider.FILE_UPDATE_UNKNOWN_WHERE_CLAUSE + ')'; + whereAndParams.sql += ' AND (' + CoreFilepoolProvider.FILE_IS_UNKNOWN_SQL + ')'; } - await db.updateRecordsWhere(FILES_TABLE_NAME, { stale: 1 }, whereAndParams.sql, whereAndParams.params); + await filesTable.updateWhere( + { stale: 1 }, + { + sql: whereAndParams.sql, + sqlParams: whereAndParams.params, + js: record => fileIds.includes(record.fileId) && ( + !onlyUnknown || CoreFilepoolProvider.FILE_IS_UNKNOWN_JS(record) + ), + }, + ); } /** @@ -2657,6 +2714,8 @@ export class CoreFilepoolProvider { */ protected async removeFileById(siteId: string, fileId: string): Promise { const db = await CoreSites.getSiteDb(siteId); + const filesTable = await this.getFilesTable(siteId); + // Get the path to the file first since it relies on the file object stored in the pool. // Don't use getFilePath to prevent performing 2 DB requests. let path = this.getFilepoolFolderPath(siteId) + '/' + fileId; @@ -2682,7 +2741,7 @@ export class CoreFilepoolProvider { const promises: Promise[] = []; // Remove entry from filepool store. - promises.push(db.deleteRecords(FILES_TABLE_NAME, conditions)); + promises.push(filesTable.delete(conditions)); // Remove links. promises.push(db.deleteRecords(LINKS_TABLE_NAME, conditions)); From fdd5d5d8a56c08a72bc9c337535d6dadb97e02c9 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 3 Feb 2022 13:24:48 +0100 Subject: [PATCH 03/10] MOBILE-3833 performance: Sort tests in summary --- scripts/print-performance-measures.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/print-performance-measures.js b/scripts/print-performance-measures.js index 2b611be01..24019b117 100755 --- a/scripts/print-performance-measures.js +++ b/scripts/print-performance-measures.js @@ -75,5 +75,13 @@ for (const [name, { duration, scripting, styling, blocking, longTasks, database, }; } +// Sort tests +const tests = Object.keys(performanceMeasures).sort(); +const sortedPerformanceMeasures = {}; + +for (const test of tests) { + sortedPerformanceMeasures[test] = performanceMeasures[test]; +} + // Display data -console.table(performanceMeasures); +console.table(sortedPerformanceMeasures); From effe53807a95fd70f17b6dfcfdae94c9bd2f8761 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 3 Feb 2022 13:26:42 +0100 Subject: [PATCH 04/10] MOBILE-3977 db: Implement method to log history --- src/core/services/db.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/core/services/db.ts b/src/core/services/db.ts index 727b5424d..489d44072 100644 --- a/src/core/services/db.ts +++ b/src/core/services/db.ts @@ -38,6 +38,17 @@ export class CoreDbProvider { return CoreAppProvider.isAutomated(); } + /** + * Print query history in console. + */ + printHistory(): void { + const substituteParams = ({ sql, params }: CoreDbQueryLog) => + Object.values(params ?? []).reduce((sql: string, param: string) => sql.replace('?', param), sql); + + // eslint-disable-next-line no-console + console.log(this.queryLogs.map(substituteParams).join('\n')); + } + /** * Log a query. * From a65919debce177d989791e012aba8103b0b31f3d Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 3 Feb 2022 13:35:01 +0100 Subject: [PATCH 05/10] MOBILE-3977 config: Add database optimizations --- .../classes/database/database-table-proxy.ts | 50 +++++++++++++++++-- src/core/classes/promised-value.ts | 10 ++++ src/core/constants.ts | 17 ------- src/core/services/config.ts | 46 +++++++++++++++++ src/types/config.d.ts | 3 ++ 5 files changed, 105 insertions(+), 21 deletions(-) diff --git a/src/core/classes/database/database-table-proxy.ts b/src/core/classes/database/database-table-proxy.ts index 393e07ac4..ef7e50b35 100644 --- a/src/core/classes/database/database-table-proxy.ts +++ b/src/core/classes/database/database-table-proxy.ts @@ -12,8 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { CoreConstants } from '@/core/constants'; import { CorePromisedValue } from '@classes/promised-value'; import { SQLiteDB, SQLiteDBRecordValues } from '@classes/sqlitedb'; +import { CoreConfigProvider } from '@services/config'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreDatabaseReducer, CoreDatabaseTable, CoreDatabaseConditions, GetDBRecordPrimaryKey } from './database-table'; import { CoreEagerDatabaseTable } from './eager-database-table'; import { CoreLazyDatabaseTable } from './lazy-database-table'; @@ -31,6 +34,7 @@ export class CoreDatabaseTableProxy< protected config: CoreDatabaseConfiguration; protected target: CorePromisedValue> = new CorePromisedValue(); + protected environmentObserver?: CoreEventObserver; constructor( config: Partial, @@ -47,11 +51,16 @@ export class CoreDatabaseTableProxy< * @inheritdoc */ async initialize(): Promise { - const target = this.createTarget(); + this.environmentObserver = CoreEvents.on(CoreConfigProvider.ENVIRONMENT_UPDATED, () => this.updateTarget()); - await target.initialize(); + await this.updateTarget(); + } - this.target.resolve(target); + /** + * @inheritdoc + */ + async destroy(): Promise { + this.environmentObserver?.off(); } /** @@ -146,13 +155,46 @@ export class CoreDatabaseTableProxy< }; } + /** + * Get database configuration to use at runtime. + * + * @returns Database configuration. + */ + protected getRuntimeConfig(): CoreDatabaseConfiguration { + return { + ...this.config, + ...CoreConstants.CONFIG.databaseOptimizations, + ...CoreConstants.CONFIG.databaseTableOptimizations?.[this.tableName], + }; + } + + /** + * Update underlying target instance. + */ + protected async updateTarget(): Promise { + const oldTarget = this.target.value; + const newTarget = this.createTarget(); + + if (oldTarget) { + await oldTarget.destroy(); + + this.target.reset(); + } + + await newTarget.initialize(); + + this.target.resolve(newTarget); + } + /** * Create proxy target. * * @returns Target instance. */ protected createTarget(): CoreDatabaseTable { - return this.createTable(this.config.cachingStrategy); + const config = this.getRuntimeConfig(); + + return this.createTable(config.cachingStrategy); } /** diff --git a/src/core/classes/promised-value.ts b/src/core/classes/promised-value.ts index ae7bbbdec..16051be37 100644 --- a/src/core/classes/promised-value.ts +++ b/src/core/classes/promised-value.ts @@ -134,6 +134,16 @@ export class CorePromisedValue implements Promise { this._reject(reason); } + /** + * Reset status and value. + */ + reset(): void { + delete this._resolvedValue; + delete this._rejectedReason; + + this.initPromise(); + } + /** * Initialize the promise and the callbacks. */ diff --git a/src/core/constants.ts b/src/core/constants.ts index 2c238261b..b83e896a8 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -145,23 +145,6 @@ export class CoreConstants { static readonly CONFIG = { ...envJson.config } as unknown as EnvironmentConfig; // Data parsed from config.json files. static readonly BUILD = envJson.build as unknown as EnvironmentBuild; // Build info. - /** - * Update config with the given values. - * - * @param config Config updates. - */ - static patchConfig(config: Partial): void { - Object.assign(this.CONFIG, config); - } - - /** - * Reset config values to its original state. - */ - static resetConfig(): void { - Object.keys(this.CONFIG).forEach(key => delete this.CONFIG[key]); - Object.assign(this.CONFIG, envJson.config); - } - } interface EnvironmentBuild { diff --git a/src/core/services/config.ts b/src/core/services/config.ts index 7b52fcd3e..d804535f1 100644 --- a/src/core/services/config.ts +++ b/src/core/services/config.ts @@ -12,14 +12,30 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { EnvironmentConfig } from '@/types/config'; import { Injectable } from '@angular/core'; import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy'; import { CoreApp } from '@services/app'; import { APP_SCHEMA, ConfigDBEntry, CONFIG_TABLE_NAME } from '@services/database/config'; import { makeSingleton } from '@singletons'; +import { CoreConstants } from '../constants'; +import { CoreEvents } from '@singletons/events'; import { CoreDatabaseTable } from '@classes/database/database-table'; import { CorePromisedValue } from '@classes/promised-value'; +declare module '@singletons/events' { + + /** + * Augment CoreEventsData interface with events specific to this service. + * + * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation + */ + export interface CoreEventsData { + [CoreConfigProvider.ENVIRONMENT_UPDATED]: EnvironmentConfig; + } + +} + /** * Factory to provide access to dynamic and permanent config and settings. * It should not be abused into a temporary storage. @@ -27,7 +43,10 @@ import { CorePromisedValue } from '@classes/promised-value'; @Injectable({ providedIn: 'root' }) export class CoreConfigProvider { + static readonly ENVIRONMENT_UPDATED = 'environment_updated'; + protected table: CorePromisedValue> = new CorePromisedValue(); + protected defaultEnvironment?: EnvironmentConfig; /** * Initialize database. @@ -98,6 +117,33 @@ export class CoreConfigProvider { await table.insert({ name, value }); } + /** + * Update config with the given values. + * + * @param config Config updates. + */ + patchEnvironment(config: Partial): void { + this.defaultEnvironment = this.defaultEnvironment ?? CoreConstants.CONFIG; + + Object.assign(CoreConstants.CONFIG, config); + CoreEvents.trigger(CoreConfigProvider.ENVIRONMENT_UPDATED, CoreConstants.CONFIG); + } + + /** + * Reset config values to its original state. + */ + resetEnvironment(): void { + if (!this.defaultEnvironment) { + // The environment config hasn't been modified; there's not need to reset. + + return; + } + + Object.keys(CoreConstants.CONFIG).forEach(key => delete CoreConstants.CONFIG[key]); + Object.assign(CoreConstants.CONFIG, this.defaultEnvironment); + CoreEvents.trigger(CoreConfigProvider.ENVIRONMENT_UPDATED, CoreConstants.CONFIG); + } + } export const CoreConfig = makeSingleton(CoreConfigProvider); diff --git a/src/types/config.d.ts b/src/types/config.d.ts index c5627bc99..c8fa468bc 100644 --- a/src/types/config.d.ts +++ b/src/types/config.d.ts @@ -17,6 +17,7 @@ import { CoreMainMenuLocalizedCustomItem } from '@features/mainmenu/services/mai import { CoreSitesDemoSiteData } from '@services/sites'; import { OpenFileAction } from '@services/utils/utils'; import { CoreLoginSiteSelectorListMethod } from '@features/login/services/login-helper'; +import { CoreDatabaseConfiguration } from '@classes/database/database-table-proxy'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -31,6 +32,8 @@ export interface EnvironmentConfig { cache_update_frequency_rarely: number; default_lang: string; languages: Record; + databaseOptimizations?: Partial; + databaseTableOptimizations?: Record>; wsservice: string; demo_sites: Record; zoomlevels: Record; From 7a1dfa38bd7ac6e2a0475c804f903e87ef554db0 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 3 Feb 2022 13:43:10 +0100 Subject: [PATCH 06/10] MOBILE-3977 core: Optimize wscache table --- src/core/classes/site.ts | 88 ++++++++++---------- src/core/initializers/initialize-services.ts | 2 + src/core/services/sites.ts | 49 +++++++++++ 3 files changed, 95 insertions(+), 44 deletions(-) diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index dc702baf9..3182f60ff 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -41,6 +41,7 @@ import { CoreLogger } from '@singletons/logger'; import { Translate } from '@singletons'; import { CoreIonLoadingElement } from './ion-loading'; import { CoreLang } from '@services/lang'; +import { CoreSites } from '@services/sites'; /** * QR Code type enumeration. @@ -920,20 +921,20 @@ export class CoreSite { preSets: CoreSiteWSPreSets, emergency?: boolean, ): Promise { - const db = this.db; - if (!db || !preSets.getFromCache) { + if (!this.db || !preSets.getFromCache) { throw new CoreError('Get from cache is disabled.'); } const id = this.getCacheId(method, data); + const cacheTable = await CoreSites.getCacheTable(this); let entry: CoreSiteWSCacheRecord | undefined; if (preSets.getCacheUsingCacheKey || (emergency && preSets.getEmergencyCacheUsingCacheKey)) { - const entries = await db.getRecords(CoreSite.WS_CACHE_TABLE, { key: preSets.cacheKey }); + const entries = await cacheTable.all({ key: preSets.cacheKey }); if (!entries.length) { // Cache key not found, get by params sent. - entry = await db.getRecord(CoreSite.WS_CACHE_TABLE, { id }); + entry = await cacheTable.findByPrimaryKey({ id }); } else { if (entries.length > 1) { // More than one entry found. Search the one with same ID as this call. @@ -945,7 +946,7 @@ export class CoreSite { } } } else { - entry = await db.getRecord(CoreSite.WS_CACHE_TABLE, { id }); + entry = await cacheTable.findByPrimaryKey({ id }); } if (entry === undefined) { @@ -990,18 +991,25 @@ export class CoreSite { */ async getComponentCacheSize(component: string, componentId?: number): Promise { const params: Array = [component]; + const cacheTable = await CoreSites.getCacheTable(this); let extraClause = ''; if (componentId !== undefined && componentId !== null) { params.push(componentId); extraClause = ' AND componentId = ?'; } - const size = await this.getDb().getFieldSql( - 'SELECT SUM(length(data)) FROM ' + CoreSite.WS_CACHE_TABLE + ' WHERE component = ?' + extraClause, - params, + return cacheTable.reduce( + { + sql: 'SUM(length(data))', + js: (size, record) => size + record.data.length, + jsInitialValue: 0, + }, + { + sql: 'WHERE component = ?' + extraClause, + sqlParams: params, + js: record => record.component === component && (params.length === 1 || record.componentId === componentId), + }, ); - - return size; } /** @@ -1015,10 +1023,6 @@ export class CoreSite { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any protected async saveToCache(method: string, data: any, response: any, preSets: CoreSiteWSPreSets): Promise { - if (!this.db) { - throw new CoreError('Site DB not initialized.'); - } - if (preSets.uniqueCacheKey) { // Cache key must be unique, delete all entries with same cache key. await CoreUtils.ignoreErrors(this.deleteFromCache(method, data, preSets, true)); @@ -1027,6 +1031,7 @@ export class CoreSite { // Since 3.7, the expiration time contains the time the entry is modified instead of the expiration time. // We decided to reuse this field to prevent modifying the database table. const id = this.getCacheId(method, data); + const cacheTable = await CoreSites.getCacheTable(this); const entry = { id, data: JSON.stringify(response), @@ -1044,7 +1049,7 @@ export class CoreSite { } } - await this.db.insertRecord(CoreSite.WS_CACHE_TABLE, entry); + await cacheTable.insert(entry); } /** @@ -1058,16 +1063,13 @@ export class CoreSite { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any protected async deleteFromCache(method: string, data: any, preSets: CoreSiteWSPreSets, allCacheKey?: boolean): Promise { - if (!this.db) { - throw new CoreError('Site DB not initialized.'); - } - const id = this.getCacheId(method, data); + const cacheTable = await CoreSites.getCacheTable(this); if (allCacheKey) { - await this.db.deleteRecords(CoreSite.WS_CACHE_TABLE, { key: preSets.cacheKey }); + await cacheTable.delete({ key: preSets.cacheKey }); } else { - await this.db.deleteRecords(CoreSite.WS_CACHE_TABLE, { id }); + await cacheTable.deleteByPrimaryKey({ id }); } } @@ -1084,18 +1086,14 @@ export class CoreSite { return; } - if (!this.db) { - throw new CoreError('Site DB not initialized'); - } + const params = { component }; + const cacheTable = await CoreSites.getCacheTable(this); - const params = { - component, - }; if (componentId) { params['componentId'] = componentId; } - await this.db.deleteRecords(CoreSite.WS_CACHE_TABLE, params); + await cacheTable.delete(params); } /* @@ -1127,14 +1125,12 @@ export class CoreSite { * @return Promise resolved when the cache entries are invalidated. */ async invalidateWsCache(): Promise { - if (!this.db) { - throw new CoreError('Site DB not initialized'); - } - this.logger.debug('Invalidate all the cache for site: ' + this.id); try { - await this.db.updateRecords(CoreSite.WS_CACHE_TABLE, { expirationTime: 0 }); + const cacheTable = await CoreSites.getCacheTable(this); + + await cacheTable.update({ expirationTime: 0 }); } finally { CoreEvents.trigger(CoreEvents.WS_CACHE_INVALIDATED, {}, this.getId()); } @@ -1147,16 +1143,15 @@ export class CoreSite { * @return Promise resolved when the cache entries are invalidated. */ async invalidateWsCacheForKey(key: string): Promise { - if (!this.db) { - throw new CoreError('Site DB not initialized'); - } if (!key) { return; } this.logger.debug('Invalidate cache for key: ' + key); - await this.db.updateRecords(CoreSite.WS_CACHE_TABLE, { expirationTime: 0 }, { key }); + const cacheTable = await CoreSites.getCacheTable(this); + + await cacheTable.update({ expirationTime: 0 }, { key }); } /** @@ -1184,18 +1179,19 @@ export class CoreSite { * @return Promise resolved when the cache entries are invalidated. */ async invalidateWsCacheForKeyStartingWith(key: string): Promise { - if (!this.db) { - throw new CoreError('Site DB not initialized'); - } if (!key) { return; } this.logger.debug('Invalidate cache for key starting with: ' + key); - const sql = 'UPDATE ' + CoreSite.WS_CACHE_TABLE + ' SET expirationTime=0 WHERE key LIKE ?'; + const cacheTable = await CoreSites.getCacheTable(this); - await this.db.execute(sql, [key + '%']); + await cacheTable.updateWhere({ expirationTime: 0 }, { + sql: 'key LIKE ?', + sqlParams: [key], + js: record => !!record.key?.startsWith(key), + }); } /** @@ -1270,9 +1266,13 @@ export class CoreSite { * @return Promise resolved with the total size of all data in the cache table (bytes) */ async getCacheUsage(): Promise { - const size = await this.getDb().getFieldSql('SELECT SUM(length(data)) FROM ' + CoreSite.WS_CACHE_TABLE); + const cacheTable = await CoreSites.getCacheTable(this); - return size; + return cacheTable.reduce({ + sql: 'SUM(length(data))', + js: (size, record) => size + record.data.length, + jsInitialValue: 0, + }); } /** diff --git a/src/core/initializers/initialize-services.ts b/src/core/initializers/initialize-services.ts index bef39b3a1..6b172b93c 100644 --- a/src/core/initializers/initialize-services.ts +++ b/src/core/initializers/initialize-services.ts @@ -15,11 +15,13 @@ import { CoreFilepool } from '@services/filepool'; import { CoreLang } from '@services/lang'; import { CoreLocalNotifications } from '@services/local-notifications'; +import { CoreSites } from '@services/sites'; import { CoreUpdateManager } from '@services/update-manager'; export default async function(): Promise { await Promise.all([ CoreFilepool.initialize(), + CoreSites.initialize(), CoreLang.initialize(), CoreLocalNotifications.initialize(), CoreUpdateManager.initialize(), diff --git a/src/core/services/sites.ts b/src/core/services/sites.ts index c20644f22..9de9c1998 100644 --- a/src/core/services/sites.ts +++ b/src/core/services/sites.ts @@ -31,6 +31,7 @@ import { CoreSiteConfig, CoreSitePublicConfigResponse, CoreSiteInfoResponse, + CoreSiteWSCacheRecord, } from '@classes/site'; import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb'; import { CoreError } from '@classes/errors/error'; @@ -57,6 +58,9 @@ import { CoreErrorWithTitle } from '@classes/errors/errorwithtitle'; import { CoreAjaxError } from '@classes/errors/ajaxerror'; import { CoreAjaxWSError } from '@classes/errors/ajaxwserror'; import { CoreSitePlugins } from '@features/siteplugins/services/siteplugins'; +import { CorePromisedValue } from '@classes/promised-value'; +import { CoreDatabaseTable } from '@classes/database/database-table'; +import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy'; export const CORE_SITE_SCHEMAS = new InjectionToken('CORE_SITE_SCHEMAS'); @@ -85,6 +89,7 @@ export class CoreSitesProvider { // Variables for DB. protected appDB: Promise; protected resolveAppDB!: (appDB: SQLiteDB) => void; + protected cacheTables: Record>> = {}; constructor(@Optional() @Inject(CORE_SITE_SCHEMAS) siteSchemas: CoreSiteSchema[][] = []) { this.appDB = new Promise(resolve => this.resolveAppDB = resolve); @@ -99,6 +104,23 @@ export class CoreSitesProvider { ); } + /** + * Initialize. + */ + initialize(): void { + CoreEvents.on(CoreEvents.SITE_DELETED, async ({ siteId }) => { + if (!siteId || !(siteId in this.cacheTables)) { + return; + } + + const cacheTable = await this.cacheTables[siteId]; + + delete this.cacheTables[siteId]; + + await cacheTable.destroy(); + }); + } + /** * Initialize database. */ @@ -112,6 +134,33 @@ export class CoreSitesProvider { this.resolveAppDB(CoreApp.getDB()); } + /** + * Get cache table. + * + * @param siteId Site id. + * @returns cache table. + */ + async getCacheTable(site: CoreSite): Promise> { + if (!site.id) { + throw new CoreError('Can\'t get cache table for site without id'); + } + + if (!(site.id in this.cacheTables)) { + const promisedTable = this.cacheTables[site.id] = new CorePromisedValue(); + const table = new CoreDatabaseTableProxy( + { cachingStrategy: CoreDatabaseCachingStrategy.None }, + site.getDb(), + CoreSite.WS_CACHE_TABLE, + ); + + await table.initialize(); + + promisedTable.resolve(table); + } + + return this.cacheTables[site.id]; + } + /** * Get the demo data for a certain "name" if it is a demo site. * From a04147120517a5018ec9e7acd7a7632998dd464a Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 3 Feb 2022 13:45:57 +0100 Subject: [PATCH 07/10] MOBILE-3977 core: Implement debug database table --- .../classes/database/database-table-proxy.ts | 6 +- .../classes/database/debug-database-table.ts | 139 ++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 src/core/classes/database/debug-database-table.ts diff --git a/src/core/classes/database/database-table-proxy.ts b/src/core/classes/database/database-table-proxy.ts index ef7e50b35..330640e9c 100644 --- a/src/core/classes/database/database-table-proxy.ts +++ b/src/core/classes/database/database-table-proxy.ts @@ -18,6 +18,7 @@ import { SQLiteDB, SQLiteDBRecordValues } from '@classes/sqlitedb'; import { CoreConfigProvider } from '@services/config'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreDatabaseReducer, CoreDatabaseTable, CoreDatabaseConditions, GetDBRecordPrimaryKey } from './database-table'; +import { CoreDebugDatabaseTable } from './debug-database-table'; import { CoreEagerDatabaseTable } from './eager-database-table'; import { CoreLazyDatabaseTable } from './lazy-database-table'; @@ -152,6 +153,7 @@ export class CoreDatabaseTableProxy< protected getConfigDefaults(): CoreDatabaseConfiguration { return { cachingStrategy: CoreDatabaseCachingStrategy.None, + debug: false, }; } @@ -193,8 +195,9 @@ export class CoreDatabaseTableProxy< */ protected createTarget(): CoreDatabaseTable { const config = this.getRuntimeConfig(); + const table = this.createTable(config.cachingStrategy); - return this.createTable(config.cachingStrategy); + return config.debug ? new CoreDebugDatabaseTable(table) : table; } /** @@ -221,6 +224,7 @@ export class CoreDatabaseTableProxy< */ export interface CoreDatabaseConfiguration { cachingStrategy: CoreDatabaseCachingStrategy; + debug: boolean; } /** diff --git a/src/core/classes/database/debug-database-table.ts b/src/core/classes/database/debug-database-table.ts new file mode 100644 index 000000000..09d80dad0 --- /dev/null +++ b/src/core/classes/database/debug-database-table.ts @@ -0,0 +1,139 @@ +// (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 { SQLiteDBRecordValues } from '@classes/sqlitedb'; +import { CoreLogger } from '@singletons/logger'; +import { CoreDatabaseTable, CoreDatabaseConditions, GetDBRecordPrimaryKey, CoreDatabaseReducer } from './database-table'; + +/** + * Database table proxy used to debug runtime operations. + * + * This proxy should only be used for development purposes. + */ +export class CoreDebugDatabaseTable< + DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues, + PrimaryKeyColumn extends keyof DBRecord = 'id', + PrimaryKey extends GetDBRecordPrimaryKey = GetDBRecordPrimaryKey +> extends CoreDatabaseTable { + + protected target: CoreDatabaseTable; + protected logger: CoreLogger; + + constructor(target: CoreDatabaseTable) { + super(target.getDatabase(), target.getTableName(), target.getPrimaryKeyColumns()); + + this.target = target; + this.logger = CoreLogger.getInstance(`CoreDatabase[${this.tableName}]`); + } + + /** + * @inheritdoc + */ + initialize(): Promise { + this.logger.log('initialize'); + + return this.target.initialize(); + } + + /** + * @inheritdoc + */ + destroy(): Promise { + this.logger.log('destroy'); + + return this.target.destroy(); + } + + /** + * @inheritdoc + */ + all(conditions?: Partial): Promise { + this.logger.log('all', conditions); + + return this.target.all(conditions); + } + + /** + * @inheritdoc + */ + find(conditions: Partial): Promise { + this.logger.log('find', conditions); + + return this.target.find(conditions); + } + + /** + * @inheritdoc + */ + findByPrimaryKey(primaryKey: PrimaryKey): Promise { + this.logger.log('findByPrimaryKey', primaryKey); + + return this.target.findByPrimaryKey(primaryKey); + } + + /** + * @inheritdoc + */ + reduce(reducer: CoreDatabaseReducer, conditions?: CoreDatabaseConditions): Promise { + this.logger.log('reduce', reducer, conditions); + + return this.target.reduce(reducer, conditions); + } + + /** + * @inheritdoc + */ + insert(record: DBRecord): Promise { + this.logger.log('insert', record); + + return this.target.insert(record); + } + + /** + * @inheritdoc + */ + update(updates: Partial, conditions?: Partial): Promise { + this.logger.log('update', updates, conditions); + + return this.target.update(updates, conditions); + } + + /** + * @inheritdoc + */ + updateWhere(updates: Partial, conditions: CoreDatabaseConditions): Promise { + this.logger.log('updateWhere', updates, conditions); + + return this.target.updateWhere(updates, conditions); + } + + /** + * @inheritdoc + */ + delete(conditions?: Partial): Promise { + this.logger.log('delete', conditions); + + return this.target.delete(conditions); + } + + /** + * @inheritdoc + */ + deleteByPrimaryKey(primaryKey: PrimaryKey): Promise { + this.logger.log('deleteByPrimaryKey', primaryKey); + + return this.target.deleteByPrimaryKey(primaryKey); + } + +} From 7a2a8c3e9867f65d81151f39c01b77ce9281dcd3 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 3 Feb 2022 13:56:36 +0100 Subject: [PATCH 08/10] MOBILE-3977 core: Implement async instance pattern --- .../classes/database/database-table-proxy.ts | 46 ++----- src/core/classes/site.ts | 41 +++--- src/core/services/config.ts | 17 +-- src/core/utils/async-instance.ts | 122 ++++++++++++++++++ 4 files changed, 158 insertions(+), 68 deletions(-) create mode 100644 src/core/utils/async-instance.ts diff --git a/src/core/classes/database/database-table-proxy.ts b/src/core/classes/database/database-table-proxy.ts index 330640e9c..0a9006282 100644 --- a/src/core/classes/database/database-table-proxy.ts +++ b/src/core/classes/database/database-table-proxy.ts @@ -13,7 +13,7 @@ // limitations under the License. import { CoreConstants } from '@/core/constants'; -import { CorePromisedValue } from '@classes/promised-value'; +import { asyncInstance } from '@/core/utils/async-instance'; import { SQLiteDB, SQLiteDBRecordValues } from '@classes/sqlitedb'; import { CoreConfigProvider } from '@services/config'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; @@ -34,7 +34,7 @@ export class CoreDatabaseTableProxy< > extends CoreDatabaseTable { protected config: CoreDatabaseConfiguration; - protected target: CorePromisedValue> = new CorePromisedValue(); + protected target = asyncInstance>(); protected environmentObserver?: CoreEventObserver; constructor( @@ -68,81 +68,63 @@ export class CoreDatabaseTableProxy< * @inheritdoc */ async all(conditions?: Partial): Promise { - const target = await this.target; - - return target.all(conditions); + return this.target.all(conditions); } /** * @inheritdoc */ async find(conditions: Partial): Promise { - const target = await this.target; - - return target.find(conditions); + return this.target.find(conditions); } /** * @inheritdoc */ async findByPrimaryKey(primaryKey: PrimaryKey): Promise { - const target = await this.target; - - return target.findByPrimaryKey(primaryKey); + return this.target.findByPrimaryKey(primaryKey); } /** * @inheritdoc */ async reduce(reducer: CoreDatabaseReducer, conditions?: CoreDatabaseConditions): Promise { - const target = await this.target; - - return target.reduce(reducer, conditions); + return this.target.reduce(reducer, conditions); } /** * @inheritdoc */ async insert(record: DBRecord): Promise { - const target = await this.target; - - return target.insert(record); + return this.target.insert(record); } /** * @inheritdoc */ async update(updates: Partial, conditions?: Partial): Promise { - const target = await this.target; - - return target.update(updates, conditions); + return this.target.update(updates, conditions); } /** * @inheritdoc */ async updateWhere(updates: Partial, conditions: CoreDatabaseConditions): Promise { - const target = await this.target; - - return target.updateWhere(updates, conditions); + return this.target.updateWhere(updates, conditions); } /** * @inheritdoc */ async delete(conditions?: Partial): Promise { - const target = await this.target; - - return target.delete(conditions); + return this.target.delete(conditions); } /** * @inheritdoc */ async deleteByPrimaryKey(primaryKey: PrimaryKey): Promise { - const target = await this.target; - - return target.deleteByPrimaryKey(primaryKey); + return this.target.deleteByPrimaryKey(primaryKey); } /** @@ -174,18 +156,18 @@ export class CoreDatabaseTableProxy< * Update underlying target instance. */ protected async updateTarget(): Promise { - const oldTarget = this.target.value; + const oldTarget = this.target.instance; const newTarget = this.createTarget(); if (oldTarget) { await oldTarget.destroy(); - this.target.reset(); + this.target.resetInstance(); } await newTarget.initialize(); - this.target.resolve(newTarget); + this.target.setInstance(newTarget); } /** diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index 3182f60ff..5ce95a266 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -42,6 +42,8 @@ import { Translate } from '@singletons'; import { CoreIonLoadingElement } from './ion-loading'; import { CoreLang } from '@services/lang'; import { CoreSites } from '@services/sites'; +import { asyncInstance, AsyncInstance } from '../utils/async-instance'; +import { CoreDatabaseTable } from './database/database-table'; /** * QR Code type enumeration. @@ -104,6 +106,7 @@ export class CoreSite { // Rest of variables. protected logger: CoreLogger; protected db?: SQLiteDB; + protected cacheTable: AsyncInstance>; protected cleanUnicode = false; protected lastAutoLogin = 0; protected offlineDisabled = false; @@ -137,6 +140,7 @@ export class CoreSite { ) { this.logger = CoreLogger.getInstance('CoreSite'); this.siteUrl = CoreUrlUtils.removeUrlParams(this.siteUrl); // Make sure the URL doesn't have params. + this.cacheTable = asyncInstance(() => CoreSites.getCacheTable(this)); this.setInfo(infos); this.calculateOfflineDisabled(); @@ -926,15 +930,14 @@ export class CoreSite { } const id = this.getCacheId(method, data); - const cacheTable = await CoreSites.getCacheTable(this); let entry: CoreSiteWSCacheRecord | undefined; if (preSets.getCacheUsingCacheKey || (emergency && preSets.getEmergencyCacheUsingCacheKey)) { - const entries = await cacheTable.all({ key: preSets.cacheKey }); + const entries = await this.cacheTable.all({ key: preSets.cacheKey }); if (!entries.length) { // Cache key not found, get by params sent. - entry = await cacheTable.findByPrimaryKey({ id }); + entry = await this.cacheTable.findByPrimaryKey({ id }); } else { if (entries.length > 1) { // More than one entry found. Search the one with same ID as this call. @@ -946,7 +949,7 @@ export class CoreSite { } } } else { - entry = await cacheTable.findByPrimaryKey({ id }); + entry = await this.cacheTable.findByPrimaryKey({ id }); } if (entry === undefined) { @@ -991,14 +994,13 @@ export class CoreSite { */ async getComponentCacheSize(component: string, componentId?: number): Promise { const params: Array = [component]; - const cacheTable = await CoreSites.getCacheTable(this); let extraClause = ''; if (componentId !== undefined && componentId !== null) { params.push(componentId); extraClause = ' AND componentId = ?'; } - return cacheTable.reduce( + return this.cacheTable.reduce( { sql: 'SUM(length(data))', js: (size, record) => size + record.data.length, @@ -1031,7 +1033,6 @@ export class CoreSite { // Since 3.7, the expiration time contains the time the entry is modified instead of the expiration time. // We decided to reuse this field to prevent modifying the database table. const id = this.getCacheId(method, data); - const cacheTable = await CoreSites.getCacheTable(this); const entry = { id, data: JSON.stringify(response), @@ -1049,7 +1050,7 @@ export class CoreSite { } } - await cacheTable.insert(entry); + await this.cacheTable.insert(entry); } /** @@ -1064,12 +1065,11 @@ export class CoreSite { // eslint-disable-next-line @typescript-eslint/no-explicit-any protected async deleteFromCache(method: string, data: any, preSets: CoreSiteWSPreSets, allCacheKey?: boolean): Promise { const id = this.getCacheId(method, data); - const cacheTable = await CoreSites.getCacheTable(this); if (allCacheKey) { - await cacheTable.delete({ key: preSets.cacheKey }); + await this.cacheTable.delete({ key: preSets.cacheKey }); } else { - await cacheTable.deleteByPrimaryKey({ id }); + await this.cacheTable.deleteByPrimaryKey({ id }); } } @@ -1087,13 +1087,12 @@ export class CoreSite { } const params = { component }; - const cacheTable = await CoreSites.getCacheTable(this); if (componentId) { params['componentId'] = componentId; } - await cacheTable.delete(params); + await this.cacheTable.delete(params); } /* @@ -1128,9 +1127,7 @@ export class CoreSite { this.logger.debug('Invalidate all the cache for site: ' + this.id); try { - const cacheTable = await CoreSites.getCacheTable(this); - - await cacheTable.update({ expirationTime: 0 }); + await this.cacheTable.update({ expirationTime: 0 }); } finally { CoreEvents.trigger(CoreEvents.WS_CACHE_INVALIDATED, {}, this.getId()); } @@ -1149,9 +1146,7 @@ export class CoreSite { this.logger.debug('Invalidate cache for key: ' + key); - const cacheTable = await CoreSites.getCacheTable(this); - - await cacheTable.update({ expirationTime: 0 }, { key }); + await this.cacheTable.update({ expirationTime: 0 }, { key }); } /** @@ -1185,9 +1180,7 @@ export class CoreSite { this.logger.debug('Invalidate cache for key starting with: ' + key); - const cacheTable = await CoreSites.getCacheTable(this); - - await cacheTable.updateWhere({ expirationTime: 0 }, { + await this.cacheTable.updateWhere({ expirationTime: 0 }, { sql: 'key LIKE ?', sqlParams: [key], js: record => !!record.key?.startsWith(key), @@ -1266,9 +1259,7 @@ export class CoreSite { * @return Promise resolved with the total size of all data in the cache table (bytes) */ async getCacheUsage(): Promise { - const cacheTable = await CoreSites.getCacheTable(this); - - return cacheTable.reduce({ + return this.cacheTable.reduce({ sql: 'SUM(length(data))', js: (size, record) => size + record.data.length, jsInitialValue: 0, diff --git a/src/core/services/config.ts b/src/core/services/config.ts index d804535f1..0ed248301 100644 --- a/src/core/services/config.ts +++ b/src/core/services/config.ts @@ -21,7 +21,7 @@ import { makeSingleton } from '@singletons'; import { CoreConstants } from '../constants'; import { CoreEvents } from '@singletons/events'; import { CoreDatabaseTable } from '@classes/database/database-table'; -import { CorePromisedValue } from '@classes/promised-value'; +import { asyncInstance } from '../utils/async-instance'; declare module '@singletons/events' { @@ -45,7 +45,7 @@ export class CoreConfigProvider { static readonly ENVIRONMENT_UPDATED = 'environment_updated'; - protected table: CorePromisedValue> = new CorePromisedValue(); + protected table = asyncInstance>(); protected defaultEnvironment?: EnvironmentConfig; /** @@ -67,7 +67,7 @@ export class CoreConfigProvider { await table.initialize(); - this.table.resolve(table); + this.table.setInstance(table); } /** @@ -77,9 +77,7 @@ export class CoreConfigProvider { * @return Promise resolved when done. */ async delete(name: string): Promise { - const table = await this.table; - - await table.deleteByPrimaryKey({ name }); + await this.table.deleteByPrimaryKey({ name }); } /** @@ -91,8 +89,7 @@ export class CoreConfigProvider { */ async get(name: string, defaultValue?: T): Promise { try { - const table = await this.table; - const record = await table.findByPrimaryKey({ name }); + const record = await this.table.findByPrimaryKey({ name }); return record.value; } catch (error) { @@ -112,9 +109,7 @@ export class CoreConfigProvider { * @return Promise resolved when done. */ async set(name: string, value: number | string): Promise { - const table = await this.table; - - await table.insert({ name, value }); + await this.table.insert({ name, value }); } /** diff --git a/src/core/utils/async-instance.ts b/src/core/utils/async-instance.ts new file mode 100644 index 000000000..f687e8a70 --- /dev/null +++ b/src/core/utils/async-instance.ts @@ -0,0 +1,122 @@ +// (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 { CorePromisedValue } from '@classes/promised-value'; + +/** + * Create a wrapper to hold an asynchronous instance. + * + * @param lazyConstructor Constructor to use the first time the instance is needed. + * @returns Asynchronous instance wrapper. + */ +function createAsyncInstanceWrapper(lazyConstructor?: () => T | Promise): AsyncInstanceWrapper { + let promisedInstance: CorePromisedValue | null = null; + + return { + get instance() { + return promisedInstance?.value ?? undefined; + }, + async getInstance() { + if (!promisedInstance) { + promisedInstance = new CorePromisedValue(); + + if (lazyConstructor) { + const instance = await lazyConstructor(); + + promisedInstance.resolve(instance); + } + } + + return promisedInstance; + }, + async getProperty(property) { + const instance = await this.getInstance(); + + return instance[property]; + }, + setInstance(instance) { + if (!promisedInstance) { + promisedInstance = new CorePromisedValue(); + } else if (promisedInstance.isSettled()) { + promisedInstance.reset(); + } + + promisedInstance.resolve(instance); + }, + resetInstance() { + if (!promisedInstance) { + return; + } + + promisedInstance.reset(); + }, + }; +} + +/** + * Asynchronous instance wrapper. + */ +export interface AsyncInstanceWrapper { + instance?: T; + getInstance(): Promise; + getProperty

(property: P): Promise; + setInstance(instance: T): void; + resetInstance(): void; +} + +/** + * Asynchronous version of a method. + */ +export type AsyncMethod = + T extends (...args: infer Params) => infer Return + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ? T extends (...args: Params) => Promise + ? T + : (...args: Params) => Promise + : never; + +/** + * Asynchronous instance. + * + * All methods are converted to their asynchronous version, and properties are available asynchronously using + * the getProperty method. + */ +export type AsyncInstance = AsyncInstanceWrapper & { + [k in keyof T]: AsyncMethod; +}; + +/** + * Create an asynchronous instance proxy, where all methods will be callable directly but will become asynchronous. If the + * underlying instance hasn't been set, methods will be resolved once it is. + * + * @param lazyConstructor Constructor to use the first time the instance is needed. + * @returns Asynchronous instance. + */ +export function asyncInstance(lazyConstructor?: () => T | Promise): AsyncInstance { + const wrapper = createAsyncInstanceWrapper(lazyConstructor); + + return new Proxy(wrapper, { + get: (target, property, receiver) => { + if (property in target) { + return Reflect.get(target, property, receiver); + } + + return async (...args: unknown[]) => { + const instance = await wrapper.getInstance(); + + return instance[property](...args); + }; + }, + }) as AsyncInstance; +} From 359d7ab5a57010c2331487040369c6c74485b863 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Mon, 7 Feb 2022 13:46:09 +0100 Subject: [PATCH 09/10] MOBILE-3977 core: Reuse databases initialization --- src/core/classes/site.ts | 7 ++- src/core/services/filepool.ts | 86 ++++++++++++----------------------- src/core/services/sites.ts | 63 +++++++++++++++---------- src/core/utils/lazy-map.ts | 40 ++++++++++++++++ 4 files changed, 114 insertions(+), 82 deletions(-) create mode 100644 src/core/utils/lazy-map.ts diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index 5ce95a266..4bcdf942e 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -44,6 +44,7 @@ import { CoreLang } from '@services/lang'; import { CoreSites } from '@services/sites'; import { asyncInstance, AsyncInstance } from '../utils/async-instance'; import { CoreDatabaseTable } from './database/database-table'; +import { CoreDatabaseCachingStrategy } from './database/database-table-proxy'; /** * QR Code type enumeration. @@ -140,7 +141,11 @@ export class CoreSite { ) { this.logger = CoreLogger.getInstance('CoreSite'); this.siteUrl = CoreUrlUtils.removeUrlParams(this.siteUrl); // Make sure the URL doesn't have params. - this.cacheTable = asyncInstance(() => CoreSites.getCacheTable(this)); + this.cacheTable = asyncInstance(() => CoreSites.getSiteTable(CoreSite.WS_CACHE_TABLE, { + siteId: this.getId(), + database: this.getDb(), + config: { cachingStrategy: CoreDatabaseCachingStrategy.None }, + })); this.setInfo(infos); this.calculateOfflineDisabled(); diff --git a/src/core/services/filepool.ts b/src/core/services/filepool.ts index e3f5bab52..08bd6f078 100644 --- a/src/core/services/filepool.ts +++ b/src/core/services/filepool.ts @@ -48,9 +48,10 @@ import { } from '@services/database/filepool'; import { CoreFileHelper } from './file-helper'; import { CoreUrl } from '@singletons/url'; -import { CorePromisedValue } from '@classes/promised-value'; import { CoreDatabaseTable } from '@classes/database/database-table'; -import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy'; +import { CoreDatabaseCachingStrategy } from '@classes/database/database-table-proxy'; +import { lazyMap, LazyMap } from '../utils/lazy-map'; +import { asyncInstance, AsyncInstance } from '../utils/async-instance'; /* * Factory for handling downloading files and retrieve downloaded files. @@ -101,11 +102,20 @@ export class CoreFilepoolProvider { // Variables for DB. protected appDB: Promise; protected resolveAppDB!: (appDB: SQLiteDB) => void; - protected filesTables: Record>> = {}; + protected filesTables: LazyMap>>; constructor() { this.appDB = new Promise(resolve => this.resolveAppDB = resolve); this.logger = CoreLogger.getInstance('CoreFilepoolProvider'); + this.filesTables = lazyMap( + siteId => asyncInstance( + () => CoreSites.getSiteTable(FILES_TABLE_NAME, { + siteId, + config: { cachingStrategy: CoreDatabaseCachingStrategy.Lazy }, + primaryKeyColumns: ['fileId'], + }), + ), + ); } /** @@ -128,11 +138,9 @@ export class CoreFilepoolProvider { return; } - const filesTable = await this.filesTables[siteId]; + await this.filesTables[siteId].destroy(); delete this.filesTables[siteId]; - - await filesTable.destroy(); }); } @@ -149,33 +157,6 @@ export class CoreFilepoolProvider { this.resolveAppDB(CoreApp.getDB()); } - /** - * Get files table. - * - * @param siteId Site id. - * @returns Files table. - */ - async getFilesTable(siteId?: string): Promise> { - siteId = siteId ?? CoreSites.getCurrentSiteId(); - - if (!(siteId in this.filesTables)) { - const filesTable = this.filesTables[siteId] = new CorePromisedValue(); - const database = await CoreSites.getSiteDb(siteId); - const table = new CoreDatabaseTableProxy( - { cachingStrategy: CoreDatabaseCachingStrategy.Lazy }, - database, - FILES_TABLE_NAME, - ['fileId'], - ); - - await table.initialize(); - - filesTable.resolve(table); - } - - return this.filesTables[siteId]; - } - /** * Link a file with a component. * @@ -262,9 +243,7 @@ export class CoreFilepoolProvider { ...data, }; - const filesTable = await this.getFilesTable(siteId); - - await filesTable.insert(record); + await this.filesTables[siteId].insert(record); } /** @@ -605,14 +584,13 @@ export class CoreFilepoolProvider { */ async clearFilepool(siteId: string): Promise { const db = await CoreSites.getSiteDb(siteId); - const filesTable = await this.getFilesTable(siteId); // Read the data first to be able to notify the deletions. - const filesEntries = await filesTable.all(); + const filesEntries = await this.filesTables[siteId].all(); const filesLinks = await db.getAllRecords(LINKS_TABLE_NAME); await Promise.all([ - filesTable.delete(), + this.filesTables[siteId].delete(), db.deleteRecords(LINKS_TABLE_NAME), ]); @@ -1167,14 +1145,13 @@ export class CoreFilepoolProvider { } const db = await CoreSites.getSiteDb(siteId); - const filesTable = await this.getFilesTable(siteId); const extension = CoreMimetypeUtils.getFileExtension(entry.path); if (!extension) { // Files does not have extension. Invalidate file (stale = true). // Minor problem: file will remain in the filesystem once downloaded again. this.logger.debug('Staled file with no extension ' + entry.fileId); - await filesTable.update({ stale: 1 }, { fileId: entry.fileId }); + await this.filesTables[siteId].update({ stale: 1 }, { fileId: entry.fileId }); return; } @@ -1184,7 +1161,7 @@ export class CoreFilepoolProvider { entry.fileId = CoreMimetypeUtils.removeExtension(fileId); entry.extension = extension; - await filesTable.update(entry, { fileId }); + await this.filesTables[siteId].update(entry, { fileId }); if (entry.fileId == fileId) { // File ID hasn't changed, we're done. this.logger.debug('Removed extesion ' + extension + ' from file ' + entry.fileId); @@ -1445,13 +1422,12 @@ export class CoreFilepoolProvider { */ async getFilesByComponent(siteId: string, component: string, componentId?: string | number): Promise { const db = await CoreSites.getSiteDb(siteId); - const filesTable = await this.getFilesTable(siteId); const items = await this.getComponentFiles(db, component, componentId); const files: CoreFilepoolFileEntry[] = []; await Promise.all(items.map(async (item) => { try { - const fileEntry = await filesTable.findByPrimaryKey({ fileId: item.fileId }); + const fileEntry = await this.filesTables[siteId].findByPrimaryKey({ fileId: item.fileId }); if (!fileEntry) { return; @@ -2184,9 +2160,7 @@ export class CoreFilepoolProvider { * @return Resolved with file object from DB on success, rejected otherwise. */ protected async hasFileInPool(siteId: string, fileId: string): Promise { - const filesTable = await this.getFilesTable(siteId); - - return filesTable.findByPrimaryKey({ fileId }); + return this.filesTables[siteId].findByPrimaryKey({ fileId }); } /** @@ -2218,17 +2192,15 @@ export class CoreFilepoolProvider { * @return Resolved on success. */ async invalidateAllFiles(siteId: string, onlyUnknown: boolean = true): Promise { - const filesTable = await this.getFilesTable(siteId); - onlyUnknown - ? await filesTable.updateWhere( + ? await this.filesTables[siteId].updateWhere( { stale: 1 }, { sql: CoreFilepoolProvider.FILE_IS_UNKNOWN_SQL, js: CoreFilepoolProvider.FILE_IS_UNKNOWN_JS, }, ) - : await filesTable.update({ stale: 1 }); + : await this.filesTables[siteId].update({ stale: 1 }); } /** @@ -2247,9 +2219,7 @@ export class CoreFilepoolProvider { const file = await this.fixPluginfileURL(siteId, fileUrl); const fileId = this.getFileIdByUrl(CoreFileHelper.getFileUrl(file)); - const filesTable = await this.getFilesTable(siteId); - - await filesTable.update({ stale: 1 }, { fileId }); + await this.filesTables[siteId].update({ stale: 1 }, { fileId }); } /** @@ -2269,7 +2239,6 @@ export class CoreFilepoolProvider { onlyUnknown: boolean = true, ): Promise { const db = await CoreSites.getSiteDb(siteId); - const filesTable = await this.getFilesTable(siteId); const items = await this.getComponentFiles(db, component, componentId); if (!items.length) { @@ -2277,6 +2246,8 @@ export class CoreFilepoolProvider { return; } + siteId = siteId ?? CoreSites.getCurrentSiteId(); + const fileIds = items.map((item) => item.fileId); const whereAndParams = db.getInOrEqual(fileIds); @@ -2287,7 +2258,7 @@ export class CoreFilepoolProvider { whereAndParams.sql += ' AND (' + CoreFilepoolProvider.FILE_IS_UNKNOWN_SQL + ')'; } - await filesTable.updateWhere( + await this.filesTables[siteId].updateWhere( { stale: 1 }, { sql: whereAndParams.sql, @@ -2714,7 +2685,6 @@ export class CoreFilepoolProvider { */ protected async removeFileById(siteId: string, fileId: string): Promise { const db = await CoreSites.getSiteDb(siteId); - const filesTable = await this.getFilesTable(siteId); // Get the path to the file first since it relies on the file object stored in the pool. // Don't use getFilePath to prevent performing 2 DB requests. @@ -2741,7 +2711,7 @@ export class CoreFilepoolProvider { const promises: Promise[] = []; // Remove entry from filepool store. - promises.push(filesTable.delete(conditions)); + promises.push(this.filesTables[siteId].delete(conditions)); // Remove links. promises.push(db.deleteRecords(LINKS_TABLE_NAME, conditions)); diff --git a/src/core/services/sites.ts b/src/core/services/sites.ts index 9de9c1998..ee324ae59 100644 --- a/src/core/services/sites.ts +++ b/src/core/services/sites.ts @@ -31,9 +31,8 @@ import { CoreSiteConfig, CoreSitePublicConfigResponse, CoreSiteInfoResponse, - CoreSiteWSCacheRecord, } from '@classes/site'; -import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb'; +import { SQLiteDB, SQLiteDBRecordValues, SQLiteDBTableSchema } from '@classes/sqlitedb'; import { CoreError } from '@classes/errors/error'; import { CoreSiteError } from '@classes/errors/siteerror'; import { makeSingleton, Translate, Http } from '@singletons'; @@ -60,7 +59,7 @@ import { CoreAjaxWSError } from '@classes/errors/ajaxwserror'; import { CoreSitePlugins } from '@features/siteplugins/services/siteplugins'; import { CorePromisedValue } from '@classes/promised-value'; import { CoreDatabaseTable } from '@classes/database/database-table'; -import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy'; +import { CoreDatabaseConfiguration, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy'; export const CORE_SITE_SCHEMAS = new InjectionToken('CORE_SITE_SCHEMAS'); @@ -89,7 +88,7 @@ export class CoreSitesProvider { // Variables for DB. protected appDB: Promise; protected resolveAppDB!: (appDB: SQLiteDB) => void; - protected cacheTables: Record>> = {}; + protected siteTables: Record>> = {}; constructor(@Optional() @Inject(CORE_SITE_SCHEMAS) siteSchemas: CoreSiteSchema[][] = []) { this.appDB = new Promise(resolve => this.resolveAppDB = resolve); @@ -109,15 +108,17 @@ export class CoreSitesProvider { */ initialize(): void { CoreEvents.on(CoreEvents.SITE_DELETED, async ({ siteId }) => { - if (!siteId || !(siteId in this.cacheTables)) { + if (!siteId || !(siteId in this.siteTables)) { return; } - const cacheTable = await this.cacheTables[siteId]; + await Promise.all( + Object + .values(this.siteTables[siteId]) + .map(promisedTable => promisedTable.then(table => table.destroy())), + ); - delete this.cacheTables[siteId]; - - await cacheTable.destroy(); + delete this.siteTables[siteId]; }); } @@ -135,30 +136,46 @@ export class CoreSitesProvider { } /** - * Get cache table. + * Get site table. * - * @param siteId Site id. - * @returns cache table. + * @param tableName Site table name. + * @param options Options to configure table initialization. + * @returns Site table. */ - async getCacheTable(site: CoreSite): Promise> { - if (!site.id) { - throw new CoreError('Can\'t get cache table for site without id'); + async getSiteTable< + DBRecord extends SQLiteDBRecordValues, + PrimaryKeyColumn extends keyof DBRecord + >( + tableName: string, + options: Partial<{ + siteId: string; + config: Partial; + database: SQLiteDB; + primaryKeyColumns: PrimaryKeyColumn[]; + }> = {}, + ): Promise> { + const siteId = options.siteId ?? this.getCurrentSiteId(); + + if (!(siteId in this.siteTables)) { + this.siteTables[siteId] = {}; } - if (!(site.id in this.cacheTables)) { - const promisedTable = this.cacheTables[site.id] = new CorePromisedValue(); - const table = new CoreDatabaseTableProxy( - { cachingStrategy: CoreDatabaseCachingStrategy.None }, - site.getDb(), - CoreSite.WS_CACHE_TABLE, + if (!(tableName in this.siteTables[siteId])) { + const promisedTable = this.siteTables[siteId][tableName] = new CorePromisedValue(); + const database = options.database ?? await this.getSiteDb(siteId); + const table = new CoreDatabaseTableProxy( + options.config ?? {}, + database, + tableName, + options.primaryKeyColumns, ); await table.initialize(); - promisedTable.resolve(table); + promisedTable.resolve(table as unknown as CoreDatabaseTable); } - return this.cacheTables[site.id]; + return this.siteTables[siteId][tableName] as unknown as Promise>; } /** diff --git a/src/core/utils/lazy-map.ts b/src/core/utils/lazy-map.ts new file mode 100644 index 000000000..3570c111c --- /dev/null +++ b/src/core/utils/lazy-map.ts @@ -0,0 +1,40 @@ +// (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. + +/** + * Lazy map. + * + * Lazy maps are empty by default, but entries are generated lazily when accessed. + */ +export type LazyMap = Record; + +/** + * Create a map that will initialize entries lazily with the given constructor. + * + * @param lazyConstructor Constructor to use the first time an entry is accessed. + * @returns Lazy map. + */ +export function lazyMap(lazyConstructor: (key: string) => T): LazyMap { + const instances = {}; + + return new Proxy(instances, { + get(target, property, receiver) { + if (!(property in instances)) { + target[property] = lazyConstructor(property.toString()); + } + + return Reflect.get(target, property, receiver); + }, + }); +} From b8dbc9aa5b2c87fa0a7e419869969cdf1e456151 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Mon, 7 Feb 2022 14:02:29 +0100 Subject: [PATCH 10/10] MOBILE-3977 core: Rename database table getters --- .../classes/database/database-table-proxy.ts | 12 +++++------ src/core/classes/database/database-table.ts | 6 +++--- .../classes/database/debug-database-table.ts | 16 +++++++-------- .../classes/database/eager-database-table.ts | 8 ++++---- .../classes/database/lazy-database-table.ts | 8 ++++---- src/core/classes/site.ts | 6 +++--- src/core/classes/tests/database-table.test.ts | 20 +++++++++---------- src/core/services/config.ts | 2 +- src/core/services/filepool.ts | 6 +++--- 9 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/core/classes/database/database-table-proxy.ts b/src/core/classes/database/database-table-proxy.ts index 0a9006282..58393f943 100644 --- a/src/core/classes/database/database-table-proxy.ts +++ b/src/core/classes/database/database-table-proxy.ts @@ -67,22 +67,22 @@ export class CoreDatabaseTableProxy< /** * @inheritdoc */ - async all(conditions?: Partial): Promise { - return this.target.all(conditions); + async getMany(conditions?: Partial): Promise { + return this.target.getMany(conditions); } /** * @inheritdoc */ - async find(conditions: Partial): Promise { - return this.target.find(conditions); + async getOne(conditions: Partial): Promise { + return this.target.getOne(conditions); } /** * @inheritdoc */ - async findByPrimaryKey(primaryKey: PrimaryKey): Promise { - return this.target.findByPrimaryKey(primaryKey); + async getOneByPrimaryKey(primaryKey: PrimaryKey): Promise { + return this.target.getOneByPrimaryKey(primaryKey); } /** diff --git a/src/core/classes/database/database-table.ts b/src/core/classes/database/database-table.ts index 2bb34ff89..f018b062c 100644 --- a/src/core/classes/database/database-table.ts +++ b/src/core/classes/database/database-table.ts @@ -80,7 +80,7 @@ export class CoreDatabaseTable< * @param conditions Matching conditions. If this argument is missing, all records in the table will be returned. * @returns Database records. */ - all(conditions?: Partial): Promise { + getMany(conditions?: Partial): Promise { return conditions ? this.database.getRecords(this.tableName, conditions) : this.database.getAllRecords(this.tableName); @@ -92,7 +92,7 @@ export class CoreDatabaseTable< * @param conditions Matching conditions. * @returns Database record. */ - find(conditions: Partial): Promise { + getOne(conditions: Partial): Promise { return this.database.getRecord(this.tableName, conditions); } @@ -102,7 +102,7 @@ export class CoreDatabaseTable< * @param primaryKey Primary key. * @returns Database record. */ - findByPrimaryKey(primaryKey: PrimaryKey): Promise { + getOneByPrimaryKey(primaryKey: PrimaryKey): Promise { return this.database.getRecord(this.tableName, primaryKey); } diff --git a/src/core/classes/database/debug-database-table.ts b/src/core/classes/database/debug-database-table.ts index 09d80dad0..209c9abb1 100644 --- a/src/core/classes/database/debug-database-table.ts +++ b/src/core/classes/database/debug-database-table.ts @@ -58,28 +58,28 @@ export class CoreDebugDatabaseTable< /** * @inheritdoc */ - all(conditions?: Partial): Promise { - this.logger.log('all', conditions); + getMany(conditions?: Partial): Promise { + this.logger.log('getMany', conditions); - return this.target.all(conditions); + return this.target.getMany(conditions); } /** * @inheritdoc */ - find(conditions: Partial): Promise { - this.logger.log('find', conditions); + getOne(conditions: Partial): Promise { + this.logger.log('getOne', conditions); - return this.target.find(conditions); + return this.target.getOne(conditions); } /** * @inheritdoc */ - findByPrimaryKey(primaryKey: PrimaryKey): Promise { + getOneByPrimaryKey(primaryKey: PrimaryKey): Promise { this.logger.log('findByPrimaryKey', primaryKey); - return this.target.findByPrimaryKey(primaryKey); + return this.target.getOneByPrimaryKey(primaryKey); } /** diff --git a/src/core/classes/database/eager-database-table.ts b/src/core/classes/database/eager-database-table.ts index 7cc3cf53c..37b09342c 100644 --- a/src/core/classes/database/eager-database-table.ts +++ b/src/core/classes/database/eager-database-table.ts @@ -34,7 +34,7 @@ export class CoreEagerDatabaseTable< * @inheritdoc */ async initialize(): Promise { - const records = await super.all(); + const records = await super.getMany(); this.records = records.reduce((data, record) => { const primaryKey = this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record)); @@ -48,7 +48,7 @@ export class CoreEagerDatabaseTable< /** * @inheritdoc */ - async all(conditions?: Partial): Promise { + async getMany(conditions?: Partial): Promise { const records = Object.values(this.records); return conditions @@ -59,7 +59,7 @@ export class CoreEagerDatabaseTable< /** * @inheritdoc */ - async find(conditions: Partial): Promise { + async getOne(conditions: Partial): Promise { const record = Object.values(this.records).find(record => this.recordMatches(record, conditions)) ?? null; if (record === null) { @@ -72,7 +72,7 @@ export class CoreEagerDatabaseTable< /** * @inheritdoc */ - async findByPrimaryKey(primaryKey: PrimaryKey): Promise { + async getOneByPrimaryKey(primaryKey: PrimaryKey): Promise { const record = this.records[this.serializePrimaryKey(primaryKey)] ?? null; if (record === null) { diff --git a/src/core/classes/database/lazy-database-table.ts b/src/core/classes/database/lazy-database-table.ts index cf64623a7..9ec890a20 100644 --- a/src/core/classes/database/lazy-database-table.ts +++ b/src/core/classes/database/lazy-database-table.ts @@ -33,12 +33,12 @@ export class CoreLazyDatabaseTable< /** * @inheritdoc */ - async find(conditions: Partial): Promise { + async getOne(conditions: Partial): Promise { let record: DBRecord | null = Object.values(this.records).find(record => record && this.recordMatches(record, conditions)) ?? null; if (!record) { - record = await super.find(conditions); + record = await super.getOne(conditions); this.records[this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record))] = record; } @@ -49,12 +49,12 @@ export class CoreLazyDatabaseTable< /** * @inheritdoc */ - async findByPrimaryKey(primaryKey: PrimaryKey): Promise { + async getOneByPrimaryKey(primaryKey: PrimaryKey): Promise { const serializePrimaryKey = this.serializePrimaryKey(primaryKey); if (!(serializePrimaryKey in this.records)) { try { - const record = await super.findByPrimaryKey(primaryKey); + const record = await super.getOneByPrimaryKey(primaryKey); this.records[serializePrimaryKey] = record; diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index 4bcdf942e..d95590192 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -938,11 +938,11 @@ export class CoreSite { let entry: CoreSiteWSCacheRecord | undefined; if (preSets.getCacheUsingCacheKey || (emergency && preSets.getEmergencyCacheUsingCacheKey)) { - const entries = await this.cacheTable.all({ key: preSets.cacheKey }); + const entries = await this.cacheTable.getMany({ key: preSets.cacheKey }); if (!entries.length) { // Cache key not found, get by params sent. - entry = await this.cacheTable.findByPrimaryKey({ id }); + entry = await this.cacheTable.getOneByPrimaryKey({ id }); } else { if (entries.length > 1) { // More than one entry found. Search the one with same ID as this call. @@ -954,7 +954,7 @@ export class CoreSite { } } } else { - entry = await this.cacheTable.findByPrimaryKey({ id }); + entry = await this.cacheTable.getOneByPrimaryKey({ id }); } if (entry === undefined) { diff --git a/src/core/classes/tests/database-table.test.ts b/src/core/classes/tests/database-table.test.ts index 4d02603d2..9c7f1757f 100644 --- a/src/core/classes/tests/database-table.test.ts +++ b/src/core/classes/tests/database-table.test.ts @@ -78,10 +78,10 @@ async function testFindItems(records: User[], table: CoreDatabaseTable) { await table.initialize(); - await expect(table.findByPrimaryKey({ id: 1 })).resolves.toEqual(john); - await expect(table.findByPrimaryKey({ id: 2 })).resolves.toEqual(amy); - await expect(table.find({ surname: 'Doe', name: 'John' })).resolves.toEqual(john); - await expect(table.find({ surname: 'Doe', name: 'Amy' })).resolves.toEqual(amy); + await expect(table.getOneByPrimaryKey({ id: 1 })).resolves.toEqual(john); + await expect(table.getOneByPrimaryKey({ id: 2 })).resolves.toEqual(amy); + await expect(table.getOne({ surname: 'Doe', name: 'John' })).resolves.toEqual(john); + await expect(table.getOne({ surname: 'Doe', name: 'Amy' })).resolves.toEqual(amy); } async function testInsertItems(records: User[], database: SQLiteDB, table: CoreDatabaseTable) { @@ -96,7 +96,7 @@ async function testInsertItems(records: User[], database: SQLiteDB, table: CoreD // Assert. expect(database.insertRecord).toHaveBeenCalledWith('users', john); - await expect(table.findByPrimaryKey({ id: 1 })).resolves.toEqual(john); + await expect(table.getOneByPrimaryKey({ id: 1 })).resolves.toEqual(john); } async function testDeleteItems(records: User[], database: SQLiteDB, table: CoreDatabaseTable) { @@ -117,9 +117,9 @@ async function testDeleteItems(records: User[], database: SQLiteDB, table: CoreD // Assert. expect(database.deleteRecords).toHaveBeenCalledWith('users', { surname: 'Doe' }); - await expect(table.findByPrimaryKey({ id: 1 })).rejects.toThrow(); - await expect(table.findByPrimaryKey({ id: 2 })).rejects.toThrow(); - await expect(table.findByPrimaryKey({ id: 3 })).resolves.toEqual(jane); + await expect(table.getOneByPrimaryKey({ id: 1 })).rejects.toThrow(); + await expect(table.getOneByPrimaryKey({ id: 2 })).rejects.toThrow(); + await expect(table.getOneByPrimaryKey({ id: 3 })).resolves.toEqual(jane); } async function testDeleteItemsByPrimaryKey(records: User[], database: SQLiteDB, table: CoreDatabaseTable) { @@ -138,8 +138,8 @@ async function testDeleteItemsByPrimaryKey(records: User[], database: SQLiteDB, // Assert. expect(database.deleteRecords).toHaveBeenCalledWith('users', { id: 1 }); - await expect(table.findByPrimaryKey({ id: 1 })).rejects.toThrow(); - await expect(table.findByPrimaryKey({ id: 2 })).resolves.toEqual(amy); + await expect(table.getOneByPrimaryKey({ id: 1 })).rejects.toThrow(); + await expect(table.getOneByPrimaryKey({ id: 2 })).resolves.toEqual(amy); } describe('CoreDatabaseTable with eager caching', () => { diff --git a/src/core/services/config.ts b/src/core/services/config.ts index 0ed248301..ef0f65d5a 100644 --- a/src/core/services/config.ts +++ b/src/core/services/config.ts @@ -89,7 +89,7 @@ export class CoreConfigProvider { */ async get(name: string, defaultValue?: T): Promise { try { - const record = await this.table.findByPrimaryKey({ name }); + const record = await this.table.getOneByPrimaryKey({ name }); return record.value; } catch (error) { diff --git a/src/core/services/filepool.ts b/src/core/services/filepool.ts index 08bd6f078..b1d4c2380 100644 --- a/src/core/services/filepool.ts +++ b/src/core/services/filepool.ts @@ -586,7 +586,7 @@ export class CoreFilepoolProvider { const db = await CoreSites.getSiteDb(siteId); // Read the data first to be able to notify the deletions. - const filesEntries = await this.filesTables[siteId].all(); + const filesEntries = await this.filesTables[siteId].getMany(); const filesLinks = await db.getAllRecords(LINKS_TABLE_NAME); await Promise.all([ @@ -1427,7 +1427,7 @@ export class CoreFilepoolProvider { await Promise.all(items.map(async (item) => { try { - const fileEntry = await this.filesTables[siteId].findByPrimaryKey({ fileId: item.fileId }); + const fileEntry = await this.filesTables[siteId].getOneByPrimaryKey({ fileId: item.fileId }); if (!fileEntry) { return; @@ -2160,7 +2160,7 @@ export class CoreFilepoolProvider { * @return Resolved with file object from DB on success, rejected otherwise. */ protected async hasFileInPool(siteId: string, fileId: string): Promise { - return this.filesTables[siteId].findByPrimaryKey({ fileId }); + return this.filesTables[siteId].getOneByPrimaryKey({ fileId }); } /**