Merge pull request #3091 from NoelDeMartin/MOBILE-3977

MOBILE-3977: Database optimization strategies
main
Dani Palou 2022-02-08 11:06:47 +01:00 committed by GitHub
commit 858cf07f73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1528 additions and 412 deletions

View File

@ -271,6 +271,7 @@ testsConfig['rules']['padded-blocks'] = [
switches: 'never', switches: 'never',
}, },
]; ];
testsConfig['rules']['jest/expect-expect'] = 'off';
testsConfig['plugins'].push('jest'); testsConfig['plugins'].push('jest');
testsConfig['extends'].push('plugin:jest/recommended'); testsConfig['extends'].push('plugin:jest/recommended');

View File

@ -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 // Display data
console.table(performanceMeasures); console.table(sortedPerformanceMeasures);

View File

@ -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<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];
};

View File

@ -0,0 +1,219 @@
// (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 { CoreConstants } from '@/core/constants';
import { asyncInstance } from '@/core/utils/async-instance';
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';
/**
* 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<DBRecord, PrimaryKeyColumn> = GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn>
> extends CoreDatabaseTable<DBRecord, PrimaryKeyColumn, PrimaryKey> {
protected config: CoreDatabaseConfiguration;
protected target = asyncInstance<CoreDatabaseTable<DBRecord, PrimaryKeyColumn>>();
protected environmentObserver?: CoreEventObserver;
constructor(
config: Partial<CoreDatabaseConfiguration>,
database: SQLiteDB,
tableName: string,
primaryKeyColumns?: PrimaryKeyColumn[],
) {
super(database, tableName, primaryKeyColumns);
this.config = { ...this.getConfigDefaults(), ...config };
}
/**
* @inheritdoc
*/
async initialize(): Promise<void> {
this.environmentObserver = CoreEvents.on(CoreConfigProvider.ENVIRONMENT_UPDATED, () => this.updateTarget());
await this.updateTarget();
}
/**
* @inheritdoc
*/
async destroy(): Promise<void> {
this.environmentObserver?.off();
}
/**
* @inheritdoc
*/
async getMany(conditions?: Partial<DBRecord>): Promise<DBRecord[]> {
return this.target.getMany(conditions);
}
/**
* @inheritdoc
*/
async getOne(conditions: Partial<DBRecord>): Promise<DBRecord> {
return this.target.getOne(conditions);
}
/**
* @inheritdoc
*/
async getOneByPrimaryKey(primaryKey: PrimaryKey): Promise<DBRecord> {
return this.target.getOneByPrimaryKey(primaryKey);
}
/**
* @inheritdoc
*/
async reduce<T>(reducer: CoreDatabaseReducer<DBRecord, T>, conditions?: CoreDatabaseConditions<DBRecord>): Promise<T> {
return this.target.reduce<T>(reducer, conditions);
}
/**
* @inheritdoc
*/
async insert(record: DBRecord): Promise<void> {
return this.target.insert(record);
}
/**
* @inheritdoc
*/
async update(updates: Partial<DBRecord>, conditions?: Partial<DBRecord>): Promise<void> {
return this.target.update(updates, conditions);
}
/**
* @inheritdoc
*/
async updateWhere(updates: Partial<DBRecord>, conditions: CoreDatabaseConditions<DBRecord>): Promise<void> {
return this.target.updateWhere(updates, conditions);
}
/**
* @inheritdoc
*/
async delete(conditions?: Partial<DBRecord>): Promise<void> {
return this.target.delete(conditions);
}
/**
* @inheritdoc
*/
async deleteByPrimaryKey(primaryKey: PrimaryKey): Promise<void> {
return this.target.deleteByPrimaryKey(primaryKey);
}
/**
* Get default configuration values.
*
* @returns Config defaults.
*/
protected getConfigDefaults(): CoreDatabaseConfiguration {
return {
cachingStrategy: CoreDatabaseCachingStrategy.None,
debug: false,
};
}
/**
* 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<void> {
const oldTarget = this.target.instance;
const newTarget = this.createTarget();
if (oldTarget) {
await oldTarget.destroy();
this.target.resetInstance();
}
await newTarget.initialize();
this.target.setInstance(newTarget);
}
/**
* Create proxy target.
*
* @returns Target instance.
*/
protected createTarget(): CoreDatabaseTable<DBRecord, PrimaryKeyColumn> {
const config = this.getRuntimeConfig();
const table = this.createTable(config.cachingStrategy);
return config.debug ? new CoreDebugDatabaseTable(table) : table;
}
/**
* Create a database table using the given caching strategy.
*
* @param cachingStrategy Caching strategy.
* @returns Database table.
*/
protected createTable(cachingStrategy: CoreDatabaseCachingStrategy): CoreDatabaseTable<DBRecord, PrimaryKeyColumn> {
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);
}
}
}
/**
* Database proxy configuration.
*/
export interface CoreDatabaseConfiguration {
cachingStrategy: CoreDatabaseCachingStrategy;
debug: boolean;
}
/**
* Database caching strategies.
*/
export enum CoreDatabaseCachingStrategy {
Eager = 'eager',
Lazy = 'lazy',
None = 'none',
}

View File

