From 1d8f0c5a66b959ec8e2597e699ef2004f7b3299b Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Mon, 24 Jan 2022 18:00:27 +0100 Subject: [PATCH 1/2] MOBILE-3971 core: Track database query performance --- scripts/print-performance-measures.js | 6 +- src/core/classes/sqlitedb.ts | 66 ++++++++++++++++--- .../features/emulator/classes/sqlitedb.ts | 48 +++++++++++--- .../initializers/prepare-automated-tests.ts | 3 + src/core/services/db.ts | 31 +++++++++ tests/behat/classes/performance_measure.php | 31 +++++++++ 6 files changed, 165 insertions(+), 20 deletions(-) diff --git a/scripts/print-performance-measures.js b/scripts/print-performance-measures.js index 064a88181..2b611be01 100755 --- a/scripts/print-performance-measures.js +++ b/scripts/print-performance-measures.js @@ -40,6 +40,7 @@ for (const file of files) { styling: [], blocking: [], longTasks: [], + database: [], networking: [], }; performanceMeasures[performanceMeasure.name].duration.push(performanceMeasure.duration); @@ -47,17 +48,19 @@ for (const file of files) { performanceMeasures[performanceMeasure.name].styling.push(performanceMeasure.styling); performanceMeasures[performanceMeasure.name].blocking.push(performanceMeasure.blocking); performanceMeasures[performanceMeasure.name].longTasks.push(performanceMeasure.longTasks); + performanceMeasures[performanceMeasure.name].database.push(performanceMeasure.database); performanceMeasures[performanceMeasure.name].networking.push(performanceMeasure.networking); } // Calculate averages -for (const [name, { duration, scripting, styling, blocking, longTasks, networking }] of Object.entries(performanceMeasures)) { +for (const [name, { duration, scripting, styling, blocking, longTasks, database, networking }] of Object.entries(performanceMeasures)) { const totalRuns = duration.length; const averageDuration = Math.round(duration.reduce((total, duration) => total + duration) / totalRuns); const averageScripting = Math.round(scripting.reduce((total, scripting) => total + scripting) / totalRuns); const averageStyling = Math.round(styling.reduce((total, styling) => total + styling) / totalRuns); const averageBlocking = Math.round(blocking.reduce((total, blocking) => total + blocking) / totalRuns); const averageLongTasks = Math.round(longTasks.reduce((total, longTasks) => total + longTasks) / totalRuns); + const averageDatabase = Math.round(database.reduce((total, database) => total + database) / totalRuns); const averageNetworking = Math.round(networking.reduce((total, networking) => total + networking) / totalRuns); performanceMeasures[name] = { @@ -66,6 +69,7 @@ for (const [name, { duration, scripting, styling, blocking, longTasks, networkin 'Styling': `${averageStyling}ms`, 'Blocking': `${averageBlocking}ms`, '# Network requests': averageNetworking, + '# DB Queries': averageDatabase, '# Long Tasks': averageLongTasks, '# runs': totalRuns, }; diff --git a/src/core/classes/sqlitedb.ts b/src/core/classes/sqlitedb.ts index 0aec0714b..615a3eb7f 100644 --- a/src/core/classes/sqlitedb.ts +++ b/src/core/classes/sqlitedb.ts @@ -16,6 +16,7 @@ import { SQLiteObject } from '@ionic-native/sqlite/ngx'; import { SQLite, Platform } from '@singletons'; import { CoreError } from '@classes/errors/error'; +import { CoreDB } from '@services/db'; type SQLiteDBColumnType = 'INTEGER' | 'REAL' | 'TEXT' | 'BLOB'; @@ -813,16 +814,19 @@ export class SQLiteDB { * Initialize the database. */ init(): void { - this.promise = Platform.ready() - .then(() => SQLite.create({ - name: this.name, - location: 'default', - })) - .then((db: SQLiteObject) => { - this.db = db; + this.promise = this.createDatabase().then(db => { + if (CoreDB.loggingEnabled()) { + const spies = this.getDatabaseSpies(db); - return; - }); + db = new Proxy(db, { + get: (target, property, receiver) => spies[property] ?? Reflect.get(target, property, receiver), + }); + } + + this.db = db; + + return; + }); } /** @@ -1147,6 +1151,50 @@ export class SQLiteDB { return { sql, params }; } + /** + * Open a database connection. + * + * @returns Database. + */ + protected async createDatabase(): Promise { + await Platform.ready(); + + return SQLite.create({ name: this.name, location: 'default' }); + } + + /** + * Get database spy methods to intercept database calls and track logging information. + * + * @param db Database to spy. + * @returns Spy methods. + */ + protected getDatabaseSpies(db: SQLiteObject): Partial { + return { + executeSql(statement, params) { + const start = performance.now(); + + return db.executeSql(statement, params).then(result => { + CoreDB.logQuery(statement, performance.now() - start, params); + + return result; + }); + }, + sqlBatch(statements) { + const start = performance.now(); + + return db.sqlBatch(statements).then(result => { + const sql = Array.isArray(statements) + ? statements.join(' | ') + : String(statements); + + CoreDB.logQuery(sql, performance.now() - start); + + return result; + }); + }, + }; + } + } export type SQLiteDBRecordValues = { diff --git a/src/core/features/emulator/classes/sqlitedb.ts b/src/core/features/emulator/classes/sqlitedb.ts index 4be4bd69d..18ad526b6 100644 --- a/src/core/features/emulator/classes/sqlitedb.ts +++ b/src/core/features/emulator/classes/sqlitedb.ts @@ -15,6 +15,8 @@ /* tslint:disable:no-console */ import { SQLiteDB } from '@classes/sqlitedb'; +import { DbTransaction, SQLiteObject } from '@ionic-native/sqlite/ngx'; +import { CoreDB } from '@services/db'; /** * Class to mock the interaction with the SQLite database. @@ -158,16 +160,6 @@ export class SQLiteDBMock extends SQLiteDB { }); } - /** - * Initialize the database. - */ - init(): void { - // This DB is for desktop apps, so use a big size to be sure it isn't filled. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.db = ( window).openDatabase(this.name, '1.0', this.name, 500 * 1024 * 1024); - this.promise = Promise.resolve(); - } - /** * Open the database. Only needed if it was closed before, a database is automatically opened when created. * @@ -178,4 +170,40 @@ export class SQLiteDBMock extends SQLiteDB { return Promise.resolve(); } + /** + * @inheritdoc + */ + protected async createDatabase(): Promise { + // This DB is for desktop apps, so use a big size to be sure it isn't filled. + return (window as unknown as WebSQLWindow).openDatabase(this.name, '1.0', this.name, 500 * 1024 * 1024); + } + + /** + * @inheritdoc + */ + protected getDatabaseSpies(db: SQLiteObject): Partial { + return { + transaction: (callback) => db.transaction((transaction) => { + const transactionSpy: DbTransaction = { + executeSql(sql, params, success, error) { + const start = performance.now(); + const resolve = callback => (...args) => { + CoreDB.logQuery(sql, performance.now() - start, params); + + return callback(...args); + }; + + return transaction.executeSql(sql, params, resolve(success), resolve(error)); + }, + }; + + return callback(transactionSpy); + }), + }; + } + +} + +interface WebSQLWindow extends Window { + openDatabase(name: string, version: string, displayName: string, estimatedSize: number): SQLiteObject; } diff --git a/src/core/initializers/prepare-automated-tests.ts b/src/core/initializers/prepare-automated-tests.ts index f8e60a42d..0a78c2456 100644 --- a/src/core/initializers/prepare-automated-tests.ts +++ b/src/core/initializers/prepare-automated-tests.ts @@ -16,12 +16,14 @@ import { ApplicationRef, NgZone as NgZoneService } from '@angular/core'; import { CorePushNotifications, CorePushNotificationsProvider } from '@features/pushnotifications/services/pushnotifications'; import { CoreApp, CoreAppProvider } from '@services/app'; import { CoreCronDelegate, CoreCronDelegateService } from '@services/cron'; +import { CoreDB, CoreDbProvider } from '@services/db'; import { CoreCustomURLSchemes, CoreCustomURLSchemesProvider } from '@services/urlschemes'; import { Application, NgZone } from '@singletons'; type AutomatedTestsWindow = Window & { appRef?: ApplicationRef; appProvider?: CoreAppProvider; + dbProvider?: CoreDbProvider; cronProvider?: CoreCronDelegateService; ngZone?: NgZoneService; pushNotifications?: CorePushNotificationsProvider; @@ -31,6 +33,7 @@ type AutomatedTestsWindow = Window & { function initializeAutomatedTestsWindow(window: AutomatedTestsWindow) { window.appRef = Application.instance; window.appProvider = CoreApp.instance; + window.dbProvider = CoreDB.instance; window.cronProvider = CoreCronDelegate.instance; window.ngZone = NgZone.instance; window.pushNotifications = CorePushNotifications.instance; diff --git a/src/core/services/db.ts b/src/core/services/db.ts index a715625c8..727b5424d 100644 --- a/src/core/services/db.ts +++ b/src/core/services/db.ts @@ -17,6 +17,7 @@ import { Injectable } from '@angular/core'; import { SQLiteDB } from '@classes/sqlitedb'; import { SQLiteDBMock } from '@features/emulator/classes/sqlitedb'; import { makeSingleton, SQLite, Platform } from '@singletons'; +import { CoreAppProvider } from './app'; /** * This service allows interacting with the local database to store and retrieve data. @@ -24,8 +25,29 @@ import { makeSingleton, SQLite, Platform } from '@singletons'; @Injectable({ providedIn: 'root' }) export class CoreDbProvider { + queryLogs: CoreDbQueryLog[] = []; + protected dbInstances: {[name: string]: SQLiteDB} = {}; + /** + * Check whether database queries should be logged. + * + * @returns Whether queries should be logged. + */ + loggingEnabled(): boolean { + return CoreAppProvider.isAutomated(); + } + + /** + * Log a query. + * + * @param sql Query SQL. + * @param params Query parameters. + */ + logQuery(sql: string, duration: number, params?: unknown[]): void { + this.queryLogs.push({ sql, duration, params }); + } + /** * Get or create a database object. * @@ -81,3 +103,12 @@ export class CoreDbProvider { } export const CoreDB = makeSingleton(CoreDbProvider); + +/** + * Database query log entry. + */ +export interface CoreDbQueryLog { + sql: string; + duration: number; + params?: unknown[]; +} diff --git a/tests/behat/classes/performance_measure.php b/tests/behat/classes/performance_measure.php index 6646f5ce4..3348fba36 100644 --- a/tests/behat/classes/performance_measure.php +++ b/tests/behat/classes/performance_measure.php @@ -58,6 +58,16 @@ class performance_measure implements behat_app_listener { */ public $blocking; + /** + * @var int + */ + public $databaseStart; + + /** + * @var int + */ + public $database; + /** * @var int */ @@ -90,6 +100,7 @@ class performance_measure implements behat_app_listener { $this->start = $this->now(); $this->observeLongTasks(); + $this->startDatabaseCount(); $this->behatAppUnsubscribe = behat_app::listen($this); } @@ -107,6 +118,7 @@ class performance_measure implements behat_app_listener { $this->analyseDuration(); $this->analyseLongTasks(); + $this->analyseDatabaseUsage(); $this->analysePerformanceLogs(); } @@ -131,6 +143,7 @@ class performance_measure implements behat_app_listener { 'styling' => $this->styling, 'blocking' => $this->blocking, 'longTasks' => count($this->longTasks), + 'database' => $this->database, 'networking' => $this->networking, ]; @@ -181,6 +194,17 @@ class performance_measure implements behat_app_listener { "); } + /** + * Record how many database queries have been logged so far. + */ + private function startDatabaseCount(): void { + try { + $this->databaseStart = $this->driver->evaluateScript('dbProvider.queryLogs.length') ?? 0; + } catch (Exception $e) { + $this->databaseStart = 0; + } + } + /** * Flush Performance observer. */ @@ -228,6 +252,13 @@ class performance_measure implements behat_app_listener { $this->blocking = $blocking; } + /** + * Analyse database usage. + */ + private function analyseDatabaseUsage(): void { + $this->database = $this->driver->evaluateScript('dbProvider.queryLogs.length') - $this->databaseStart; + } + /** * Analyse performance logs. */ From cca8c0a53005438544d7d8bd08f3c0cf17d022b2 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 25 Jan 2022 09:33:40 +0100 Subject: [PATCH 2/2] MOBILE-3971 core: Improve config db performance --- src/core/classes/database-table.ts | 194 ++++++++++++++++++ src/core/classes/tests/database-table.test.ts | 124 +++++++++++ src/core/services/config.ts | 52 +++-- src/testing/utils.ts | 6 +- 4 files changed, 352 insertions(+), 24 deletions(-) create mode 100644 src/core/classes/database-table.ts create mode 100644 src/core/classes/tests/database-table.test.ts 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;