MOBILE-3971 core: Improve config db performance
parent
1d8f0c5a66
commit
cca8c0a530
|
@ -0,0 +1,194 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { SQLiteDB, SQLiteDBRecordValues } from './sqlitedb';
|
||||
|
||||
/**
|
||||
* Database table wrapper used to improve performance by caching all data in memory
|
||||
* for faster read operations.
|
||||
*/
|
||||
export abstract class CoreDatabaseTable<
|
||||
DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues,
|
||||
PrimaryKeyColumns extends keyof DBRecord = 'id',
|
||||
PrimaryKey extends GetPrimaryKey<DBRecord, PrimaryKeyColumns> = GetPrimaryKey<DBRecord, PrimaryKeyColumns>
|
||||
> {
|
||||
|
||||
/**
|
||||
* Create an instance.
|
||||
*
|
||||
* @param db Database connection.
|
||||
* @returns Instance.
|
||||
*/
|
||||
static async create<This extends AnyCoreDatabaseTable>(this: CoreDatabaseTableConstructor<This>, db: SQLiteDB): Promise<This> {
|
||||
const instance = new this(db);
|
||||
|
||||
await instance.initialize();
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
protected db: SQLiteDB;
|
||||
protected data: Record<string, DBRecord>;
|
||||
protected primaryKeys: string[] = ['id'];
|
||||
|
||||
constructor(db: SQLiteDB) {
|
||||
this.db = db;
|
||||
this.data = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a record matching the given conditions.
|
||||
*
|
||||
* @param conditions Matching conditions.
|
||||
* @returns Database record.
|
||||
*/
|
||||
find(conditions: Partial<DBRecord>): DBRecord | null {
|
||||
return Object.values(this.data).find(record => this.recordMatches(record, conditions)) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a record by its primary key.
|
||||
*
|
||||
* @param primaryKey Primary key.
|
||||
* @returns Database record.
|
||||
*/
|
||||
findByPrimaryKey(primaryKey: PrimaryKey): DBRecord | null {
|
||||
return this.data[this.serializePrimaryKey(primaryKey)] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new record.
|
||||
*
|
||||
* @param record Database record.
|
||||
*/
|
||||
async insert(record: DBRecord): Promise<void> {
|
||||
await this.db.insertRecord(this.table, record);
|
||||
|
||||
const primaryKey = this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record));
|
||||
|
||||
this.data[primaryKey] = record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete records matching the given conditions.
|
||||
*
|
||||
* @param conditions Matching conditions. If this argument is missing, all records will be deleted.
|
||||
*/
|
||||
async delete(conditions?: Partial<DBRecord>): Promise<void> {
|
||||
if (!conditions) {
|
||||
await this.db.deleteRecords(this.table);
|
||||
|
||||
this.data = {};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.db.deleteRecords(this.table, conditions);
|
||||
|
||||
Object.entries(this.data).forEach(([id, record]) => {
|
||||
if (!this.recordMatches(record, conditions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete this.data[id];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single record identified by its primary key.
|
||||
*
|
||||
* @param primaryKey Record primary key.
|
||||
*/
|
||||
async deleteByPrimaryKey(primaryKey: PrimaryKey): Promise<void> {
|
||||
await this.db.deleteRecords(this.table, primaryKey);
|
||||
|
||||
delete this.data[this.serializePrimaryKey(primaryKey)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Database table name.
|
||||
*/
|
||||
protected abstract get table(): string;
|
||||
|
||||
/**
|
||||
* Initialize object by getting the current state of the database table.
|
||||
*/
|
||||
protected async initialize(): Promise<void> {
|
||||
const records = await this.db.getRecords<DBRecord>(this.table);
|
||||
|
||||
this.data = records.reduce((data, record) => {
|
||||
const primaryKey = this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record));
|
||||
|
||||
data[primaryKey] = record;
|
||||
|
||||
return data;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an object with the columns representing the primary key of a database record.
|
||||
*
|
||||
* @param record Database record.
|
||||
* @returns Primary key column-value pairs.
|
||||
*/
|
||||
protected getPrimaryKeyFromRecord(record: DBRecord): PrimaryKey {
|
||||
return this.primaryKeys.reduce((primaryKey, column) => {
|
||||
primaryKey[column] = record[column];
|
||||
|
||||
return primaryKey;
|
||||
}, {} as Record<PrimaryKeyColumns, unknown>) as PrimaryKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a primary key with a string representation.
|
||||
*
|
||||
* @param primaryKey Database record primary key.
|
||||
* @returns Serialized primary key.
|
||||
*/
|
||||
protected serializePrimaryKey(primaryKey: PrimaryKey): string {
|
||||
return Object.values(primaryKey).map(value => String(value)).join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a given record matches the given conditions.
|
||||
*
|
||||
* @param record Database record.
|
||||
* @param conditions Conditions.
|
||||
* @returns Whether the record matches the conditions.
|
||||
*/
|
||||
protected recordMatches(record: DBRecord, conditions: Partial<DBRecord>): boolean {
|
||||
return !Object.entries(conditions).some(([column, value]) => record[column] !== value);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic type to match against any concrete database table type.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyCoreDatabaseTable = CoreDatabaseTable<SQLiteDBRecordValues, string, Record<string, any>>;
|
||||
|
||||
/**
|
||||
* Database table constructor.
|
||||
*/
|
||||
type CoreDatabaseTableConstructor<T extends AnyCoreDatabaseTable> = {
|
||||
new (db: SQLiteDB): T;
|
||||
};
|
||||
|
||||
/**
|
||||
* Infer primary key type from database record and columns types.
|
||||
*/
|
||||
type GetPrimaryKey<DBRecord extends SQLiteDBRecordValues, PrimaryKeyColumns extends keyof DBRecord> = {
|
||||
[column in PrimaryKeyColumns]: DBRecord[column];
|
||||
};
|
|
@ -0,0 +1,124 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { mock } from '@/testing/utils';
|
||||
import { CoreDatabaseTable } from '@classes/database-table';
|
||||
import { SQLiteDB, SQLiteDBRecordValues } from '@classes/sqlitedb';
|
||||
|
||||
interface User extends SQLiteDBRecordValues {
|
||||
id: number;
|
||||
name: string;
|
||||
surname: string;
|
||||
}
|
||||
|
||||
class UsersTable extends CoreDatabaseTable<User> {
|
||||
|
||||
protected table = 'users';
|
||||
|
||||
}
|
||||
|
||||
describe('CoreDatabaseTable', () => {
|
||||
|
||||
let records: User[];
|
||||
let db: SQLiteDB;
|
||||
|
||||
beforeEach(() => {
|
||||
records = [];
|
||||
db = mock<SQLiteDB>({
|
||||
getRecords: async <T>() => records as unknown as T[],
|
||||
deleteRecords: async () => 0,
|
||||
insertRecord: async () => 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads all records on create', async () => {
|
||||
await UsersTable.create(db);
|
||||
|
||||
expect(db.getRecords).toHaveBeenCalledWith('users');
|
||||
});
|
||||
|
||||
it('finds items', async () => {
|
||||
const john = { id: 1, name: 'John', surname: 'Doe' };
|
||||
const amy = { id: 2, name: 'Amy', surname: 'Doe' };
|
||||
|
||||
records.push(john);
|
||||
records.push(amy);
|
||||
|
||||
const table = await UsersTable.create(db);
|
||||
|
||||
expect(table.findByPrimaryKey({ id: 1 })).toEqual(john);
|
||||
expect(table.findByPrimaryKey({ id: 2 })).toEqual(amy);
|
||||
expect(table.find({ surname: 'Doe', name: 'John' })).toEqual(john);
|
||||
expect(table.find({ surname: 'Doe', name: 'Amy' })).toEqual(amy);
|
||||
});
|
||||
|
||||
it('inserts items', async () => {
|
||||
// Arrange.
|
||||
const john = { id: 1, name: 'John', surname: 'Doe' };
|
||||
|
||||
// Act.
|
||||
const table = await UsersTable.create(db);
|
||||
|
||||
await table.insert(john);
|
||||
|
||||
// Assert.
|
||||
expect(db.insertRecord).toHaveBeenCalledWith('users', john);
|
||||
|
||||
expect(table.findByPrimaryKey({ id: 1 })).toEqual(john);
|
||||
});
|
||||
|
||||
it('deletes items', async () => {
|
||||
// Arrange.
|
||||
const john = { id: 1, name: 'John', surname: 'Doe' };
|
||||
const amy = { id: 2, name: 'Amy', surname: 'Doe' };
|
||||
const jane = { id: 3, name: 'Jane', surname: 'Smith' };
|
||||
|
||||
records.push(john);
|
||||
records.push(amy);
|
||||
records.push(jane);
|
||||
|
||||
// Act.
|
||||
const table = await UsersTable.create(db);
|
||||
|
||||
await table.delete({ surname: 'Doe' });
|
||||
|
||||
// Assert.
|
||||
expect(db.deleteRecords).toHaveBeenCalledWith('users', { surname: 'Doe' });
|
||||
|
||||
expect(table.findByPrimaryKey({ id: 1 })).toBeNull();
|
||||
expect(table.findByPrimaryKey({ id: 2 })).toBeNull();
|
||||
expect(table.findByPrimaryKey({ id: 3 })).toEqual(jane);
|
||||
});
|
||||
|
||||
it('deletes items by primary key', async () => {
|
||||
// Arrange.
|
||||
const john = { id: 1, name: 'John', surname: 'Doe' };
|
||||
const amy = { id: 2, name: 'Amy', surname: 'Doe' };
|
||||
|
||||
records.push(john);
|
||||
records.push(amy);
|
||||
|
||||
// Act.
|
||||
const table = await UsersTable.create(db);
|
||||
|
||||
await table.deleteByPrimaryKey({ id: 1 });
|
||||
|
||||
// Assert.
|
||||
expect(db.deleteRecords).toHaveBeenCalledWith('users', { id: 1 });
|
||||
|
||||
expect(table.findByPrimaryKey({ id: 1 })).toBeNull();
|
||||
expect(table.findByPrimaryKey({ id: 2 })).toEqual(amy);
|
||||
});
|
||||
|
||||
});
|
|
@ -15,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<SQLiteDB>;
|
||||
protected resolveAppDB!: (appDB: SQLiteDB) => void;
|
||||
protected dbTable: CorePromisedValue<CoreConfigTable>;
|
||||
|
||||
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<void> {
|
||||
const db = await this.appDB;
|
||||
const table = await this.dbTable;
|
||||
|
||||
await db.deleteRecords(CONFIG_TABLE_NAME, { name });
|
||||
await table.deleteByPrimaryKey({ name });
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -66,19 +69,18 @@ export class CoreConfigProvider {
|
|||
* @return Resolves upon success along with the config data. Reject on failure.
|
||||
*/
|
||||
async get<T>(name: string, defaultValue?: T): Promise<T> {
|
||||
const db = await this.appDB;
|
||||
const table = await this.dbTable;
|
||||
const record = table.findByPrimaryKey({ name });
|
||||
|
||||
try {
|
||||
const entry = await db.getRecord<ConfigDBEntry>(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<void> {
|
||||
const db = await this.appDB;
|
||||
const table = await this.dbTable;
|
||||
|
||||
await db.insertRecord(CONFIG_TABLE_NAME, { name, value });
|
||||
await table.insert({ name, value });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const CoreConfig = makeSingleton(CoreConfigProvider);
|
||||
|
||||
/**
|
||||
* Config database table.
|
||||
*/
|
||||
class CoreConfigTable extends CoreDatabaseTable<ConfigDBEntry, 'name'> {
|
||||
|
||||
protected table = CONFIG_TABLE_NAME;
|
||||
protected primaryKeys = ['name'];
|
||||
|
||||
}
|
||||
|
|
|
@ -133,12 +133,10 @@ export type WrapperComponentFixture<T> = ComponentFixture<WrapperComponent<T>>;
|
|||
* @param overrides Object with the properties or methods to override, or a list of methods to override with an empty function.
|
||||
* @return Mock instance.
|
||||
*/
|
||||
export function mock<T>(instance?: T, overrides?: string[] | Record<string, unknown>): T;
|
||||
export function mock<T>(instance?: Partial<T>, overrides?: string[] | Record<string, unknown>): Partial<T>;
|
||||
export function mock<T>(
|
||||
instance: T | Partial<T> = {},
|
||||
overrides: string[] | Record<string, unknown> = {},
|
||||
): T | Partial<T> {
|
||||
): 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<T>(
|
|||
}
|
||||
}
|
||||
|
||||
return instance;
|
||||
return instance as T;
|
||||
}
|
||||
|
||||
export function mockSingleton<T>(singletonClass: CoreSingletonProxy<T>, instance: T): T;
|
||||
|
|
Loading…
Reference in New Issue