@ -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<DBRecord, PrimaryKeyColumn> = GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn>
> {
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<void> {
// Nothing to initialize by default, override this method if necessary.
}
/**
* Destroy.
*/
async destroy(): Promise<void> {
// 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.
*/
getMany(conditions?: Partial<DBRecord>): Promise<DBRecord[]> {
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.
*/
getOne(conditions: Partial<DBRecord>): Promise<DBRecord> {
return this.database.getRecord<DBRecord>(this.tableName, conditions);
}
/**
* Find one record by its primary key.
*
* @param primaryKey Primary key.
* @returns Database record.
*/
getOneByPrimaryKey(primaryKey: PrimaryKey): Promise<DBRecord> {
return this.database.getRecord<DBRecord>(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<T>(reducer: CoreDatabaseReducer<DBRecord, T>, conditions?: CoreDatabaseConditions<DBRecord>): Promise<T> {
return this.database.getFieldSql(
`SELECT ${reducer.sql} FROM ${this.tableName} ${conditions?.sql ?? ''}`,
conditions?.sqlParams,
) as unknown as Promise<T>;
}
/**
* Insert a new record.
*
* @param record Database record.
*/
async insert(record: DBRecord): Promise<void> {
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<DBRecord>, conditions?: Partial<DBRecord>): Promise<void> {
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<DBRecord>, conditions: CoreDatabaseConditions<DBRecord>): Promise<void> {
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<DBRecord>): Promise<void> {
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<void> {
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<PrimaryKeyColumn, unknown>) 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<DBRecord>): 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<DBRecord extends SQLiteDBRecordValues, PrimaryKeyColumn extends keyof DBRecord> = {
[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<DBRecord, T> = {
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<DBRecord> = {
sql: string;
sqlParams?: SQLiteDBRecordValue[];
js: (record: DBRecord) => boolean;
};

View File

@ -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<DBRecord, PrimaryKeyColumn> = GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn>
> extends CoreDatabaseTable<DBRecord, PrimaryKeyColumn, PrimaryKey> {
protected target: CoreDatabaseTable<DBRecord, PrimaryKeyColumn, PrimaryKey>;
protected logger: CoreLogger;
constructor(target: CoreDatabaseTable<DBRecord, PrimaryKeyColumn, PrimaryKey>) {
super(target.getDatabase(), target.getTableName(), target.getPrimaryKeyColumns());
this.target = target;
this.logger = CoreLogger.getInstance(`CoreDatabase[${this.tableName}]`);
}
/**
* @inheritdoc
*/
initialize(): Promise<void> {
this.logger.log('initialize');
return this.target.initialize();
}
/**
* @inheritdoc
*/
destroy(): Promise<void> {
this.logger.log('destroy');
return this.target.destroy();
}
/**
* @inheritdoc
*/
getMany(conditions?: Partial<DBRecord>): Promise<DBRecord[]> {
this.logger.log('getMany', conditions);
return this.target.getMany(conditions);
}
/**
* @inheritdoc
*/
getOne(conditions: Partial<DBRecord>): Promise<DBRecord> {
this.logger.log('getOne', conditions);
return this.target.getOne(conditions);
}
/**
* @inheritdoc
*/
getOneByPrimaryKey(primaryKey: PrimaryKey): Promise<DBRecord> {
this.logger.log('findByPrimaryKey', primaryKey);
return this.target.getOneByPrimaryKey(primaryKey);
}
/**
* @inheritdoc
*/
reduce<T>(reducer: CoreDatabaseReducer<DBRecord, T>, conditions?: CoreDatabaseConditions<DBRecord>): Promise<T> {
this.logger.log('reduce', reducer, conditions);
return this.target.reduce<T>(reducer, conditions);
}
/**
* @inheritdoc
*/
insert(record: DBRecord): Promise<void> {
this.logger.log('insert', record);
return this.target.insert(record);
}
/**
* @inheritdoc
*/
update(updates: Partial<DBRecord>, conditions?: Partial<DBRecord>): Promise<void> {
this.logger.log('update', updates, conditions);
return this.target.update(updates, conditions);
}
/**
* @inheritdoc
*/
updateWhere(updates: Partial<DBRecord>, conditions: CoreDatabaseConditions<DBRecord>): Promise<void> {
this.logger.log('updateWhere', updates, conditions);
return this.target.updateWhere(updates, conditions);
}
/**
* @inheritdoc
*/
delete(conditions?: Partial<DBRecord>): Promise<void> {
this.logger.log('delete', conditions);
return this.target.delete(conditions);
}
/**
* @inheritdoc
*/
deleteByPrimaryKey(primaryKey: PrimaryKey): Promise<void> {
this.logger.log('deleteByPrimaryKey', primaryKey);
return this.target.deleteByPrimaryKey(primaryKey);
}
}

View File

@ -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<DBRecord, PrimaryKeyColumn> = GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn>
> extends CoreDatabaseTable<DBRecord, PrimaryKeyColumn, PrimaryKey> {
protected records: Record<string, DBRecord> = {};
/**
* @inheritdoc
*/
async initialize(): Promise<void> {
const records = await super.getMany();
this.records = records.reduce((data, record) => {
const primaryKey = this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record));
data[primaryKey] = record;
return data;
}, {});
}
/**
* @inheritdoc
*/
async getMany(conditions?: Partial<DBRecord>): Promise<DBRecord[]> {
const records = Object.values(this.records);
return conditions
? records.filter(record => this.recordMatches(record, conditions))
: records;
}
/**
* @inheritdoc
*/
async getOne(conditions: Partial<DBRecord>): Promise<DBRecord> {
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 getOneByPrimaryKey(primaryKey: PrimaryKey): Promise<DBRecord> {
const record = this.records[this.serializePrimaryKey(primaryKey)] ?? null;
if (record === null) {
throw new CoreError('No records found.');
}
return record;
}
/**
* @inheritdoc
*/
async reduce<T>(reducer: CoreDatabaseReducer<DBRecord, T>, conditions?: CoreDatabaseConditions<DBRecord>): Promise<T> {
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<void> {
await super.insert(record);
const primaryKey = this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record));
this.records[primaryKey] = record;
}
/**
* @inheritdoc
*/
async update(updates: Partial<DBRecord>, conditions?: Partial<DBRecord>): Promise<void> {
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<DBRecord>, conditions: CoreDatabaseConditions<DBRecord>): Promise<void> {
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<DBRecord>): Promise<void> {
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<void> {
await super.deleteByPrimaryKey(primaryKey);
delete this.records[this.serializePrimaryKey(primaryKey)];
}
}

View File

@ -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<DBRecord, PrimaryKeyColumn> = GetDBRecordPrimaryKey<DBRecord, PrimaryKeyColumn>
> extends CoreDatabaseTable<DBRecord, PrimaryKeyColumn, PrimaryKey> {
protected records: Record<string, DBRecord | null> = {};
/**
* @inheritdoc
*/
async getOne(conditions: Partial<DBRecord>): Promise<DBRecord> {
let record: DBRecord | null =
Object.values(this.records).find(record => record && this.recordMatches(record, conditions)) ?? null;
if (!record) {
record = await super.getOne(conditions);
this.records[this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record))] = record;
}
return record;
}
/**
* @inheritdoc
*/
async getOneByPrimaryKey(primaryKey: PrimaryKey): Promise<DBRecord> {
const serializePrimaryKey = this.serializePrimaryKey(primaryKey);
if (!(serializePrimaryKey in this.records)) {
try {
const record = await super.getOneByPrimaryKey(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<void> {
await super.insert(record);
this.records[this.serializePrimaryKey(this.getPrimaryKeyFromRecord(record))] = record;
}
/**
* @inheritdoc
*/
async update(updates: Partial<DBRecord>, conditions?: Partial<DBRecord>): Promise<void> {
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<DBRecord>, conditions: CoreDatabaseConditions<DBRecord>): Promise<void> {
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<DBRecord>): Promise<void> {
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<void> {
await super.deleteByPrimaryKey(primaryKey);
this.records[this.serializePrimaryKey(primaryKey)] = null;
}
}

View File

@ -134,6 +134,16 @@ export class CorePromisedValue<T = unknown> implements Promise<T> {
this._reject(reason); this._reject(reason);
} }
/**
* Reset status and value.
*/
reset(): void {
delete this._resolvedValue;
delete this._rejectedReason;
this.initPromise();
}
/** /**
* Initialize the promise and the callbacks. * Initialize the promise and the callbacks.
*/ */

View File

@ -41,6 +41,10 @@ import { CoreLogger } from '@singletons/logger';
import { Translate } from '@singletons'; import { Translate } from '@singletons';
import { CoreIonLoadingElement } from './ion-loading'; import { CoreIonLoadingElement } from './ion-loading';
import { CoreLang } from '@services/lang'; 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. * QR Code type enumeration.
@ -103,6 +107,7 @@ export class CoreSite {
// Rest of variables. // Rest of variables.
protected logger: CoreLogger; protected logger: CoreLogger;
protected db?: SQLiteDB; protected db?: SQLiteDB;
protected cacheTable: AsyncInstance<CoreDatabaseTable<CoreSiteWSCacheRecord>>;
protected cleanUnicode = false; protected cleanUnicode = false;
protected lastAutoLogin = 0; protected lastAutoLogin = 0;
protected offlineDisabled = false; protected offlineDisabled = false;
@ -136,6 +141,11 @@ export class CoreSite {
) { ) {
this.logger = CoreLogger.getInstance('CoreSite'); this.logger = CoreLogger.getInstance('CoreSite');
this.siteUrl = CoreUrlUtils.removeUrlParams(this.siteUrl); // Make sure the URL doesn't have params. this.siteUrl = CoreUrlUtils.removeUrlParams(this.siteUrl); // Make sure the URL doesn't have params.
this.cacheTable = asyncInstance(() => CoreSites.getSiteTable(CoreSite.WS_CACHE_TABLE, {
siteId: this.getId(),
database: this.getDb(),
config: { cachingStrategy: CoreDatabaseCachingStrategy.None },
}));
this.setInfo(infos); this.setInfo(infos);
this.calculateOfflineDisabled(); this.calculateOfflineDisabled();
@ -920,8 +930,7 @@ export class CoreSite {
preSets: CoreSiteWSPreSets, preSets: CoreSiteWSPreSets,
emergency?: boolean, emergency?: boolean,
): Promise<T> { ): Promise<T> {
const db = this.db; if (!this.db || !preSets.getFromCache) {
if (!db || !preSets.getFromCache) {
throw new CoreError('Get from cache is disabled.'); throw new CoreError('Get from cache is disabled.');
} }
@ -929,11 +938,11 @@ export class CoreSite {
let entry: CoreSiteWSCacheRecord | undefined; let entry: CoreSiteWSCacheRecord | undefined;
if (preSets.getCacheUsingCacheKey || (emergency && preSets.getEmergencyCacheUsingCacheKey)) { if (preSets.getCacheUsingCacheKey || (emergency && preSets.getEmergencyCacheUsingCacheKey)) {
const entries = await db.getRecords<CoreSiteWSCacheRecord>(CoreSite.WS_CACHE_TABLE, { key: preSets.cacheKey }); const entries = await this.cacheTable.getMany({ key: preSets.cacheKey });
if (!entries.length) { if (!entries.length) {
// Cache key not found, get by params sent. // Cache key not found, get by params sent.
entry = await db.getRecord(CoreSite.WS_CACHE_TABLE, { id }); entry = await this.cacheTable.getOneByPrimaryKey({ id });
} else { } else {
if (entries.length > 1) { if (entries.length > 1) {
// More than one entry found. Search the one with same ID as this call. // More than one entry found. Search the one with same ID as this call.
@ -945,7 +954,7 @@ export class CoreSite {
} }
} }
} else { } else {
entry = await db.getRecord(CoreSite.WS_CACHE_TABLE, { id }); entry = await this.cacheTable.getOneByPrimaryKey({ id });
} }
if (entry === undefined) { if (entry === undefined) {
@ -996,12 +1005,18 @@ export class CoreSite {
extraClause = ' AND componentId = ?'; extraClause = ' AND componentId = ?';
} }
const size = <number> await this.getDb().getFieldSql( return this.cacheTable.reduce(
'SELECT SUM(length(data)) FROM ' + CoreSite.WS_CACHE_TABLE + ' WHERE component = ?' + extraClause, {
params, 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 +1030,6 @@ export class CoreSite {
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
protected async saveToCache(method: string, data: any, response: any, preSets: CoreSiteWSPreSets): Promise<void> { protected async saveToCache(method: string, data: any, response: any, preSets: CoreSiteWSPreSets): Promise<void> {
if (!this.db) {
throw new CoreError('Site DB not initialized.');
}
if (preSets.uniqueCacheKey) { if (preSets.uniqueCacheKey) {
// Cache key must be unique, delete all entries with same cache key. // Cache key must be unique, delete all entries with same cache key.
await CoreUtils.ignoreErrors(this.deleteFromCache(method, data, preSets, true)); await CoreUtils.ignoreErrors(this.deleteFromCache(method, data, preSets, true));
@ -1044,7 +1055,7 @@ export class CoreSite {
} }
} }
await this.db.insertRecord(CoreSite.WS_CACHE_TABLE, entry); await this.cacheTable.insert(entry);
} }
/** /**
@ -1058,16 +1069,12 @@ export class CoreSite {
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
protected async deleteFromCache(method: string, data: any, preSets: CoreSiteWSPreSets, allCacheKey?: boolean): Promise<void> { protected async deleteFromCache(method: string, data: any, preSets: CoreSiteWSPreSets, allCacheKey?: boolean): Promise<void> {
if (!this.db) {
throw new CoreError('Site DB not initialized.');
}
const id = this.getCacheId(method, data); const id = this.getCacheId(method, data);
if (allCacheKey) { if (allCacheKey) {
await this.db.deleteRecords(CoreSite.WS_CACHE_TABLE, { key: preSets.cacheKey }); await this.cacheTable.delete({ key: preSets.cacheKey });
} else { } else {
await this.db.deleteRecords(CoreSite.WS_CACHE_TABLE, { id }); await this.cacheTable.deleteByPrimaryKey({ id });
} }
} }
@ -1084,18 +1091,13 @@ export class CoreSite {
return; return;
} }
if (!this.db) { const params = { component };
throw new CoreError('Site DB not initialized');
}
const params = {
component,
};
if (componentId) { if (componentId) {
params['componentId'] = componentId; params['componentId'] = componentId;
} }
await this.db.deleteRecords(CoreSite.WS_CACHE_TABLE, params); await this.cacheTable.delete(params);
} }
/* /*
@ -1127,14 +1129,10 @@ export class CoreSite {
* @return Promise resolved when the cache entries are invalidated. * @return Promise resolved when the cache entries are invalidated.
*/ */
async invalidateWsCache(): Promise<void> { async invalidateWsCache(): Promise<void> {
if (!this.db) {
throw new CoreError('Site DB not initialized');
}
this.logger.debug('Invalidate all the cache for site: ' + this.id); this.logger.debug('Invalidate all the cache for site: ' + this.id);
try { try {
await this.db.updateRecords(CoreSite.WS_CACHE_TABLE, { expirationTime: 0 }); await this.cacheTable.update({ expirationTime: 0 });
} finally { } finally {
CoreEvents.trigger(CoreEvents.WS_CACHE_INVALIDATED, {}, this.getId()); CoreEvents.trigger(CoreEvents.WS_CACHE_INVALIDATED, {}, this.getId());
} }
@ -1147,16 +1145,13 @@ export class CoreSite {
* @return Promise resolved when the cache entries are invalidated. * @return Promise resolved when the cache entries are invalidated.
*/ */
async invalidateWsCacheForKey(key: string): Promise<void> { async invalidateWsCacheForKey(key: string): Promise<void> {
if (!this.db) {
throw new CoreError('Site DB not initialized');
}
if (!key) { if (!key) {
return; return;
} }
this.logger.debug('Invalidate cache for key: ' + key); this.logger.debug('Invalidate cache for key: ' + key);
await this.db.updateRecords(CoreSite.WS_CACHE_TABLE, { expirationTime: 0 }, { key }); await this.cacheTable.update({ expirationTime: 0 }, { key });
} }
/** /**
@ -1184,18 +1179,17 @@ export class CoreSite {
* @return Promise resolved when the cache entries are invalidated. * @return Promise resolved when the cache entries are invalidated.
*/ */
async invalidateWsCacheForKeyStartingWith(key: string): Promise<void> { async invalidateWsCacheForKeyStartingWith(key: string): Promise<void> {
if (!this.db) {
throw new CoreError('Site DB not initialized');
}
if (!key) { if (!key) {
return; return;
} }
this.logger.debug('Invalidate cache for key starting with: ' + key); this.logger.debug('Invalidate cache for key starting with: ' + key);
const sql = 'UPDATE ' + CoreSite.WS_CACHE_TABLE + ' SET expirationTime=0 WHERE key LIKE ?'; await this.cacheTable.updateWhere({ expirationTime: 0 }, {
sql: 'key LIKE ?',
await this.db.execute(sql, [key + '%']); sqlParams: [key],
js: record => !!record.key?.startsWith(key),
});
} }
/** /**
@ -1270,9 +1264,11 @@ export class CoreSite {
* @return Promise resolved with the total size of all data in the cache table (bytes) * @return Promise resolved with the total size of all data in the cache table (bytes)
*/ */
async getCacheUsage(): Promise<number> { async getCacheUsage(): Promise<number> {
const size = <number> await this.getDb().getFieldSql('SELECT SUM(length(data)) FROM ' + CoreSite.WS_CACHE_TABLE); return this.cacheTable.reduce({
sql: 'SUM(length(data))',
return size; js: (size, record) => size + record.data.length,
jsInitialValue: 0,
});
} }
/** /**

View File

@ -1206,4 +1206,4 @@ export type SQLiteDBQueryParams = {
params: SQLiteDBRecordValue[]; params: SQLiteDBRecordValue[];
}; };
type SQLiteDBRecordValue = number | string; export type SQLiteDBRecordValue = number | string;

View File

@ -13,7 +13,12 @@
// limitations under the License. // limitations under the License.
import { mock } from '@/testing/utils'; import { mock } from '@/testing/utils';
import { CoreDatabaseTable } from '@classes/database-table'; import { CoreDatabaseTable } from '@classes/database/database-table';
import {
CoreDatabaseCachingStrategy,
CoreDatabaseConfiguration,
CoreDatabaseTableProxy,
} from '@classes/database/database-table-proxy';
import { SQLiteDB, SQLiteDBRecordValues } from '@classes/sqlitedb'; import { SQLiteDB, SQLiteDBRecordValues } from '@classes/sqlitedb';
interface User extends SQLiteDBRecordValues { interface User extends SQLiteDBRecordValues {
@ -22,103 +27,197 @@ interface User extends SQLiteDBRecordValues {
surname: string; surname: string;
} }
class UsersTable extends CoreDatabaseTable<User> { function userMatches(user: User, conditions: Partial<User>) {
return !Object.entries(conditions).some(([column, value]) => user[column] !== value);
protected table = 'users';
} }
describe('CoreDatabaseTable', () => { function prepareStubs(config: Partial<CoreDatabaseConfiguration> = {}): [User[], SQLiteDB, CoreDatabaseTable<User>] {
const records: User[] = [];
const database = mock<SQLiteDB>({
getRecord: async <T>(_, conditions) => {
const record = records.find(record => userMatches(record, conditions));
if (!record) {
throw new Error();
}
return record as unknown as T;
},
getRecords: async <T>(_, conditions) => records.filter(record => userMatches(record, conditions)) as unknown as T[],
getAllRecords: async <T>() => 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<User>(config, database, 'users');
return [records, database, table];
}
async function testFindItems(records: User[], table: CoreDatabaseTable<User>) {
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.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<User>) {
// 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.getOneByPrimaryKey({ id: 1 })).resolves.toEqual(john);
}
async function testDeleteItems(records: User[], database: SQLiteDB, table: CoreDatabaseTable<User>) {
// 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.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<User>) {
// 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.getOneByPrimaryKey({ id: 1 })).rejects.toThrow();
await expect(table.getOneByPrimaryKey({ id: 2 })).resolves.toEqual(amy);
}
describe('CoreDatabaseTable with eager caching', () => {
let records: User[]; let records: User[];
let db: SQLiteDB; let database: SQLiteDB;
let table: CoreDatabaseTable<User>;
beforeEach(() => { beforeEach(() => [records, database, table] = prepareStubs({ cachingStrategy: CoreDatabaseCachingStrategy.Eager }));
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 () => { it('reads all records on initialization', async () => {
await UsersTable.create(db); await table.initialize();
expect(db.getRecords).toHaveBeenCalledWith('users'); expect(database.getAllRecords).toHaveBeenCalledWith('users');
}); });
it('finds items', async () => { it('finds items', async () => {
const john = { id: 1, name: 'John', surname: 'Doe' }; await testFindItems(records, table);
const amy = { id: 2, name: 'Amy', surname: 'Doe' };
records.push(john); expect(database.getRecord).not.toHaveBeenCalled();
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 () => { it('inserts items', () => testInsertItems(records, database, table));
// Arrange. it('deletes items', () => testDeleteItems(records, database, table));
const john = { id: 1, name: 'John', surname: 'Doe' }; it('deletes items by primary key', () => testDeleteItemsByPrimaryKey(records, database, table));
// Act. });
const table = await UsersTable.create(db);
describe('CoreDatabaseTable with lazy caching', () => {
await table.insert(john);
let records: User[];
// Assert. let database: SQLiteDB;
expect(db.insertRecord).toHaveBeenCalledWith('users', john); let table: CoreDatabaseTable<User>;
expect(table.findByPrimaryKey({ id: 1 })).toEqual(john); beforeEach(() => [records, database, table] = prepareStubs({ cachingStrategy: CoreDatabaseCachingStrategy.Lazy }));
});
it('reads no records on initialization', async () => {
it('deletes items', async () => { await table.initialize();
// Arrange.
const john = { id: 1, name: 'John', surname: 'Doe' }; expect(database.getRecords).not.toHaveBeenCalled();
const amy = { id: 2, name: 'Amy', surname: 'Doe' }; expect(database.getAllRecords).not.toHaveBeenCalled();
const jane = { id: 3, name: 'Jane', surname: 'Smith' }; });
records.push(john); it('finds items', async () => {
records.push(amy); await testFindItems(records, table);
records.push(jane);
expect(database.getRecord).toHaveBeenCalledTimes(2);
// Act. });
const table = await UsersTable.create(db);
it('inserts items', () => testInsertItems(records, database, table));
await table.delete({ surname: 'Doe' }); it('deletes items', () => testDeleteItems(records, database, table));
it('deletes items by primary key', () => testDeleteItemsByPrimaryKey(records, database, table));
// Assert.
expect(db.deleteRecords).toHaveBeenCalledWith('users', { surname: 'Doe' }); });
expect(table.findByPrimaryKey({ id: 1 })).toBeNull(); describe('CoreDatabaseTable with no caching', () => {
expect(table.findByPrimaryKey({ id: 2 })).toBeNull();
expect(table.findByPrimaryKey({ id: 3 })).toEqual(jane); let records: User[];
}); let database: SQLiteDB;
let table: CoreDatabaseTable<User>;
it('deletes items by primary key', async () => {
// Arrange. beforeEach(() => [records, database, table] = prepareStubs({ cachingStrategy: CoreDatabaseCachingStrategy.None }));
const john = { id: 1, name: 'John', surname: 'Doe' };
const amy = { id: 2, name: 'Amy', surname: 'Doe' }; it('reads no records on initialization', async () => {
await table.initialize();
records.push(john);
records.push(amy); expect(database.getRecords).not.toHaveBeenCalled();
expect(database.getAllRecords).not.toHaveBeenCalled();
// Act. });
const table = await UsersTable.create(db);
it('finds items', async () => {
await table.deleteByPrimaryKey({ id: 1 }); await testFindItems(records, table);
// Assert. expect(database.getRecord).toHaveBeenCalledTimes(4);
expect(db.deleteRecords).toHaveBeenCalledWith('users', { id: 1 }); });
expect(table.findByPrimaryKey({ id: 1 })).toBeNull(); it('inserts items', () => testInsertItems(records, database, table));
expect(table.findByPrimaryKey({ id: 2 })).toEqual(amy); it('deletes items', () => testDeleteItems(records, database, table));
}); it('deletes items by primary key', () => testDeleteItemsByPrimaryKey(records, database, table));
}); });

View File

@ -145,23 +145,6 @@ export class CoreConstants {
static readonly CONFIG = { ...envJson.config } as unknown as EnvironmentConfig; // Data parsed from config.json files. 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. 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<EnvironmentConfig>): 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 { interface EnvironmentBuild {

View File

@ -15,11 +15,13 @@
import { CoreFilepool } from '@services/filepool'; import { CoreFilepool } from '@services/filepool';
import { CoreLang } from '@services/lang'; import { CoreLang } from '@services/lang';
import { CoreLocalNotifications } from '@services/local-notifications'; import { CoreLocalNotifications } from '@services/local-notifications';
import { CoreSites } from '@services/sites';
import { CoreUpdateManager } from '@services/update-manager'; import { CoreUpdateManager } from '@services/update-manager';
export default async function(): Promise<void> { export default async function(): Promise<void> {
await Promise.all([ await Promise.all([
CoreFilepool.initialize(), CoreFilepool.initialize(),
CoreSites.initialize(),
CoreLang.initialize(), CoreLang.initialize(),
CoreLocalNotifications.initialize(), CoreLocalNotifications.initialize(),
CoreUpdateManager.initialize(), CoreUpdateManager.initialize(),

View File

@ -12,13 +12,29 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import { EnvironmentConfig } from '@/types/config';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CoreDatabaseCachingStrategy, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy';
import { CoreApp } from '@services/app'; import { CoreApp } from '@services/app';
import { APP_SCHEMA, ConfigDBEntry, CONFIG_TABLE_NAME } from '@services/database/config';
import { makeSingleton } from '@singletons'; import { makeSingleton } from '@singletons';
import { CONFIG_TABLE_NAME, APP_SCHEMA, ConfigDBEntry } from '@services/database/config'; import { CoreConstants } from '../constants';
import { CoreDatabaseTable } from '@classes/database-table'; import { CoreEvents } from '@singletons/events';
import { CorePromisedValue } from '@classes/promised-value'; import { CoreDatabaseTable } from '@classes/database/database-table';
import { asyncInstance } from '../utils/async-instance';
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. * Factory to provide access to dynamic and permanent config and settings.
@ -27,11 +43,10 @@ import { CorePromisedValue } from '@classes/promised-value';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class CoreConfigProvider { export class CoreConfigProvider {
protected dbTable: CorePromisedValue<CoreConfigTable>; static readonly ENVIRONMENT_UPDATED = 'environment_updated';
constructor() { protected table = asyncInstance<CoreDatabaseTable<ConfigDBEntry, 'name'>>();
this.dbTable = new CorePromisedValue(); protected defaultEnvironment?: EnvironmentConfig;
}
/** /**
* Initialize database. * Initialize database.
@ -43,10 +58,16 @@ export class CoreConfigProvider {
// Ignore errors. // Ignore errors.
} }
const db = CoreApp.getDB(); const table = new CoreDatabaseTableProxy<ConfigDBEntry, 'name'>(
const table = await CoreConfigTable.create(db); { cachingStrategy: CoreDatabaseCachingStrategy.Eager },
CoreApp.getDB(),
CONFIG_TABLE_NAME,
['name'],
);
this.dbTable.resolve(table); await table.initialize();
this.table.setInstance(table);
} }
/** /**
@ -56,9 +77,7 @@ export class CoreConfigProvider {
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
async delete(name: string): Promise<void> { async delete(name: string): Promise<void> {
const table = await this.dbTable; await this.table.deleteByPrimaryKey({ name });
await table.deleteByPrimaryKey({ name });
} }
/** /**
@ -69,18 +88,17 @@ export class CoreConfigProvider {
* @return Resolves upon success along with the config data. Reject on failure. * @return Resolves upon success along with the config data. Reject on failure.
*/ */
async get<T>(name: string, defaultValue?: T): Promise<T> { async get<T>(name: string, defaultValue?: T): Promise<T> {
const table = await this.dbTable; try {
const record = table.findByPrimaryKey({ name }); const record = await this.table.getOneByPrimaryKey({ name });
if (record !== null) {
return record.value; return record.value;
} } catch (error) {
if (defaultValue !== undefined) {
return defaultValue;
}
if (defaultValue !== undefined) { throw error;
return defaultValue;
} }
throw new Error(`Couldn't get config with name '${name}'`);
} }
/** /**
@ -91,21 +109,36 @@ export class CoreConfigProvider {
* @return Promise resolved when done. * @return Promise resolved when done.
*/ */
async set(name: string, value: number | string): Promise<void> { async set(name: string, value: number | string): Promise<void> {
const table = await this.dbTable; await this.table.insert({ name, value });
}
await table.insert({ name, value }); /**
* Update config with the given values.
*
* @param config Config updates.
*/
patchEnvironment(config: Partial<EnvironmentConfig>): 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); export const CoreConfig = makeSingleton(CoreConfigProvider);
/**
* Config database table.
*/
class CoreConfigTable extends CoreDatabaseTable<ConfigDBEntry, 'name'> {
protected table = CONFIG_TABLE_NAME;
protected primaryKeys = ['name'];
}

View File

@ -38,6 +38,17 @@ export class CoreDbProvider {
return CoreAppProvider.isAutomated(); 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. * Log a query.
* *

View File

@ -48,6 +48,10 @@ import {
} from '@services/database/filepool'; } from '@services/database/filepool';
import { CoreFileHelper } from './file-helper'; import { CoreFileHelper } from './file-helper';
import { CoreUrl } from '@singletons/url'; import { CoreUrl } from '@singletons/url';
import { CoreDatabaseTable } from '@classes/database/database-table';
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. * Factory for handling downloading files and retrieve downloaded files.
@ -72,9 +76,13 @@ export class CoreFilepoolProvider {
protected static readonly ERR_FS_OR_NETWORK_UNAVAILABLE = 'CoreFilepoolError:ERR_FS_OR_NETWORK_UNAVAILABLE'; 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 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))'; '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 logger: CoreLogger;
protected queueState = CoreFilepoolProvider.QUEUE_PAUSED; protected queueState = CoreFilepoolProvider.QUEUE_PAUSED;
protected urlAttributes: RegExp[] = [ protected urlAttributes: RegExp[] = [
@ -94,10 +102,20 @@ export class CoreFilepoolProvider {
// Variables for DB. // Variables for DB.
protected appDB: Promise<SQLiteDB>; protected appDB: Promise<SQLiteDB>;
protected resolveAppDB!: (appDB: SQLiteDB) => void; protected resolveAppDB!: (appDB: SQLiteDB) => void;
protected filesTables: LazyMap<AsyncInstance<CoreDatabaseTable<CoreFilepoolFileEntry, 'fileId'>>>;
constructor() { constructor() {
this.appDB = new Promise(resolve => this.resolveAppDB = resolve); this.appDB = new Promise(resolve => this.resolveAppDB = resolve);
this.logger = CoreLogger.getInstance('CoreFilepoolProvider'); this.logger = CoreLogger.getInstance('CoreFilepoolProvider');
this.filesTables = lazyMap(
siteId => asyncInstance(
() => CoreSites.getSiteTable<CoreFilepoolFileEntry, 'fileId'>(FILES_TABLE_NAME, {
siteId,
config: { cachingStrategy: CoreDatabaseCachingStrategy.Lazy },
primaryKeyColumns: ['fileId'],
}),
),
);
} }
/** /**
@ -114,6 +132,16 @@ export class CoreFilepoolProvider {
NgZone.run(() => this.checkQueueProcessing()); NgZone.run(() => this.checkQueueProcessing());
}); });
}); });
CoreEvents.on(CoreEvents.SITE_DELETED, async ({ siteId }) => {
if (!siteId || !(siteId in this.filesTables)) {
return;
}
await this.filesTables[siteId].destroy();
delete this.filesTables[siteId];
});
} }
/** /**
@ -215,9 +243,7 @@ export class CoreFilepoolProvider {
...data, ...data,
}; };
const db = await CoreSites.getSiteDb(siteId); await this.filesTables[siteId].insert(record);
await db.insertRecord(FILES_TABLE_NAME, record);
} }
/** /**
@ -560,11 +586,11 @@ export class CoreFilepoolProvider {
const db = await CoreSites.getSiteDb(siteId); const db = await CoreSites.getSiteDb(siteId);
// Read the data first to be able to notify the deletions. // Read the data first to be able to notify the deletions.
const filesEntries = await db.getAllRecords<CoreFilepoolFileEntry>(FILES_TABLE_NAME); const filesEntries = await this.filesTables[siteId].getMany();
const filesLinks = await db.getAllRecords<CoreFilepoolLinksRecord>(LINKS_TABLE_NAME); const filesLinks = await db.getAllRecords<CoreFilepoolLinksRecord>(LINKS_TABLE_NAME);
await Promise.all([ await Promise.all([
db.deleteRecords(FILES_TABLE_NAME), this.filesTables[siteId].delete(),
db.deleteRecords(LINKS_TABLE_NAME), db.deleteRecords(LINKS_TABLE_NAME),
]); ]);
@ -1125,7 +1151,7 @@ export class CoreFilepoolProvider {
// Minor problem: file will remain in the filesystem once downloaded again. // Minor problem: file will remain in the filesystem once downloaded again.
this.logger.debug('Staled file with no extension ' + entry.fileId); this.logger.debug('Staled file with no extension ' + entry.fileId);
await db.updateRecords(FILES_TABLE_NAME, { stale: 1 }, { fileId: entry.fileId }); await this.filesTables[siteId].update({ stale: 1 }, { fileId: entry.fileId });
return; return;
} }
@ -1135,7 +1161,7 @@ export class CoreFilepoolProvider {
entry.fileId = CoreMimetypeUtils.removeExtension(fileId); entry.fileId = CoreMimetypeUtils.removeExtension(fileId);
entry.extension = extension; entry.extension = extension;
await db.updateRecords(FILES_TABLE_NAME, entry, { fileId }); await this.filesTables[siteId].update(entry, { fileId });
if (entry.fileId == fileId) { if (entry.fileId == fileId) {
// File ID hasn't changed, we're done. // File ID hasn't changed, we're done.
this.logger.debug('Removed extesion ' + extension + ' from file ' + entry.fileId); this.logger.debug('Removed extesion ' + extension + ' from file ' + entry.fileId);
@ -1401,10 +1427,7 @@ export class CoreFilepoolProvider {
await Promise.all(items.map(async (item) => { await Promise.all(items.map(async (item) => {
try { try {
const fileEntry = await db.getRecord<CoreFilepoolFileEntry>( const fileEntry = await this.filesTables[siteId].getOneByPrimaryKey({ fileId: item.fileId });
FILES_TABLE_NAME,
{ fileId: item.fileId },
);
if (!fileEntry) { if (!fileEntry) {
return; return;
@ -2137,14 +2160,7 @@ export class CoreFilepoolProvider {
* @return Resolved with file object from DB on success, rejected otherwise. * @return Resolved with file object from DB on success, rejected otherwise.
*/ */
protected async hasFileInPool(siteId: string, fileId: string): Promise<CoreFilepoolFileEntry> { protected async hasFileInPool(siteId: string, fileId: string): Promise<CoreFilepoolFileEntry> {
const db = await CoreSites.getSiteDb(siteId); return this.filesTables[siteId].getOneByPrimaryKey({ fileId });
const entry = await db.getRecord<CoreFilepoolFileEntry>(FILES_TABLE_NAME, { fileId });
if (entry === undefined) {
throw new CoreError('File not found in filepool.');
}
return entry;
} }
/** /**
@ -2176,11 +2192,15 @@ export class CoreFilepoolProvider {
* @return Resolved on success. * @return Resolved on success.
*/ */
async invalidateAllFiles(siteId: string, onlyUnknown: boolean = true): Promise<void> { async invalidateAllFiles(siteId: string, onlyUnknown: boolean = true): Promise<void> {
const db = await CoreSites.getSiteDb(siteId); onlyUnknown
? await this.filesTables[siteId].updateWhere(
const where = onlyUnknown ? CoreFilepoolProvider.FILE_UPDATE_UNKNOWN_WHERE_CLAUSE : undefined; { stale: 1 },
{
await db.updateRecordsWhere(FILES_TABLE_NAME, { stale: 1 }, where); sql: CoreFilepoolProvider.FILE_IS_UNKNOWN_SQL,
js: CoreFilepoolProvider.FILE_IS_UNKNOWN_JS,
},
)
: await this.filesTables[siteId].update({ stale: 1 });
} }
/** /**
@ -2199,9 +2219,7 @@ export class CoreFilepoolProvider {
const file = await this.fixPluginfileURL(siteId, fileUrl); const file = await this.fixPluginfileURL(siteId, fileUrl);
const fileId = this.getFileIdByUrl(CoreFileHelper.getFileUrl(file)); const fileId = this.getFileIdByUrl(CoreFileHelper.getFileUrl(file));
const db = await CoreSites.getSiteDb(siteId); await this.filesTables[siteId].update({ stale: 1 }, { fileId });
await db.updateRecords(FILES_TABLE_NAME, { stale: 1 }, { fileId });
} }
/** /**
@ -2221,7 +2239,6 @@ export class CoreFilepoolProvider {
onlyUnknown: boolean = true, onlyUnknown: boolean = true,
): Promise<void> { ): Promise<void> {
const db = await CoreSites.getSiteDb(siteId); const db = await CoreSites.getSiteDb(siteId);
const items = await this.getComponentFiles(db, component, componentId); const items = await this.getComponentFiles(db, component, componentId);
if (!items.length) { if (!items.length) {
@ -2229,6 +2246,8 @@ export class CoreFilepoolProvider {
return; return;
} }
siteId = siteId ?? CoreSites.getCurrentSiteId();
const fileIds = items.map((item) => item.fileId); const fileIds = items.map((item) => item.fileId);
const whereAndParams = db.getInOrEqual(fileIds); const whereAndParams = db.getInOrEqual(fileIds);
@ -2236,10 +2255,19 @@ export class CoreFilepoolProvider {
whereAndParams.sql = 'fileId ' + whereAndParams.sql; whereAndParams.sql = 'fileId ' + whereAndParams.sql;
if (onlyUnknown) { 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 this.filesTables[siteId].updateWhere(
{ stale: 1 },
{
sql: whereAndParams.sql,
sqlParams: whereAndParams.params,
js: record => fileIds.includes(record.fileId) && (
!onlyUnknown || CoreFilepoolProvider.FILE_IS_UNKNOWN_JS(record)
),
},
);
} }
/** /**
@ -2657,6 +2685,7 @@ export class CoreFilepoolProvider {
*/ */
protected async removeFileById(siteId: string, fileId: string): Promise<void> { protected async removeFileById(siteId: string, fileId: string): Promise<void> {
const db = await CoreSites.getSiteDb(siteId); const db = await CoreSites.getSiteDb(siteId);
// Get the path to the file first since it relies on the file object stored in the pool. // 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. // Don't use getFilePath to prevent performing 2 DB requests.
let path = this.getFilepoolFolderPath(siteId) + '/' + fileId; let path = this.getFilepoolFolderPath(siteId) + '/' + fileId;
@ -2682,7 +2711,7 @@ export class CoreFilepoolProvider {
const promises: Promise<unknown>[] = []; const promises: Promise<unknown>[] = [];
// Remove entry from filepool store. // Remove entry from filepool store.
promises.push(db.deleteRecords(FILES_TABLE_NAME, conditions)); promises.push(this.filesTables[siteId].delete(conditions));
// Remove links. // Remove links.
promises.push(db.deleteRecords(LINKS_TABLE_NAME, conditions)); promises.push(db.deleteRecords(LINKS_TABLE_NAME, conditions));

View File

@ -32,7 +32,7 @@ import {
CoreSitePublicConfigResponse, CoreSitePublicConfigResponse,
CoreSiteInfoResponse, CoreSiteInfoResponse,
} from '@classes/site'; } from '@classes/site';
import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb'; import { SQLiteDB, SQLiteDBRecordValues, SQLiteDBTableSchema } from '@classes/sqlitedb';
import { CoreError } from '@classes/errors/error'; import { CoreError } from '@classes/errors/error';
import { CoreSiteError } from '@classes/errors/siteerror'; import { CoreSiteError } from '@classes/errors/siteerror';
import { makeSingleton, Translate, Http } from '@singletons'; import { makeSingleton, Translate, Http } from '@singletons';
@ -57,6 +57,9 @@ import { CoreErrorWithTitle } from '@classes/errors/errorwithtitle';
import { CoreAjaxError } from '@classes/errors/ajaxerror'; import { CoreAjaxError } from '@classes/errors/ajaxerror';
import { CoreAjaxWSError } from '@classes/errors/ajaxwserror'; import { CoreAjaxWSError } from '@classes/errors/ajaxwserror';
import { CoreSitePlugins } from '@features/siteplugins/services/siteplugins'; import { CoreSitePlugins } from '@features/siteplugins/services/siteplugins';
import { CorePromisedValue } from '@classes/promised-value';
import { CoreDatabaseTable } from '@classes/database/database-table';
import { CoreDatabaseConfiguration, CoreDatabaseTableProxy } from '@classes/database/database-table-proxy';
export const CORE_SITE_SCHEMAS = new InjectionToken<CoreSiteSchema[]>('CORE_SITE_SCHEMAS'); export const CORE_SITE_SCHEMAS = new InjectionToken<CoreSiteSchema[]>('CORE_SITE_SCHEMAS');
@ -85,6 +88,7 @@ export class CoreSitesProvider {
// Variables for DB. // Variables for DB.
protected appDB: Promise<SQLiteDB>; protected appDB: Promise<SQLiteDB>;
protected resolveAppDB!: (appDB: SQLiteDB) => void; protected resolveAppDB!: (appDB: SQLiteDB) => void;
protected siteTables: Record<string, Record<string, CorePromisedValue<CoreDatabaseTable>>> = {};
constructor(@Optional() @Inject(CORE_SITE_SCHEMAS) siteSchemas: CoreSiteSchema[][] = []) { constructor(@Optional() @Inject(CORE_SITE_SCHEMAS) siteSchemas: CoreSiteSchema[][] = []) {
this.appDB = new Promise(resolve => this.resolveAppDB = resolve); this.appDB = new Promise(resolve => this.resolveAppDB = resolve);
@ -99,6 +103,25 @@ export class CoreSitesProvider {
); );
} }
/**
* Initialize.
*/
initialize(): void {
CoreEvents.on(CoreEvents.SITE_DELETED, async ({ siteId }) => {
if (!siteId || !(siteId in this.siteTables)) {
return;
}
await Promise.all(
Object
.values(this.siteTables[siteId])
.map(promisedTable => promisedTable.then(table => table.destroy())),
);
delete this.siteTables[siteId];
});
}
/** /**
* Initialize database. * Initialize database.
*/ */
@ -112,6 +135,49 @@ export class CoreSitesProvider {
this.resolveAppDB(CoreApp.getDB()); this.resolveAppDB(CoreApp.getDB());
} }
/**
* Get site table.
*
* @param tableName Site table name.
* @param options Options to configure table initialization.
* @returns Site table.
*/
async getSiteTable<
DBRecord extends SQLiteDBRecordValues,
PrimaryKeyColumn extends keyof DBRecord
>(
tableName: string,
options: Partial<{
siteId: string;
config: Partial<CoreDatabaseConfiguration>;
database: SQLiteDB;
primaryKeyColumns: PrimaryKeyColumn[];
}> = {},
): Promise<CoreDatabaseTable<DBRecord, PrimaryKeyColumn>> {
const siteId = options.siteId ?? this.getCurrentSiteId();
if (!(siteId in this.siteTables)) {
this.siteTables[siteId] = {};
}
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<DBRecord, PrimaryKeyColumn>(
options.config ?? {},
database,
tableName,
options.primaryKeyColumns,
);
await table.initialize();
promisedTable.resolve(table as unknown as CoreDatabaseTable);
}
return this.siteTables[siteId][tableName] as unknown as Promise<CoreDatabaseTable<DBRecord, PrimaryKeyColumn>>;
}
/** /**
* Get the demo data for a certain "name" if it is a demo site. * Get the demo data for a certain "name" if it is a demo site.
* *

View File

@ -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<T>(lazyConstructor?: () => T | Promise<T>): AsyncInstanceWrapper<T> {
let promisedInstance: CorePromisedValue<T> | 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<T> {
instance?: T;
getInstance(): Promise<T>;
getProperty<P extends keyof T>(property: P): Promise<T[P]>;
setInstance(instance: T): void;
resetInstance(): void;
}
/**
* Asynchronous version of a method.
*/
export type AsyncMethod<T> =
T extends (...args: infer Params) => infer Return
// eslint-disable-next-line @typescript-eslint/no-explicit-any
? T extends (...args: Params) => Promise<any>
? T
: (...args: Params) => Promise<Return>
: never;
/**
* Asynchronous instance.
*
* All methods are converted to their asynchronous version, and properties are available asynchronously using
* the getProperty method.
*/
export type AsyncInstance<T> = AsyncInstanceWrapper<T> & {
[k in keyof T]: AsyncMethod<T[k]>;
};
/**
* 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<T>(lazyConstructor?: () => T | Promise<T>): AsyncInstance<T> {
const wrapper = createAsyncInstanceWrapper<T>(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<T>;
}

View File

@ -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<T> = Record<string, T>;
/**
* 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<T>(lazyConstructor: (key: string) => T): LazyMap<T> {
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);
},
});
}

View File

@ -17,6 +17,7 @@ import { CoreMainMenuLocalizedCustomItem } from '@features/mainmenu/services/mai
import { CoreSitesDemoSiteData } from '@services/sites'; import { CoreSitesDemoSiteData } from '@services/sites';
import { OpenFileAction } from '@services/utils/utils'; import { OpenFileAction } from '@services/utils/utils';
import { CoreLoginSiteSelectorListMethod } from '@features/login/services/login-helper'; import { CoreLoginSiteSelectorListMethod } from '@features/login/services/login-helper';
import { CoreDatabaseConfiguration } from '@classes/database/database-table-proxy';
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
@ -31,6 +32,8 @@ export interface EnvironmentConfig {
cache_update_frequency_rarely: number; cache_update_frequency_rarely: number;
default_lang: string; default_lang: string;
languages: Record<string, string>; languages: Record<string, string>;
databaseOptimizations?: Partial<CoreDatabaseConfiguration>;
databaseTableOptimizations?: Record<string, Partial<CoreDatabaseConfiguration>>;
wsservice: string; wsservice: string;
demo_sites: Record<string, CoreSitesDemoSiteData>; demo_sites: Record<string, CoreSitesDemoSiteData>;
zoomlevels: Record<CoreZoomLevel, number>; zoomlevels: Record<CoreZoomLevel, number>;