Merge pull request #3091 from NoelDeMartin/MOBILE-3977
MOBILE-3977: Database optimization strategies
This commit is contained in:
		
						commit
						858cf07f73
					
				| @ -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'); | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -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); | ||||||
|  | |||||||
| @ -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]; |  | ||||||
| }; |  | ||||||
							
								
								
									
										219
									
								
								src/core/classes/database/database-table-proxy.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										219
									
								
								src/core/classes/database/database-table-proxy.ts
									
									
									
									
									
										Normal 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', | ||||||
|  | } | ||||||
							
								
								
									
										240
									
								
								src/core/classes/database/database-table.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										240
									
								
								src/core/classes/database/database-table.ts
									
									
									
									
									
										Normal 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; | ||||||
|  | }; | ||||||
							
								
								
									
										139
									
								
								src/core/classes/database/debug-database-table.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								src/core/classes/database/debug-database-table.ts
									
									
									
									
									
										Normal 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); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
							
								
								
									
										168
									
								
								src/core/classes/database/eager-database-table.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								src/core/classes/database/eager-database-table.ts
									
									
									
									
									
										Normal 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)]; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
							
								
								
									
										141
									
								
								src/core/classes/database/lazy-database-table.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										141
									
								
								src/core/classes/database/lazy-database-table.ts
									
									
									
									
									
										Normal 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; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @ -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. | ||||||
|      */ |      */ | ||||||
|  | |||||||
| @ -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, | ||||||
|  |         }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|  | |||||||
| @ -1206,4 +1206,4 @@ export type SQLiteDBQueryParams = { | |||||||
|     params: SQLiteDBRecordValue[]; |     params: SQLiteDBRecordValue[]; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| type SQLiteDBRecordValue = number | string; | export type SQLiteDBRecordValue = number | string; | ||||||
|  | |||||||
| @ -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,63 +27,79 @@ 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)); | ||||||
| 
 | 
 | ||||||
|     let records: User[]; |             if (!record) { | ||||||
|     let db: SQLiteDB; |                 throw new Error(); | ||||||
|  |             } | ||||||
| 
 | 
 | ||||||
