From 808a242cbcd462b9c275711a145a0426dcd306ce Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 3 Feb 2022 12:35:16 +0100 Subject: [PATCH] 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']; - -}