// (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 { SQLiteDB, SQLiteDBRecordValue, SQLiteDBRecordValues } from '@classes/sqlitedb'; /** * Wrapper used to interact with a database table. */ export class CoreDatabaseTable< DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues, PrimaryKeyColumn extends keyof DBRecord = 'id', PrimaryKey extends GetDBRecordPrimaryKey = GetDBRecordPrimaryKey > { protected config: Partial; protected database: SQLiteDB; protected tableName: string; protected primaryKeyColumns: PrimaryKeyColumn[]; protected listeners: CoreDatabaseTableListener[] = []; constructor( config: Partial, database: SQLiteDB, tableName: string, primaryKeyColumns?: PrimaryKeyColumn[], ) { this.config = config; this.database = database; this.tableName = tableName; this.primaryKeyColumns = primaryKeyColumns ?? ['id'] as PrimaryKeyColumn[]; } /** * Get database configuration. * * @returns The database configuration. */ getConfig(): Partial { return this.config; } /** * Get database connection. * * @returns Database connection. */ getDatabase(): SQLiteDB { return this.database; } /** * Get table name. * * @returns Table name. */ getTableName(): string { return this.tableName; } /** * Get primary key columns. * * @returns Primary key columns. */ getPrimaryKeyColumns(): PrimaryKeyColumn[] { return this.primaryKeyColumns.slice(0); } /** * Initialize. */ async initialize(): Promise { // Nothing to initialize by default, override this method if necessary. } /** * Destroy. */ async destroy(): Promise { this.listeners.forEach(listener => listener.onDestroy?.()); } /** * Add listener. * * @param listener Listener. */ addListener(listener: CoreDatabaseTableListener): void { this.listeners.push(listener); } /** * Check whether the table matches the given configuration for the values that concern it. * * @param config Database config. * @returns Whether the table matches the given configuration. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars matchesConfig(config: Partial): boolean { return true; } /** * Get records matching the given conditions. * * @param conditions Matching conditions. If this argument is missing, all records in the table will be returned. * @param options Query options. * @returns Database records. */ getMany(conditions?: Partial, options?: Partial>): Promise { if (!conditions && !options) { return this.database.getAllRecords(this.tableName); } const sorting = options?.sorting && this.normalizedSorting(options.sorting).map(([column, direction]) => `${column.toString()} ${direction}`).join(', '); return this.database.getRecords(this.tableName, conditions, sorting, '*', options?.offset, options?.limit); } /** * Get records matching the given conditions. * * This method should be used when it's necessary to apply complex conditions; the simple `getMany` * method should be favored otherwise for better performance. * * @param conditions Matching conditions in SQL and JavaScript. * @returns Records matching the given conditions. */ getManyWhere(conditions: CoreDatabaseConditions): Promise { return this.database.getRecordsSelect(this.tableName, conditions.sql, conditions.sqlParams); } /** * Find one record matching the given conditions. * * @param conditions Matching conditions. * @param options Result options. * @returns Database record. */ async getOne( conditions?: Partial, options?: Partial, 'offset' | 'limit'>>, ): Promise { if (!options) { return this.database.getRecord(this.tableName, conditions); } const records = await this.getMany(conditions, { ...options, limit: 1, }); if (records.length === 0) { throw new CoreError('No records found.'); } return records[0]; } /** * Find one record by its primary key. * * @param primaryKey Primary key. * @returns Database record. */ getOneByPrimaryKey(primaryKey: PrimaryKey): Promise { return this.database.getRecord(this.tableName, primaryKey); } /** * Reduce some records into a single value. * * @param reducer Reducer functions in SQL and JavaScript. * @param conditions Matching conditions in SQL and JavaScript. If this argument is missing, all records in the table * will be used. * @returns Reduced value. */ reduce(reducer: CoreDatabaseReducer, conditions?: CoreDatabaseConditions): Promise { return this.database.getFieldSql( `SELECT ${reducer.sql} FROM ${this.tableName} ${conditions?.sql ?? ''}`, conditions?.sqlParams, ) as unknown as Promise; } /** * Check whether the table is empty or not. * * @returns Whether the table is empty or not. */ isEmpty(): Promise { return this.hasAny(); } /** * Check whether the table has any record matching the given conditions. * * @param conditions Matching conditions. If this argument is missing, this method will return whether the table * is empty or not. * @returns Whether the table contains any records matching the given conditions. */ async hasAny(conditions?: Partial): Promise { try { await this.getOne(conditions); return true; } catch (error) { // Couldn't get a single record. return false; } } /** * Check whether the table has any record matching the given primary key. * * @param primaryKey Record primary key. * @returns Whether the table contains a record matching the given primary key. */ async hasAnyByPrimaryKey(primaryKey: PrimaryKey): Promise { try { await this.getOneByPrimaryKey(primaryKey); return true; } catch (error) { // Couldn't get the record. return false; } } /** * Count records in table. * * @param conditions Matching conditions. * @returns Number of records matching the given conditions. */ count(conditions?: Partial): Promise { return this.database.countRecords(this.tableName, conditions); } /** * Insert a new record. * * @param record Database record. */ async insert(record: DBRecord): Promise { await this.database.insertRecord(this.tableName, record); } /** * Insert a new record synchronously. * * @param record Database record. */ syncInsert(record: DBRecord): void { // The current database architecture does not support synchronous operations, // so calling this method will mean that errors will be silenced. Because of that, // this should only be called if using the asynchronous alternatives is not possible. this.insert(record); } /** * Update records matching the given conditions. * * @param updates Record updates. * @param conditions Matching conditions. If this argument is missing, all records will be updated. */ async update(updates: Partial, conditions?: Partial): Promise { await this.database.updateRecords(this.tableName, updates, conditions); } /** * Update records matching the given conditions. * * This method should be used when it's necessary to apply complex conditions; the simple `update` * method should be favored otherwise for better performance. * * @param updates Record updates. * @param conditions Matching conditions in SQL and JavaScript. */ async updateWhere(updates: Partial, conditions: CoreDatabaseConditions): Promise { await this.database.updateRecordsWhere(this.tableName, updates, conditions.sql, conditions.sqlParams); } /** * Delete records matching the given conditions. * * @param conditions Matching conditions. If this argument is missing, all records will be deleted. */ async delete(conditions?: Partial): Promise { conditions ? await this.database.deleteRecords(this.tableName, conditions) : await this.database.deleteRecords(this.tableName); } /** * Delete a single record identified by its primary key. * * @param primaryKey Record primary key. */ async deleteByPrimaryKey(primaryKey: PrimaryKey): Promise { await this.database.deleteRecords(this.tableName, primaryKey); } /** * Get the primary key from a database record. * * @param record Database record. * @returns Primary key. */ protected getPrimaryKeyFromRecord(record: DBRecord): PrimaryKey { return this.primaryKeyColumns.reduce((primaryKey, column) => { primaryKey[column] = record[column]; return primaryKey; }, {} as Record) as PrimaryKey; } /** * Serialize a primary key with a string representation. * * @param primaryKey Primary key. * @returns Serialized primary key. */ protected serializePrimaryKey(primaryKey: PrimaryKey): string { return Object.values(primaryKey).map(value => String(value)).join('-'); } /** * Check whether a given record matches the given conditions. * * @param record Database record. * @param conditions Matching conditions. * @returns Whether the record matches the conditions. */ protected recordMatches(record: DBRecord, conditions: Partial): boolean { return !Object.entries(conditions).some(([column, value]) => record[column] !== value); } /** * Sort a list of records with the given order. This method mutates the input array. * * @param records Array of records to sort. * @param sorting Sorting conditions. * @returns Sorted array. This will be the same reference that was given as an argument. */ protected sortRecords(records: DBRecord[], sorting: CoreDatabaseSorting): DBRecord[] { const columnsSorting = this.normalizedSorting(sorting); records.sort((a, b) => { for (const [column, direction] of columnsSorting) { const aValue = a[column] ?? 0; const bValue = b[column] ?? 0; if (aValue > bValue) { return direction === 'desc' ? -1 : 1; } if (aValue < bValue) { return direction === 'desc' ? 1 : -1; } } return 0; }); return records; } /** * Get a normalized array of sorting conditions. * * @param sorting Sorting conditions. * @returns Normalized sorting conditions. */ protected normalizedSorting(sorting: CoreDatabaseSorting): [keyof DBRecord, 'asc' | 'desc'][] { const sortingArray = Array.isArray(sorting) ? sorting : [sorting]; return sortingArray.reduce((normalizedSorting, columnSorting) => { normalizedSorting.push( typeof columnSorting === 'object' ? [ Object.keys(columnSorting)[0] as keyof DBRecord, Object.values(columnSorting)[0] as 'asc' | 'desc', ] : [columnSorting, 'asc'], ); return normalizedSorting; }, [] as [keyof DBRecord, 'asc' | 'desc'][]); } } /** * Database configuration. */ export interface CoreDatabaseConfiguration { // This definition is augmented in subclasses. } /** * Database table listener. */ export interface CoreDatabaseTableListener { onDestroy?(): void; } /** * CoreDatabaseTable constructor. */ export type CoreDatabaseTableConstructor< DBRecord extends SQLiteDBRecordValues = SQLiteDBRecordValues, PrimaryKeyColumn extends keyof DBRecord = 'id', PrimaryKey extends GetDBRecordPrimaryKey = GetDBRecordPrimaryKey > = { new ( config: Partial, database: SQLiteDB, tableName: string, primaryKeyColumns?: PrimaryKeyColumn[] ): CoreDatabaseTable; }; /** * Infer primary key type from database record and primary key column types. */ export type GetDBRecordPrimaryKey = { [column in PrimaryKeyColumn]: DBRecord[column]; }; /** * Reducer used to accumulate a value from multiple records both in SQL and JavaScript. * * Both operations should be equivalent. */ export type CoreDatabaseReducer = { sql: string; js: (previousValue: T, record: DBRecord) => T; jsInitialValue: T; }; /** * Conditions to match database records both in SQL and JavaScript. * * Both conditions should be equivalent. */ export type CoreDatabaseConditions = { sql: string; sqlParams?: SQLiteDBRecordValue[]; js: (record: DBRecord) => boolean; }; /** * Sorting conditions for a single column. * * This type will accept an object that defines sorting conditions for a single column, but not more. * For example, `{id: 'desc'}` and `{name: 'asc'}` would be acceptend values, but `{id: 'desc', name: 'asc'}` wouldn't. * * @see https://stackoverflow.com/questions/57571664/typescript-type-for-an-object-with-only-one-key-no-union-type-allowed-as-a-key */ export type CoreDatabaseColumnSorting = { [Column in DBRecordColumn]: (Record & Partial, never>>) extends infer ColumnSorting ? { [Column in keyof ColumnSorting]: ColumnSorting[Column] } : never; }[DBRecordColumn]; /** * Sorting conditions to apply to query results. * * Columns will be sorted in ascending order by default. */ export type CoreDatabaseSorting = keyof DBRecord | CoreDatabaseColumnSorting | Array>; /** * Options to configure query results. */ export type CoreDatabaseQueryOptions = { offset: number; limit: number; sorting: CoreDatabaseSorting; };