// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {Database, Q} from '@nozbe/watermelondb'; import { getRangeOfValues, getValidRecordsForUpdate, retrieveRecords, } from '@database/operator/utils/general'; import {OperationType} from '@typings/database/enums'; import {logWarning} from '@utils/log'; import type {WriterInterface} from '@nozbe/watermelondb/Database'; import type Model from '@nozbe/watermelondb/Model'; import type { HandleRecordsArgs, OperationArgs, ProcessRecordResults, ProcessRecordsArgs, RecordPair, } from '@typings/database/database'; export interface BaseDataOperatorType { database: Database; handleRecords: ({buildKeyRecordBy, fieldName, transformer, createOrUpdateRawValues, deleteRawValues, tableName, prepareRecordsOnly}: HandleRecordsArgs) => Promise; processRecords: ({createOrUpdateRawValues, deleteRawValues, tableName, buildKeyRecordBy, fieldName}: ProcessRecordsArgs) => Promise; batchRecords: (models: Model[]) => Promise; prepareRecords: ({tableName, createRaws, deleteRaws, updateRaws, transformer}: OperationArgs) => Promise; } export default class BaseDataOperator { database: Database; constructor(database: Database) { this.database = database; } /** * processRecords: This method weeds out duplicates entries. It may happen that we do multiple inserts for * the same value. Hence, prior to that we query the database and pick only those values that are 'new' from the 'Raw' array. * @param {ProcessRecordsArgs} inputsArg * @param {RawValue[]} inputsArg.createOrUpdateRawValues * @param {string} inputsArg.tableName * @param {string} inputsArg.fieldName * @param {(existing: Model, newElement: RawValue) => boolean} inputsArg.buildKeyRecordBy * @returns {Promise<{ProcessRecordResults}>} */ processRecords = async ({createOrUpdateRawValues = [], deleteRawValues = [], tableName, buildKeyRecordBy, fieldName}: ProcessRecordsArgs): Promise => { const getRecords = async (rawValues: RawValue[]) => { // We will query a table where one of its fields can match a range of values. Hence, here we are extracting all those potential values. const columnValues: string[] = getRangeOfValues({fieldName, raws: rawValues}); if (!columnValues.length && rawValues.length) { throw new Error( `Invalid "fieldName" or "tableName" has been passed to the processRecords method for tableName ${tableName} fieldName ${fieldName}`, ); } if (!rawValues.length) { return []; } const existingRecords = await retrieveRecords({ database: this.database, tableName, condition: Q.where(fieldName, Q.oneOf(columnValues)), }); return existingRecords; }; const createRaws: RecordPair[] = []; const updateRaws: RecordPair[] = []; // for delete flow const deleteRaws = await getRecords(deleteRawValues); // for create or update flow const createOrUpdateRaws = await getRecords(createOrUpdateRawValues); const recordsByKeys = createOrUpdateRaws.reduce((result: Record, record) => { // @ts-expect-error object with string key const key = buildKeyRecordBy?.(record) || record[fieldName]; result[key] = record; return result; }, {}); if (createOrUpdateRawValues.length > 0) { for (const newElement of createOrUpdateRawValues) { // @ts-expect-error object with string key const key = buildKeyRecordBy?.(newElement) || newElement[fieldName]; const existingRecord = recordsByKeys[key]; // We found a record in the database that matches this element; hence, we'll proceed for an UPDATE operation if (existingRecord) { // Some raw value has an update_at field. We'll proceed to update only if the update_at value is different from the record's value in database const updateRecords = getValidRecordsForUpdate({ tableName, existingRecord, newValue: newElement, }); updateRaws.push(updateRecords); continue; } // This RawValue is not present in the database; hence, we need to create it createRaws.push({record: undefined, raw: newElement}); } } return { createRaws, updateRaws, deleteRaws, }; }; /** * prepareRecords: Utility method that actually calls the operators for the handlers * @param {OperationArgs} prepareRecord * @param {string} prepareRecord.tableName * @param {RawValue[]} prepareRecord.createRaws * @param {RawValue[]} prepareRecord.updateRaws * @param {Model[]} prepareRecord.deleteRaws * @param {(TransformerArgs) => Promise;} transformer * @returns {Promise} */ prepareRecords = async ({tableName, createRaws, deleteRaws, updateRaws, transformer}: OperationArgs): Promise => { if (!this.database) { logWarning('Database not defined in prepareRecords'); return []; } let preparedRecords: Array> = []; // create operation if (createRaws?.length) { const recordPromises = createRaws.map( (createRecord: RecordPair) => { return transformer({ database: this.database, tableName, value: createRecord, action: OperationType.CREATE, }); }, ); preparedRecords = preparedRecords.concat(recordPromises); } // update operation if (updateRaws?.length) { const recordPromises = updateRaws.map( (updateRecord: RecordPair) => { return transformer({ database: this.database, tableName, value: updateRecord, action: OperationType.UPDATE, }); }, ); preparedRecords = preparedRecords.concat(recordPromises); } const results = await Promise.all(preparedRecords); if (deleteRaws?.length) { deleteRaws.forEach((deleteRecord) => { results.push(deleteRecord.prepareDestroyPermanently()); }); } return results; }; /** * batchRecords: Accepts an instance of Database (either Default or Server) and an array of * prepareCreate/prepareUpdate 'models' and executes the actions on the database. * @param {Array} models * @returns {Promise} */ async batchRecords(models: Model[]): Promise { try { if (models.length > 0) { await this.database.write(async (writer: WriterInterface) => { await writer.batch(...models); }); } } catch (e) { logWarning('batchRecords error ', e as Error); } } /** * handleRecords : Utility that processes some records' data against values already present in the database so as to avoid duplicity. * @param {HandleRecordsArgs} handleRecordsArgs * @param {(existing: Model, newElement: RawValue) => boolean} handleRecordsArgs.buildKeyRecordBy * @param {string} handleRecordsArgs.fieldName * @param {(TransformerArgs) => Promise} handleRecordsArgs.composer * @param {RawValue[]} handleRecordsArgs.createOrUpdateRawValues * @param {RawValue[]} handleRecordsArgs.deleteRawValues * @param {string} handleRecordsArgs.tableName * @returns {Promise} */ async handleRecords({buildKeyRecordBy, fieldName, transformer, createOrUpdateRawValues, deleteRawValues = [], tableName, prepareRecordsOnly = true}: HandleRecordsArgs): Promise { if (!createOrUpdateRawValues.length) { logWarning( `An empty "rawValues" array has been passed to the handleRecords method for tableName ${tableName}`, ); return []; } const {createRaws, deleteRaws, updateRaws} = await this.processRecords({ createOrUpdateRawValues, deleteRawValues, tableName, buildKeyRecordBy, fieldName, }); let models: Model[] = []; models = await this.prepareRecords({ tableName, createRaws, updateRaws, deleteRaws, transformer, }); if (!prepareRecordsOnly && models?.length) { await this.batchRecords(models); } return models; } }