diff --git a/src/core/classes/database-table.ts b/src/core/classes/database-table.ts new file mode 100644 index 000000000..5e6ea32ee --- /dev/null +++ b/src/core/classes/database-table.ts @@ -0,0 +1,194 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { SQLiteDB, SQLiteDBRecordValues } from './sqlitedb'; + +/** + * Database table wrapper used to improve performance by caching all data in memory + * for faster read operations. + */ +export abstract class CoreDatabaseTable< + DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues, + PrimaryKeyColumns extends keyof DBRecord = 'id', + PrimaryKey extends GetPrimaryKey = 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/tests/database-table.test.ts b/src/core/classes/tests/database-table.test.ts new file mode 100644 index 000000000..92e6d7ec2 --- /dev/null +++ b/src/core/classes/tests/database-table.test.ts @@ -0,0 +1,124 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { mock } from '@/testing/utils'; +import { CoreDatabaseTable } from '@classes/database-table'; +import { SQLiteDB, SQLiteDBRecordValues } from '@classes/sqlitedb'; + +interface User extends SQLiteDBRecordValues { + id: number; + name: string; + surname: string; +} + +class UsersTable extends CoreDatabaseTable { + + protected table = 'users'; + +} + +describe('CoreDatabaseTable', () => { + + let records: User[]; + let db: SQLiteDB; + + beforeEach(() => { + records = []; + db = mock({ + getRecords: async () => records as unknown as T[], + deleteRecords: async () => 0, + insertRecord: async () => 0, + }); + }); + + it('reads all records on create', async () => { + await UsersTable.create(db); + + expect(db.getRecords).toHaveBeenCalledWith('users'); + }); + + it('finds items', async () => { + const john = { id: 1, name: 'John', surname: 'Doe' }; + const amy = { id: 2, name: 'Amy', surname: 'Doe' }; + + records.push(john); + records.push(amy); + + const table = await UsersTable.create(db); + + expect(table.findByPrimaryKey({ id: 1 })).toEqual(john); + expect(table.findByPrimaryKey({ id: 2 })).toEqual(amy); + expect(table.find({ surname: 'Doe', name: 'John' })).toEqual(john); + expect(table.find({ surname: 'Doe', name: 'Amy' })).toEqual(amy); + }); + + it('inserts items', async () => { + // Arrange. + const john = { id: 1, name: 'John', surname: 'Doe' }; + + // Act. + const table = await UsersTable.create(db); + + await table.insert(john); + + // Assert. + expect(db.insertRecord).toHaveBeenCalledWith('users', john); + + expect(table.findByPrimaryKey({ id: 1 })).toEqual(john); + }); + + it('deletes items', async () => { + // Arrange. + const john = { id: 1, name: 'John', surname: 'Doe' }; + const amy = { id: 2, name: 'Amy', surname: 'Doe' }; + const jane = { id: 3, name: 'Jane', surname: 'Smith' }; + + records.push(john); + records.push(amy); + records.push(jane); + + // Act. + const table = await UsersTable.create(db); + + await table.delete({ surname: 'Doe' }); + + // Assert. + expect(db.deleteRecords).toHaveBeenCalledWith('users', { surname: 'Doe' }); + + expect(table.findByPrimaryKey({ id: 1 })).toBeNull(); + expect(table.findByPrimaryKey({ id: 2 })).toBeNull(); + expect(table.findByPrimaryKey({ id: 3 })).toEqual(jane); + }); + + it('deletes items by primary key', async () => { + // Arrange. + const john = { id: 1, name: 'John', surname: 'Doe' }; + const amy = { id: 2, name: 'Amy', surname: 'Doe' }; + + records.push(john); + records.push(amy); + + // Act. + const table = await UsersTable.create(db); + + await table.deleteByPrimaryKey({ id: 1 }); + + // Assert. + expect(db.deleteRecords).toHaveBeenCalledWith('users', { id: 1 }); + + expect(table.findByPrimaryKey({ id: 1 })).toBeNull(); + expect(table.findByPrimaryKey({ id: 2 })).toEqual(amy); + }); + +}); diff --git a/src/core/services/config.ts b/src/core/services/config.ts index 70d6982ef..ee49af16d 100644 --- a/src/core/services/config.ts +++ b/src/core/services/config.ts @@ -15,9 +15,10 @@ import { Injectable } from '@angular/core'; import { CoreApp } from '@services/app'; -import { SQLiteDB } from '@classes/sqlitedb'; import { makeSingleton } from '@singletons'; import { CONFIG_TABLE_NAME, APP_SCHEMA, ConfigDBEntry } from '@services/database/config'; +import { CoreDatabaseTable } from '@classes/database-table'; +import { CorePromisedValue } from '@classes/promised-value'; /** * Factory to provide access to dynamic and permanent config and settings. @@ -26,11 +27,10 @@ import { CONFIG_TABLE_NAME, APP_SCHEMA, ConfigDBEntry } from '@services/database @Injectable({ providedIn: 'root' }) export class CoreConfigProvider { - protected appDB: Promise; - protected resolveAppDB!: (appDB: SQLiteDB) => void; + protected dbTable: CorePromisedValue; constructor() { - this.appDB = new Promise(resolve => this.resolveAppDB = resolve); + this.dbTable = new CorePromisedValue(); } /** @@ -43,7 +43,10 @@ export class CoreConfigProvider { // Ignore errors. } - this.resolveAppDB(CoreApp.getDB()); + const db = CoreApp.getDB(); + const table = await CoreConfigTable.create(db); + + this.dbTable.resolve(table); } /** @@ -53,9 +56,9 @@ export class CoreConfigProvider { * @return Promise resolved when done. */ async delete(name: string): Promise { - const db = await this.appDB; + const table = await this.dbTable; - await db.deleteRecords(CONFIG_TABLE_NAME, { name }); + await table.deleteByPrimaryKey({ name }); } /** @@ -66,19 +69,18 @@ export class CoreConfigProvider { * @return Resolves upon success along with the config data. Reject on failure. */ async get(name: string, defaultValue?: T): Promise { - const db = await this.appDB; + const table = await this.dbTable; + const record = table.findByPrimaryKey({ name }); - try { - const entry = await db.getRecord(CONFIG_TABLE_NAME, { name }); - - return entry.value; - } catch (error) { - if (defaultValue !== undefined) { - return defaultValue; - } - - throw error; + if (record !== null) { + return record.value; } + + if (defaultValue !== undefined) { + return defaultValue; + } + + throw new Error(`Couldn't get config with name '${name}'`); } /** @@ -89,11 +91,21 @@ export class CoreConfigProvider { * @return Promise resolved when done. */ async set(name: string, value: number | string): Promise { - const db = await this.appDB; + const table = await this.dbTable; - await db.insertRecord(CONFIG_TABLE_NAME, { name, value }); + await table.insert({ name, value }); } } export const CoreConfig = makeSingleton(CoreConfigProvider); + +/** + * Config database table. + */ +class CoreConfigTable extends CoreDatabaseTable { + + protected table = CONFIG_TABLE_NAME; + protected primaryKeys = ['name']; + +} diff --git a/src/testing/utils.ts b/src/testing/utils.ts index 4b8ecadd9..e31f69047 100644 --- a/src/testing/utils.ts +++ b/src/testing/utils.ts @@ -133,12 +133,10 @@ export type WrapperComponentFixture = ComponentFixture>; * @param overrides Object with the properties or methods to override, or a list of methods to override with an empty function. * @return Mock instance. */ -export function mock(instance?: T, overrides?: string[] | Record): T; -export function mock(instance?: Partial, overrides?: string[] | Record): Partial; export function mock( instance: T | Partial = {}, overrides: string[] | Record = {}, -): T | Partial { +): T { // If overrides is an object, apply them to the instance. if (!Array.isArray(overrides)) { Object.assign(instance, overrides); @@ -162,7 +160,7 @@ export function mock( } } - return instance; + return instance as T; } export function mockSingleton(singletonClass: CoreSingletonProxy, instance: T): T;