|     beforeEach(() => { |             return record as unknown as T; | ||||||
|         records = []; |         }, | ||||||
|         db = mock<SQLiteDB>({ |         getRecords: async <T>(_, conditions) => records.filter(record => userMatches(record, conditions)) as unknown as T[], | ||||||
|             getRecords: async <T>() => records as unknown as T[], |         getAllRecords: async <T>() => records as unknown as T[], | ||||||
|             deleteRecords: async () => 0, |         deleteRecords: async (_, conditions) => { | ||||||
|             insertRecord: async () => 0, |             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'); | ||||||
| 
 | 
 | ||||||
|     it('reads all records on create', async () => { |     return [records, database, table]; | ||||||
|         await UsersTable.create(db); | } | ||||||
| 
 | 
 | ||||||
|         expect(db.getRecords).toHaveBeenCalledWith('users'); | async function testFindItems(records: User[], table: CoreDatabaseTable<User>) { | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     it('finds items', async () => { |  | ||||||
|     const john = { id: 1, name: 'John', surname: 'Doe' }; |     const john = { id: 1, name: 'John', surname: 'Doe' }; | ||||||
|     const amy = { id: 2, name: 'Amy', surname: 'Doe' }; |     const amy = { id: 2, name: 'Amy', surname: 'Doe' }; | ||||||
| 
 | 
 | ||||||
|     records.push(john); |     records.push(john); | ||||||
|     records.push(amy); |     records.push(amy); | ||||||
| 
 | 
 | ||||||
|         const table = await UsersTable.create(db); |     await table.initialize(); | ||||||
| 
 | 
 | ||||||
|         expect(table.findByPrimaryKey({ id: 1 })).toEqual(john); |     await expect(table.getOneByPrimaryKey({ id: 1 })).resolves.toEqual(john); | ||||||
|         expect(table.findByPrimaryKey({ id: 2 })).toEqual(amy); |     await expect(table.getOneByPrimaryKey({ id: 2 })).resolves.toEqual(amy); | ||||||
|         expect(table.find({ surname: 'Doe', name: 'John' })).toEqual(john); |     await expect(table.getOne({ surname: 'Doe', name: 'John' })).resolves.toEqual(john); | ||||||
|         expect(table.find({ surname: 'Doe', name: 'Amy' })).toEqual(amy); |     await expect(table.getOne({ surname: 'Doe', name: 'Amy' })).resolves.toEqual(amy); | ||||||
|     }); | } | ||||||
| 
 | 
 | ||||||
|     it('inserts items', async () => { | async function testInsertItems(records: User[], database: SQLiteDB, table: CoreDatabaseTable<User>) { | ||||||
|     // Arrange.
 |     // Arrange.
 | ||||||
|     const john = { id: 1, name: 'John', surname: 'Doe' }; |     const john = { id: 1, name: 'John', surname: 'Doe' }; | ||||||
| 
 | 
 | ||||||
|         // Act.
 |     await table.initialize(); | ||||||
|         const table = await UsersTable.create(db); |  | ||||||
| 
 | 
 | ||||||
|  |     // Act.
 | ||||||
|     await table.insert(john); |     await table.insert(john); | ||||||
| 
 | 
 | ||||||
|     // Assert.
 |     // Assert.
 | ||||||
|         expect(db.insertRecord).toHaveBeenCalledWith('users', john); |     expect(database.insertRecord).toHaveBeenCalledWith('users', john); | ||||||
| 
 | 
 | ||||||
|         expect(table.findByPrimaryKey({ id: 1 })).toEqual(john); |     await expect(table.getOneByPrimaryKey({ id: 1 })).resolves.toEqual(john); | ||||||
|     }); | } | ||||||
| 
 | 
 | ||||||
|     it('deletes items', async () => { | async function testDeleteItems(records: User[], database: SQLiteDB, table: CoreDatabaseTable<User>) { | ||||||
|     // Arrange.
 |     // Arrange.
 | ||||||
|     const john = { id: 1, name: 'John', surname: 'Doe' }; |     const john = { id: 1, name: 'John', surname: 'Doe' }; | ||||||
|     const amy = { id: 2, name: 'Amy', surname: 'Doe' }; |     const amy = { id: 2, name: 'Amy', surname: 'Doe' }; | ||||||
| @ -88,20 +109,20 @@ describe('CoreDatabaseTable', () => { | |||||||
|     records.push(amy); |     records.push(amy); | ||||||
|     records.push(jane); |     records.push(jane); | ||||||
| 
 | 
 | ||||||
|         // Act.
 |     await table.initialize(); | ||||||
|         const table = await UsersTable.create(db); |  | ||||||
| 
 | 
 | ||||||
|  |     // Act.
 | ||||||
|     await table.delete({ surname: 'Doe' }); |     await table.delete({ surname: 'Doe' }); | ||||||
| 
 | 
 | ||||||
|     // Assert.
 |     // Assert.
 | ||||||
|         expect(db.deleteRecords).toHaveBeenCalledWith('users', { surname: 'Doe' }); |     expect(database.deleteRecords).toHaveBeenCalledWith('users', { surname: 'Doe' }); | ||||||
| 
 | 
 | ||||||
|         expect(table.findByPrimaryKey({ id: 1 })).toBeNull(); |     await expect(table.getOneByPrimaryKey({ id: 1 })).rejects.toThrow(); | ||||||
|         expect(table.findByPrimaryKey({ id: 2 })).toBeNull(); |     await expect(table.getOneByPrimaryKey({ id: 2 })).rejects.toThrow(); | ||||||
|         expect(table.findByPrimaryKey({ id: 3 })).toEqual(jane); |     await expect(table.getOneByPrimaryKey({ id: 3 })).resolves.toEqual(jane); | ||||||
|     }); | } | ||||||
| 
 | 
 | ||||||
|     it('deletes items by primary key', async () => { | async function testDeleteItemsByPrimaryKey(records: User[], database: SQLiteDB, table: CoreDatabaseTable<User>) { | ||||||
|     // Arrange.
 |     // Arrange.
 | ||||||
|     const john = { id: 1, name: 'John', surname: 'Doe' }; |     const john = { id: 1, name: 'John', surname: 'Doe' }; | ||||||
|     const amy = { id: 2, name: 'Amy', surname: 'Doe' }; |     const amy = { id: 2, name: 'Amy', surname: 'Doe' }; | ||||||
| @ -109,16 +130,94 @@ describe('CoreDatabaseTable', () => { | |||||||
|     records.push(john); |     records.push(john); | ||||||
|     records.push(amy); |     records.push(amy); | ||||||
| 
 | 
 | ||||||
|         // Act.
 |     await table.initialize(); | ||||||
|         const table = await UsersTable.create(db); |  | ||||||
| 
 | 
 | ||||||
|  |     // Act.
 | ||||||
|     await table.deleteByPrimaryKey({ id: 1 }); |     await table.deleteByPrimaryKey({ id: 1 }); | ||||||
| 
 | 
 | ||||||
|     // Assert.
 |     // Assert.
 | ||||||
|         expect(db.deleteRecords).toHaveBeenCalledWith('users', { id: 1 }); |     expect(database.deleteRecords).toHaveBeenCalledWith('users', { id: 1 }); | ||||||
| 
 | 
 | ||||||
|         expect(table.findByPrimaryKey({ id: 1 })).toBeNull(); |     await expect(table.getOneByPrimaryKey({ id: 1 })).rejects.toThrow(); | ||||||
|         expect(table.findByPrimaryKey({ id: 2 })).toEqual(amy); |     await expect(table.getOneByPrimaryKey({ id: 2 })).resolves.toEqual(amy); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | describe('CoreDatabaseTable with eager caching', () => { | ||||||
|  | 
 | ||||||
|  |     let records: User[]; | ||||||
|  |     let database: SQLiteDB; | ||||||
|  |     let table: CoreDatabaseTable<User>; | ||||||
|  | 
 | ||||||
|  |     beforeEach(() => [records, database, table] = prepareStubs({ cachingStrategy: CoreDatabaseCachingStrategy.Eager })); | ||||||
|  | 
 | ||||||
|  |     it('reads all records on initialization', async () => { | ||||||
|  |         await table.initialize(); | ||||||
|  | 
 | ||||||
|  |         expect(database.getAllRecords).toHaveBeenCalledWith('users'); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |     it('finds items', async () => { | ||||||
|  |         await testFindItems(records, table); | ||||||
|  | 
 | ||||||
|  |         expect(database.getRecord).not.toHaveBeenCalled(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('inserts items', () => testInsertItems(records, database, table)); | ||||||
|  |     it('deletes items', () => testDeleteItems(records, database, table)); | ||||||
|  |     it('deletes items by primary key', () => testDeleteItemsByPrimaryKey(records, database, table)); | ||||||
|  | 
 | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | describe('CoreDatabaseTable with lazy caching', () => { | ||||||
|  | 
 | ||||||
|  |     let records: User[]; | ||||||
|  |     let database: SQLiteDB; | ||||||
|  |     let table: CoreDatabaseTable<User>; | ||||||
|  | 
 | ||||||
|  |     beforeEach(() => [records, database, table] = prepareStubs({ cachingStrategy: CoreDatabaseCachingStrategy.Lazy })); | ||||||
|  | 
 | ||||||
|  |     it('reads no records on initialization', async () => { | ||||||
|  |         await table.initialize(); | ||||||
|  | 
 | ||||||
|  |         expect(database.getRecords).not.toHaveBeenCalled(); | ||||||
|  |         expect(database.getAllRecords).not.toHaveBeenCalled(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('finds items', async () => { | ||||||
|  |         await testFindItems(records, table); | ||||||
|  | 
 | ||||||
|  |         expect(database.getRecord).toHaveBeenCalledTimes(2); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('inserts items', () => testInsertItems(records, database, table)); | ||||||
|  |     it('deletes items', () => testDeleteItems(records, database, table)); | ||||||
|  |     it('deletes items by primary key', () => testDeleteItemsByPrimaryKey(records, database, table)); | ||||||
|  | 
 | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | describe('CoreDatabaseTable with no caching', () => { | ||||||
|  | 
 | ||||||
|  |     let records: User[]; | ||||||
|  |     let database: SQLiteDB; | ||||||
|  |     let table: CoreDatabaseTable<User>; | ||||||
|  | 
 | ||||||
|  |     beforeEach(() => [records, database, table] = prepareStubs({ cachingStrategy: CoreDatabaseCachingStrategy.None })); | ||||||
|  | 
 | ||||||
|  |     it('reads no records on initialization', async () => { | ||||||
|  |         await table.initialize(); | ||||||
|  | 
 | ||||||
|  |         expect(database.getRecords).not.toHaveBeenCalled(); | ||||||
|  |         expect(database.getAllRecords).not.toHaveBeenCalled(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('finds items', async () => { | ||||||
|  |         await testFindItems(records, table); | ||||||
|  | 
 | ||||||
|  |         expect(database.getRecord).toHaveBeenCalledTimes(4); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('inserts items', () => testInsertItems(records, database, table)); | ||||||
|  |     it('deletes items', () => testDeleteItems(records, database, table)); | ||||||
|  |     it('deletes items by primary key', () => testDeleteItemsByPrimaryKey(records, database, table)); | ||||||
|  | 
 | ||||||
| }); | }); | ||||||
|  | |||||||
| @ -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 { | ||||||
|  | |||||||
| @ -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(), | ||||||
|  | |||||||
| @ -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) { |             if (defaultValue !== undefined) { | ||||||
|                 return defaultValue; |                 return defaultValue; | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|         throw new Error(`Couldn't get config with name '${name}'`); |             throw error; | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -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']; |  | ||||||
| 
 |  | ||||||
| } |  | ||||||
|  | |||||||
| @ -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. | ||||||
|      * |      * | ||||||
|  | |||||||
| @ -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)); | ||||||
|  | |||||||
| @ -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. | ||||||
|      * |      * | ||||||
|  | |||||||
							
								
								
									
										122
									
								
								src/core/utils/async-instance.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								src/core/utils/async-instance.ts
									
									
									
									
									
										Normal 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>; | ||||||
|  | } | ||||||
							
								
								
									
										40
									
								
								src/core/utils/lazy-map.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/core/utils/lazy-map.ts
									
									
									
									
									
										Normal 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); | ||||||
|  |         }, | ||||||
|  |     }); | ||||||
|  | } | ||||||
							
								
								
									
										3
									
								
								src/types/config.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								src/types/config.d.ts
									
									
									
									
										vendored
									
									
								
							| @ -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>; | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user