From e3f7c178bbd5dc4b3c54da937bb535588e554b75 Mon Sep 17 00:00:00 2001 From: Avinash Lingaloo Date: Fri, 2 Apr 2021 00:26:05 +0400 Subject: [PATCH] MM_33226 [v2] DataOperator - User section (#5255) * MM_30475 : ADDED default schema * MM_30475 : ADDED todo for field 'value' of default/Global entity * MM_30476 : Created schema for SERVER DB * MM_30476 : Server model [ IN PROGRESS ] * MM_30476 : Including types for group, groups_in_channel and role * MM_30476 : ADDED models for Group - @typings absolute path has been added to the tsconfig.json * MM_30476 : ADDED typings to current models * MM_30476 : ADDED typings to current models * MM_30476 : ADDED models related to TEAM section of the ERD * MM_30476 : ADDED models for User section of the ERD * MM_30476 : ADDED models for POST section of the ERD * MM_30476 : ADDED models for Channel section of the ERD * MM_30475 : Updated typings and references to MM_TABLES * MM_30476 : Verified all field names * MM_30476 : Verified every table associations * MM_30476 : Verified all relation fields * MM_30476 : Updated primary id of the main models We will override the wdb id at component level when we create a new records. This involves the models : channel, group, post, team and user. * MM_30476 : Including 1:1 relationship amongs some entities * MM_30476 : ADDED Schema Managers * The migration array will hold all the migration steps. * The initial app release (e.g. v2 )will have an empty array and subsequent releases (e.g. v2.1 ) will have the steps listed in that array. * On initialization, the database will perform the migration to accomodate for new columns/tables creation and while it will conserve the mobile phone's data, it will also make it conform to this new schema. * If a migration fails, the migration process will rollback any changes. This migration will be thoroughly tested in development before pushing it live. * Revert "MM_30476 : ADDED Schema Managers" This reverts commit a505bd5e11124e8eb8f258ce8dbb8168a535f7ae. * MM_30478 : Converted schema_manager into a function * MM_30478 : Updated schema manager and included patch for wdb * MM_30478: Updated watermelondb patch package * MM_30478 : Update function create_schema_manager to createSqliteAdaptorOptions * MM_30476 : Update constant name to reflect directory name * MM_30476 : Updated msgCount from my_channel model to message_count in server schema * MM_30482 : Added tests for schema_manager * MM_30482 : Database Manager [ IN PROGRESS ] * MM_30478 : Returning an sqliteAdapter instead of an object * MM_30476 : Apply suggestions from code review Co-authored-by: Elias Nahum * MM_30476 : Updated all imports as per instruction. * MM_30476 : Shortening object chains by destructuring * MM_30476 : Updated schema file structure * MM_30476 : Prettifying @typings folder * MM_30476 : Removing useless ids * MM_30476 : Prettify imports for decorators * MM_30476 : ADDED documentations and lazy queries to Channel and Channel_Info * MM_30476 : ADDED documentations for default schema * MM_30476 : Documentation [ IN PROGRESS ] - Following JSDoc syntax for single line comment - Removed redundant fields in the 'membership' tables and left only the @relation records. * MM_30476 : Documentations [ IN PROGRESS ] * MM_30476 : Documentations [ IN PROGRESS ] * MM_30476 : Documentations [ IN PROGRESS ] * MM_30476 : Documentations [ IN PROGRESS] Updated 1) my_team and team, 2) my_channel and channel, to each have 1:1 relationship with one another * MM_30476 : Updated all Typescript definitions * MM_30476 :Updated @relation to @immutableRelation * MM_30476 : Updated description for previous_post_id * MM_30478 : Updated patch package for wdb module * MM_30478: DB Manager [IN PROGRESS ] * MM_30478: DB Manager [IN PROGRESS] * MM_30478: DB Manager [IN PROGRESS] * MM_30478 : DB Manager [IN PROGRESS] * MM_30478 : Deleting .db file on iOS * MM_30478: Successfully deleting .db files and directory on iOS side * MM_30478 : Update definition for default/global * MM_30478 : Updated all models * MM_30478 : Doing a bit of house cleaning * MM_30478: Record of new server connection added to default/servers db * TS Definitely Typed Assignment issue is now FIXED * MM_30478 : TS Definitely Typed Assignment \n Removed all the constructors but error still in editor tabs. But this time the app is not crashing * MM_30478 : Attempt 1 [SUCCESSFUL] * MM_30478 : Removing useDefineForClassFields * MM_30478 : Retrieving the servers in a list + Improved the DB Manager and Babel config * MM_30478 : Updated babel.config.js * MM_30478 : Minor UI correction * MM_30478 : Jest and Typescript configuration * MM_30478 : A bit of housekeeping * MM_30478 : Installed WDB on Android * MM_30478 : Deletes new server record from default DB * MM_30478 : Returns subset of server db instances * MM_30478 : Code clean up * MM_30478 : Code clean up on db manager * MM_30478 : House keeping + Patch for WDB * MM_30478 : Android - Saving & Deleting in FilesDir [COMPLETED] * MM_30478 : Code clean up * MM_30478 : Code clean up * MM_30478 : Code clean up * MM_30478 : Test successful on Android device * MM_30478 : Rolling back change to jest.config.js * MM_30478 : Updated test to test_integration * MM_30478 : Fix imports * MM_30478 : Refactored the manual testscript * MM_30478 : Renamed database manager test file * MM_30478 : Code clean up * MM_30478 : Updated manual test file with a note. * MM_30482 : DataOperator [ IN PROGRESS ] * MM_30482 : DataOperator - setting up the factory [ IN PROGRESS ] * MM_30482: Code refactoring * MM_30482 : DataOperator - setting up the factory [ IN PROGRESS ] * MM_30482 : DataOperator - code clean up [ IN PROGRESS ] * MM_30482 : Minor code clean up * MM_30478 : Fixed JEST issue with TS * MM_30478 : Fixed JEST issue with TS * MM_30478 : Fixed JEST issue with TS * MM_30478 : Implementing JEST test cases * MM_30478 : Implementing JEST last test cases * MM_30478 : Jest fixing ts errors * MM_30478 : Database Manager Jest testing [ IN PROGRESS ] * MM_30482 - Fixing DataOperator [ IN PROGRESS ] * MM_30482 : Code clean up * MM_30482 - Creates multiple records [ IN PROGRESS ] * MM_30482 - Creates multiple records [ IN PROGRESS ] * MM_30482 : Update operation [ COMPLETED ] * MM_30482 : Code clean up * MM_30482 : Updated TS for Data Operator * Update mobile v2 detox deps * MM_30482 : Added factories for all isolated tables * MM_30482 : Refactored TS * MM_30482 : Refactored base factory * MM_30482 : Updated JSDoc for operateBaseRecord - Delete CASE * MM_30482 : Implementing test for Data Operator * MM_30482 : Completed tests for all isolated tables * MM_30482 : Renamed entity_factory into operators * MM_30482 : Fix all imports * MM_30482 : Update multiple records * MM_30482 : Edge case for existing records ( update instead of create ) * MM_30482 : Edge case - create instead of update * MM_30482 : Code clean up * MM_30482 : Code clean up * MM_30482 : Code clean up * MM_30482 : Code clean up * Update app/database/admin/data_operator/operators.ts Co-authored-by: Joseph Baylon * Update app/database/admin/data_operator/operators.ts Co-authored-by: Joseph Baylon * Update app/database/admin/data_operator/operators.ts Co-authored-by: Joseph Baylon * MM_30482 : Imposing usage of correct table name for isolated entities * MM_30482 : Code improvement as per Joseph reviews * MM_30482 : Updated tests to validate choice of operator service wrt tableName * MM_30482 : Updated PR as per suggestions * MM_30482 : Updated comments to follow jsdoc conventions * MM_33223 : Renamed DBInstance to DatabaseInstance * MM_33223 : ADDED Prettier * MM_33223 - Prettier formatting * MM_33223 : Prettier formatting * MM_33223 - Post section [ in progress ] * MM_33223 : PostsInThread [99% completed ] * MM_33223: Reaction entity completed * MM_33223: Added Reaction to the Post * MM_33223 : Refactored reactions utils * MM_33223 : Added previous post id to all posts * MM_33223 : Added File Metadata * MM_33223 : Code clean up * MM_33223 : Added PostMetadata * MM_33223 : Added Draft * MM_33223 - Removed Prettier * MM_33223 - Undo files changes due to Prettier * MM_33223 : Making use of MM eslint plugins * MM_33223 : PostsInChannel [ IN PROGRESS ] * MM_33223 : Including update_at in Post schema * MM_33223: Code clean up * MM_33223: Code clean up * MM_33223 : Code clean up * MM_33223: Testing Reaction [IN PROGRESS] * MM_33223 : Updated typings for RawCustomEmoji in Reactions * MM_33223 : Refactored DataOperator test * MM_33223 : Jest - handleReactions - Completed * MM_33223 : Jest - HandleDraft - Completed * MM_33223 : Jest - HandleFiles - Completed * MM_33223 : Refactored DataOperator-PostMetadata * MM_33223 : Jest - HandlePostMetadata - Completed * MM_33223 : Refactored posts into ordered and unordered * MM_33223 : Refactoring + Jest Utils [ IN PROGRESS ] * MM_33223 - Jest Utils - Completed * MM_33223 : Jest - Remaining operators - Completed * MM_33223 : Jest - Handler PostsInThread - Completed * MM_33223 : Jest - HandlePostsInChannel - Completed * MM_33223 : Refactored DataOperator class * MM_33223 : DataOperator test clean up * MM_33223 : DataOperator code clean up * MM_33223 : Jest - HandlePosts - Completed * MM_33223: JSDoc - Operators - Completed * MM_33223 : Refactoring file types.ts * MM_33223 : Refactored import statements * MM_33223 : Added @database alias * MM_33223 : Added missing JSDoc * MM_33223 : Minor code clean up * MM_33223 : Lint fixed * MM_33223 : Disable eslint rules for Notification * MM_33223 : Disable eslint rule for screens * Update app/database/admin/data_operator/index.ts Co-authored-by: Miguel Alatzar * Apply suggestions from code review Co-authored-by: Miguel Alatzar * Apply suggestions from code review Co-authored-by: Miguel Alatzar * MM_33223 : Update data_operatator as per suggestion * MM_33226: Removed optType * MM_33226 : ADDED User Handler+Operator+Jest, Included update_at field for User * MM_33226 : Preference entity - Completed * MM_33226 : Team Membership entity - Completed * MM_33226 : Team Membership - Jest - Completed * MM_33226 : Removing duplicates for TeamMembership and Preferences * MM_33226 : Refactored Custom Emojis to remove duplicates * MM_33226 : Group Membership - Completed * MM_33226 : ChannelMembership - Completed * MM_33226 : Refactored some handlers whose response have no Ids * MM_33226 : Refactoring - in progress * MM_33226 : Refactoring - in progress * MM_33226 : Refactoring - in progress * MM_33226 : Code clean up * MM_33226 : Polishing Operator tests * MM_33226 : Removing redundant test cases * MM_33226 : Polishing Operators * MM_33226 : Testing for duplicate post id in Raw values * MM_33226 : Including some error-throwing in the Database Manager * MM_33226 : Merged in DataOperator/Post-section * MM_33226 : Fixing the merging issues * MM_33226 : fixing merge issues * MM_33226 : Code polishing * MM_33226 : Enabling user notify props comment * MM_33226 : Correcting type casting * MM_33226 : Correcting data operators * MM_33226 : Corrections * MM_33226 : Code clean up * MM_33226 : Rename oneOfField to fieldName * MM_33226 : Renaming comparators to Boolean name and oneOfField to fieldName * MM_33226 : Putting back custom emoji into handleIsolatedEntity * MM_33226 : Comparing simple arrays * MM_33226 : Renaming DiscardDuplicates to ProcessInputs * MM_33226 : Sort imports * MM_33226 : Types clean up Co-authored-by: Elias Nahum Co-authored-by: Avinash Lingaloo <> Co-authored-by: Joseph Baylon Co-authored-by: Miguel Alatzar --- .../admin/data_operator/comparators/index.ts | 104 + .../admin/data_operator/handlers/index.ts | 1681 ++++++++++------- .../admin/data_operator/handlers/test.ts | 498 +++-- app/database/admin/data_operator/index.ts | 3 +- .../admin/data_operator/operators/index.ts | 592 +++--- .../admin/data_operator/operators/test.ts | 624 +++--- .../admin/data_operator/utils/index.ts | 109 +- .../admin/data_operator/utils/test.ts | 39 +- .../admin/data_operator/wrapper/index.ts | 3 +- .../admin/data_operator/wrapper/test.ts | 13 +- .../admin/database_manager/__mocks__/index.ts | 514 ++--- app/database/admin/database_manager/index.ts | 655 ++++--- app/database/admin/database_manager/test.ts | 11 +- ...xception.ts => data_operator_exception.ts} | 6 +- app/database/server/models/user.ts | 5 +- .../server/schema/table_schemas/user.ts | 1 + app/database/server/schema/test.ts | 2 + types/database/database.d.ts | 220 ++- types/database/enums.ts | 24 +- types/database/index.d.ts | 10 +- types/database/user.d.ts | 4 +- 21 files changed, 3078 insertions(+), 2040 deletions(-) create mode 100644 app/database/admin/data_operator/comparators/index.ts rename app/database/admin/exceptions/{database_operator_exception.ts => data_operator_exception.ts} (56%) diff --git a/app/database/admin/data_operator/comparators/index.ts b/app/database/admin/data_operator/comparators/index.ts new file mode 100644 index 0000000000..7d536a2f67 --- /dev/null +++ b/app/database/admin/data_operator/comparators/index.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {App} from '@database/default/models'; +import {Role, User} from '@database/server/models'; +import ChannelMembership from '@typings/database/channel_membership'; +import CustomEmoji from '@typings/database/custom_emoji'; +import { + RawApp, + RawChannelMembership, + RawCustomEmoji, + RawDraft, + RawGlobal, + RawGroupMembership, + RawPost, + RawPreference, + RawRole, + RawServers, + RawSystem, + RawTeamMembership, + RawTermsOfService, + RawUser, +} from '@typings/database/database'; +import Draft from '@typings/database/draft'; +import Global from '@typings/database/global'; +import GroupMembership from '@typings/database/group_membership'; +import Post from '@typings/database/post'; +import Preference from '@typings/database/preference'; +import Servers from '@typings/database/servers'; +import System from '@typings/database/system'; +import TeamMembership from '@typings/database/team_membership'; +import TermsOfService from '@typings/database/terms_of_service'; + +/** + * This file contains all the comparators that are used by the handlers to find out which records to truly update and + * which one to create. A 'record' is a model in our database and a 'raw' is the object that is passed to the handler + * (e.g. API response). Each comparator will return a boolean condition after comparing specific fields from the + * 'record' and the 'raw' + */ + +export const isRecordAppEqualToRaw = (record: App, raw: RawApp) => { + return ( + raw.buildNumber === record.buildNumber && + raw.createdAt === record.createdAt && + raw.versionNumber === record.versionNumber + ); +}; + +export const isRecordGlobalEqualToRaw = (record: Global, raw: RawGlobal) => { + return raw.name === record.name && raw.value === record.value; +}; + +export const isRecordServerEqualToRaw = (record: Servers, raw: RawServers) => { + return raw.url === record.url && raw.dbPath === record.dbPath; +}; + +export const isRecordRoleEqualToRaw = (record: Role, raw: RawRole) => { + return raw.name === record.name && JSON.stringify(raw.permissions) === JSON.stringify(record.permissions); +}; + +export const isRecordSystemEqualToRaw = (record: System, raw: RawSystem) => { + return raw.name === record.name && raw.value === record.value; +}; + +export const isRecordTermsOfServiceEqualToRaw = (record: TermsOfService, raw: RawTermsOfService) => { + return raw.acceptedAt === record.acceptedAt; +}; + +export const isRecordDraftEqualToRaw = (record: Draft, raw: RawDraft) => { + return raw.channel_id === record.channelId; +}; + +export const isRecordPostEqualToRaw = (record: Post, raw: RawPost) => { + return raw.id === record.id; +}; + +export const isRecordUserEqualToRaw = (record: User, raw: RawUser) => { + return raw.id === record.id; +}; + +export const isRecordPreferenceEqualToRaw = (record: Preference, raw: RawPreference) => { + return ( + raw.category === record.category && + raw.name === record.name && + raw.user_id === record.userId && + raw.value === record.value + ); +}; + +export const isRecordTeamMembershipEqualToRaw = (record: TeamMembership, raw: RawTeamMembership) => { + return raw.team_id === record.teamId && raw.user_id === record.userId; +}; + +export const isRecordCustomEmojiEqualToRaw = (record: CustomEmoji, raw: RawCustomEmoji) => { + return raw.name === record.name; +}; + +export const isRecordGroupMembershipEqualToRaw = (record: GroupMembership, raw: RawGroupMembership) => { + return raw.user_id === record.userId && raw.group_id === record.groupId; +}; + +export const isRecordChannelMembershipEqualToRaw = (record: ChannelMembership, raw: RawChannelMembership) => { + return raw.user_id === record.userId && raw.channel_id === record.channelId; +}; diff --git a/app/database/admin/data_operator/handlers/index.ts b/app/database/admin/data_operator/handlers/index.ts index ac94cf2ca5..185ddec9cf 100644 --- a/app/database/admin/data_operator/handlers/index.ts +++ b/app/database/admin/data_operator/handlers/index.ts @@ -5,748 +5,1055 @@ import {Database, Q} from '@nozbe/watermelondb'; import Model from '@nozbe/watermelondb/Model'; import {MM_TABLES} from '@constants/database'; -import DatabaseManager from '@database/admin/database_manager'; -import logger from '@nozbe/watermelondb/utils/common/logger'; import { - BatchOperations, DatabaseInstance, - ExecuteRecords, - HandleFiles, - HandleIsolatedEntityData, - HandlePostMetadata, - HandlePosts, - HandleReactions, + isRecordAppEqualToRaw, + isRecordChannelMembershipEqualToRaw, + isRecordCustomEmojiEqualToRaw, + isRecordDraftEqualToRaw, + isRecordGlobalEqualToRaw, + isRecordGroupMembershipEqualToRaw, + isRecordPostEqualToRaw, + isRecordPreferenceEqualToRaw, + isRecordRoleEqualToRaw, + isRecordServerEqualToRaw, + isRecordSystemEqualToRaw, + isRecordTeamMembershipEqualToRaw, + isRecordTermsOfServiceEqualToRaw, + isRecordUserEqualToRaw, +} from '@database/admin/data_operator/comparators'; +import DatabaseManager from '@database/admin/database_manager'; +import CustomEmoji from '@typings/database/custom_emoji'; +import { + BatchOperationsArgs, + DatabaseInstance, + ProcessInputsArgs, + HandleEntityRecordsArgs, + HandleFilesArgs, + HandleIsolatedEntityArgs, + HandlePostMetadataArgs, + HandlePostsArgs, + HandleReactionsArgs, + MatchExistingRecord, PostImage, - PrepareRecords, + PrepareForDatabaseArgs, + PrepareRecordsArgs, + RawChannelMembership, RawCustomEmoji, RawDraft, RawEmbed, RawFile, + RawGroupMembership, RawPost, RawPostMetadata, RawPostsInThread, + RawPreference, RawReaction, + RawTeamMembership, + RawUser, + RawValue, } from '@typings/database/database'; +import {IsolatedEntities, OperationType} from '@typings/database/enums'; import File from '@typings/database/file'; -import CustomEmoji from '@typings/database/custom_emoji'; -import {IsolatedEntities} from '@typings/database/enums'; import Post from '@typings/database/post'; import PostMetadata from '@typings/database/post_metadata'; import PostsInChannel from '@typings/database/posts_in_channel'; import PostsInThread from '@typings/database/posts_in_thread'; import Reaction from '@typings/database/reaction'; +import DataOperatorException from '../../exceptions/data_operator_exception'; import DatabaseConnectionException from '../../exceptions/database_connection_exception'; -import DatabaseOperatorException from '../../exceptions/database_operator_exception'; import { operateAppRecord, + operateChannelMembershipRecord, operateCustomEmojiRecord, operateDraftRecord, operateFileRecord, operateGlobalRecord, + operateGroupMembershipRecord, operatePostInThreadRecord, operatePostMetadataRecord, operatePostRecord, operatePostsInChannelRecord, + operatePreferenceRecord, operateReactionRecord, operateRoleRecord, operateServersRecord, operateSystemRecord, + operateTeamMembershipRecord, operateTermsOfServiceRecord, + operateUserRecord, } from '../operators'; -import {createPostsChain, sanitizePosts, sanitizeReactions} from '../utils'; +import { + createPostsChain, + getRangeOfValues, + getRawRecordPairs, + hasSimilarUpdateAt, + retrieveRecords, + sanitizePosts, + sanitizeReactions, +} from '../utils'; const { + CHANNEL_MEMBERSHIP, CUSTOM_EMOJI, DRAFT, FILE, + GROUP_MEMBERSHIP, POST, - POST_METADATA, - POSTS_IN_THREAD, POSTS_IN_CHANNEL, + POSTS_IN_THREAD, + POST_METADATA, + PREFERENCE, REACTION, + TEAM_MEMBERSHIP, + USER, } = MM_TABLES.SERVER; -if (!__DEV__) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - logger.silence(); -} - class DataOperator { /** * serverDatabase : In a multi-server configuration, this connection will be used by WebSockets and other parties to update databases other than the active one. * @type {DatabaseInstance} */ - serverDatabase: DatabaseInstance + serverDatabase: DatabaseInstance; constructor(serverDatabase?: Database) { this.serverDatabase = serverDatabase; } - /** - * handleIsolatedEntity: Handler responsible for the Create/Update operations on the isolated entities as described - * by the IsolatedEntities enum - * @param {HandleIsolatedEntityData} entityData - * @param {IsolatedEntities} entityData.tableName - * @param {Records} entityData.values - * @returns {Promise} - */ - handleIsolatedEntity = async ({tableName, values}: HandleIsolatedEntityData) => { - let recordOperator; - - if (!values.length) { - throw new DatabaseOperatorException( - 'An empty "values" array has been passed to the handleIsolatedEntity method', - ); - } - - switch (tableName) { - case IsolatedEntities.APP: { - recordOperator = operateAppRecord; - break; - } - case IsolatedEntities.GLOBAL: { - recordOperator = operateGlobalRecord; - break; - } - case IsolatedEntities.SERVERS: { - recordOperator = operateServersRecord; - break; - } - case IsolatedEntities.CUSTOM_EMOJI: { - recordOperator = operateCustomEmojiRecord; - break; - } - case IsolatedEntities.ROLE: { - recordOperator = operateRoleRecord; - break; - } - case IsolatedEntities.SYSTEM: { - recordOperator = operateSystemRecord; - break; - } - case IsolatedEntities.TERMS_OF_SERVICE: { - recordOperator = operateTermsOfServiceRecord; - break; - } - default: { - recordOperator = null; - break; - } - } - - if (recordOperator) { - await this.executeInDatabase({values, tableName, recordOperator}); - } - }; - - /** - * handleDraft: Handler responsible for the Create/Update operations occurring the Draft entity from the 'Server' schema - * @param {RawDraft[]} drafts - * @returns {Promise} - */ - handleDraft = async (drafts: RawDraft[]) => { - if (!drafts.length) { - throw new DatabaseOperatorException( - 'An empty "drafts" array has been passed to the handleReactions method', - ); - } - - await this.executeInDatabase({ - values: drafts, - tableName: DRAFT, - recordOperator: operateDraftRecord, - }); - }; - - /** - * handleReactions: Handler responsible for the Create/Update operations occurring on the Reaction entity from the 'Server' schema - * @param {HandleReactions} handleReactions - * @param {RawReaction[]} handleReactions.reactions - * @param {boolean} handleReactions.prepareRowsOnly - * @returns {Promise<[] | (Reaction | CustomEmoji)[]>} - */ - handleReactions = async ({reactions, prepareRowsOnly}: HandleReactions) => { - if (!reactions.length) { - throw new DatabaseOperatorException( - 'An empty "reactions" array has been passed to the handleReactions method', - ); - } - - const database = await this.getDatabase(REACTION); - - const { - createEmojis, - createReactions, - deleteReactions, - } = await sanitizeReactions({ - database, - post_id: reactions[0].post_id, - rawReactions: reactions, - }); - - let batchRecords: Model[] = []; - - // Prepares record for model Reactions - if (createReactions.length) { - const reactionsRecords = ((await this.prepareRecords({ - database, - recordOperator: operateReactionRecord, - tableName: REACTION, - values: createReactions, - })) as unknown) as Reaction[]; - batchRecords = batchRecords.concat(reactionsRecords); - } - - // Prepares records for model CustomEmoji - if (createEmojis.length) { - const emojiRecords = ((await this.prepareRecords({ - database, - recordOperator: operateCustomEmojiRecord, - tableName: CUSTOM_EMOJI, - values: createEmojis as RawCustomEmoji[], - })) as unknown) as CustomEmoji[]; - batchRecords = batchRecords.concat(emojiRecords); - } - - batchRecords = batchRecords.concat(deleteReactions); - - if (prepareRowsOnly) { - return batchRecords; - } - - if (batchRecords?.length) { - await this.batchOperations({ - database, - models: batchRecords, - }); - } - - return []; - }; - - /** - * handlePosts: Handler responsible for the Create/Update operations occurring on the Post entity from the 'Server' schema - * @param {HandlePosts} handlePosts - * @param {string[]} handlePosts.orders - * @param {RawPost[]} handlePosts.values - * @param {string | undefined} handlePosts.previousPostId - * @returns {Promise} - */ - handlePosts = async ({orders, values, previousPostId}: HandlePosts) => { - const tableName = POST; - - // We rely on the order array; if it is empty, we stop processing - if (!orders.length) { - throw new DatabaseOperatorException( - 'An empty "order" array has been passed to the HandlePosts method', - ); - } - - let batch: Model[] = []; - let files: RawFile[] = []; - const postsInThread = []; - let reactions: RawReaction[] = []; - let emojis: RawCustomEmoji[] = []; - const images: { images: Dictionary; postId: string }[] = []; - const embeds: { embed: RawEmbed[]; postId: string }[] = []; - - // We treat those posts who are present in the order array only - const {orderedPosts, unOrderedPosts} = sanitizePosts({ - posts: values, - orders, - }); - - // We create the 'chain of posts' by linking each posts' previousId to the post before it in the order array - const linkedRawPosts: RawPost[] = createPostsChain({ - orders, - previousPostId: previousPostId || '', - rawPosts: orderedPosts, - }); - - const database = await this.getDatabase(tableName); - - // Prepares records for batch processing onto the 'Post' entity for the server schema - const posts = ((await this.prepareRecords({ - database, - tableName, - values: [...linkedRawPosts, ...unOrderedPosts], - recordOperator: operatePostRecord, - })) as unknown) as Post[]; - - // Appends the processed records into the final batch array - batch = batch.concat(posts); - - // Starts extracting information from each post to build up for related entities' data - for (let i = 0; i < orderedPosts.length; i++) { - const post = orderedPosts[i] as RawPost; - - // PostsInChannel - a root post has an empty root_id value - if (!post.root_id) { - postsInThread.push({ - earliest: post.create_at, - post_id: post.id, - }); - } - - if (post?.metadata && Object.keys(post?.metadata).length > 0) { - const metadata = post.metadata; - - // Extracts reaction from post's metadata - reactions = reactions.concat(metadata?.reactions ?? []); - - // Extracts emojis from post's metadata - emojis = emojis.concat(metadata?.emojis ?? []); - - // Extracts files from post's metadata - files = files.concat(metadata?.files ?? []); - - // Extracts images and embeds from post's metadata - if (metadata?.images) { - images.push({images: metadata.images, postId: post.id}); - } - - if (metadata?.embeds) { - embeds.push({embed: metadata.embeds, postId: post.id}); - } - } - } - - if (reactions.length) { - // calls handler for Reactions - const postReactions = (await this.handleReactions({ - reactions, - prepareRowsOnly: true, - })) as Reaction[]; - batch = batch.concat(postReactions); - } - - if (files.length) { - // calls handler for Files - const postFiles = (await this.handleFiles({ - files, - prepareRowsOnly: true, - })) as File[]; - batch = batch.concat(postFiles); - } - - if (images.length || embeds.length) { - // calls handler for postMetadata ( embeds and images ) - const postMetadata = (await this.handlePostMetadata({ - images, - embeds, - prepareRowsOnly: true, - })) as PostMetadata[]; - batch = batch.concat(postMetadata); - } - - if (batch.length) { - await this.batchOperations({database, models: batch}); - } - - // LAST: calls handler for CustomEmojis, PostsInThread, PostsInChannel - await this.handleIsolatedEntity({ - tableName: IsolatedEntities.CUSTOM_EMOJI, - values: emojis, - }); - - if (postsInThread.length) { - await this.handlePostsInThread(postsInThread); - } - - if (orderedPosts.length) { - await this.handlePostsInChannel(orderedPosts); - } - }; - - /** - * handleFiles: Handler responsible for the Create/Update operations occurring on the File entity from the 'Server' schema - * @param {HandleFiles} handleFiles - * @param {RawFile[]} handleFiles.files - * @param {boolean} handleFiles.prepareRowsOnly - * @returns {Promise} - */ - private handleFiles = async ({files, prepareRowsOnly}: HandleFiles) => { - if (!files.length) { - throw new DatabaseOperatorException( - 'An empty "files" array has been passed to the handleFiles method', - ); - } - - const database = await this.getDatabase(FILE); - - const postFiles = ((await this.prepareRecords({ - database, - recordOperator: operateFileRecord, - tableName: FILE, - values: files, - })) as unknown) as File[]; - - if (prepareRowsOnly) { - return postFiles; - } - - if (postFiles?.length) { - await this.batchOperations({database, models: [...postFiles]}); - } - - return []; - }; - - /** - * handlePostMetadata: Handler responsible for the Create/Update operations occurring on the PostMetadata entity from the 'Server' schema - * @param {HandlePostMetadata} handlePostMetadata - * @param {{embed: RawEmbed[], postId: string}[] | undefined} handlePostMetadata.embeds - * @param {{images: Dictionary, postId: string}[] | undefined} handlePostMetadata.images - * @param {boolean} handlePostMetadata.prepareRowsOnly - * @returns {Promise} - */ - private handlePostMetadata = async ({embeds, images, prepareRowsOnly}: HandlePostMetadata) => { - const metadata: RawPostMetadata[] = []; - - if (images?.length) { - images.forEach((image) => { - const imageEntry = Object.entries(image.images); - metadata.push({ - data: {...imageEntry?.[0]?.[1], url: imageEntry?.[0]?.[0]}, - type: 'images', - postId: image.postId, - }); - }); - } - - if (embeds?.length) { - embeds.forEach((postEmbed) => { - postEmbed.embed.forEach((embed: RawEmbed) => { - metadata.push({ - data: {...embed.data}, - type: embed.type, - postId: postEmbed.postId, - }); - }); - }); - } - - if (!metadata.length) { - return []; - } - - const database = await this.getDatabase(POST_METADATA); - - const postMetas = ((await this.prepareRecords({ - database, - recordOperator: operatePostMetadataRecord, - tableName: POST_METADATA, - values: metadata, - })) as unknown) as PostMetadata[]; - - if (prepareRowsOnly) { - return postMetas; - } - - if (postMetas?.length) { - await this.batchOperations({database, models: [...postMetas]}); - } - - return []; - }; - - /** - * handlePostsInThread: Handler responsible for the Create/Update operations occurring on the PostsInThread entity from the 'Server' schema - * @param {RawPostsInThread[]} rootPosts - * @returns {Promise} - */ - private handlePostsInThread = async (rootPosts: RawPostsInThread[]) => { - if (!rootPosts.length) { - throw new DatabaseOperatorException( - 'An empty "rootPosts" array has been passed to the handlePostsInThread method', - ); - } - - // Creates an array of post ids - const postIds = rootPosts.map((postThread) => postThread.post_id); - - const rawPostsInThreads: RawPostsInThread[] = []; - - const database = await this.getDatabase(POSTS_IN_THREAD); - - // Retrieves all threads whereby their root_id can be one of the element in the postIds array - const threads = (await database.collections. - get(POST). - query(Q.where('root_id', Q.oneOf(postIds))). - fetch()) as Post[]; - - // The aim here is to find the last reply in that thread; hence the latest create_at value - rootPosts.forEach((rootPost) => { - const childPosts = []; - let maxCreateAt = 0; - for (let i = 0; i < threads.length; i++) { - const thread = threads[i]; - if (thread?.rootId === rootPost.post_id) { - // Creates a sub-array of threads relating to rootPost.post_id - childPosts.push(thread); - } - - // Retrieves max createAt date of all posts whose root_id is rootPost.post_id - maxCreateAt = thread.createAt > maxCreateAt ? thread.createAt : maxCreateAt; - } - - // Collects all 'raw' postInThreads objects that will be sent to the operatePostsInThread function - rawPostsInThreads.push({...rootPost, latest: maxCreateAt}); - }); - - if (rawPostsInThreads.length) { - const postInThreadRecords = ((await this.prepareRecords({ - database, - recordOperator: operatePostInThreadRecord, - tableName: POSTS_IN_THREAD, - values: rawPostsInThreads, - })) as unknown) as PostsInThread[]; - - if (postInThreadRecords?.length) { - await this.batchOperations({database, models: postInThreadRecords}); - } - } - }; - - /** - * handlePostsInChannel: Handler responsible for the Create/Update operations occurring on the PostsInChannel entity from the 'Server' schema - * @param {RawPost[]} posts - * @returns {Promise} - */ - private handlePostsInChannel = async (posts: RawPost[]) => { - // At this point, the parameter 'posts' is already a chain of posts. Now, we have to figure out how to plug it - // into existing chains in the PostsInChannel table - if (!posts.length) { - throw new DatabaseOperatorException( - 'An empty "posts" array has been passed to the handlePostsInChannel method', - ); - } - - // Sort a clone of 'posts' array by create_at ( oldest to newest ) - const sortedPosts = [...posts].sort((a, b) => { - return a.create_at - b.create_at; - }); - - // The first element (beginning of chain) - const tipOfChain: RawPost = sortedPosts[0]; - - // Channel Id for this chain of posts - const channelId = tipOfChain.channel_id; - - // Find smallest 'create_at' value in chain - const earliest = tipOfChain.create_at; - - // Find highest 'create_at' value in chain - const latest = sortedPosts[sortedPosts.length - 1].create_at; - - const database = await this.getDatabase(POSTS_IN_CHANNEL); - - // Find the records in the PostsInChannel table that have a matching channel_id - const chunks = (await database.collections. - get(POSTS_IN_CHANNEL). - query(Q.where('channel_id', channelId)). - fetch()) as PostsInChannel[]; - - const createPostsInChannelRecord = async () => { - await this.executeInDatabase({ - values: [{channel_id: channelId, earliest, latest}], - tableName: POSTS_IN_CHANNEL, - recordOperator: operatePostsInChannelRecord, - }); - }; - - // chunk length 0; then it's a new chunk to be added to the PostsInChannel table - if (chunks.length === 0) { - await createPostsInChannelRecord(); - return; - } - - // Sort chunks (in-place) by earliest field ( oldest to newest ) - chunks.sort((a, b) => { - return a.earliest - b.earliest; - }); - - let found = false; - let targetChunk: PostsInChannel; - for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { - // find if we should plug the chain before - const chunk = chunks[chunkIndex]; - if (earliest < chunk.earliest) { - found = true; - targetChunk = chunk; - } - - if (found) { - break; - } - } - - if (found) { - // We have a potential chunk to plug nearby - const potentialPosts = (await database.collections. - get(POST). - query(Q.where('create_at', earliest)). - fetch()) as Post[]; - - if (potentialPosts?.length > 0) { - const targetPost = potentialPosts[0]; - - // now we decide if we need to operate on the targetChunk or just create a new chunk - const isChainable = tipOfChain.prev_post_id === targetPost.previousPostId; - - if (isChainable) { - // Update this chunk's data in PostsInChannel table. earliest comes from tipOfChain while latest comes from chunk - await database.action(async () => { - await targetChunk.update((postInChannel) => { - postInChannel.earliest = earliest; - }); - }); - } else { - await createPostsInChannelRecord(); - } - } - } else { - await createPostsInChannelRecord(); - } - }; - - /** - * batchOperations: Accepts an instance of Database (either Default or Server) and an array of - * prepareCreate/prepareUpdate 'models' and executes the actions on the database. - * @param {BatchOperations} operation - * @param {Database} operation.database - * @param {Array} operation.models - * @returns {Promise} - */ - private batchOperations = async ({database, models}: BatchOperations) => { - try { - if (models.length > 0) { - await database.action(async () => { - await database.batch(...models); - }); - } else { - throw new DatabaseOperatorException( - 'batchOperations does not process empty model array', - ); - } - } catch (e) { - throw new DatabaseOperatorException('batchOperations error ', e); - } - }; - - /** - * prepareRecords: This method loops over the raw data, onto which it calls the assigned operator finally produce an array of prepareUpdate/prepareCreate objects - * @param {PrepareRecords} prepareRecords - * @param {Database} prepareRecords.database - * @param {RecordValue[]} prepareRecords.values - * @param {RecordOperator} prepareRecords.recordOperator - * @returns {Promise} - */ - private prepareRecords = async ({database, values, recordOperator}: PrepareRecords) => { - if (!values.length) { - return []; - } - - if (!Array.isArray(values) || !values?.length || !database) { - throw new DatabaseOperatorException( - 'prepareRecords accepts only values of type RecordValue[] or valid database connection', - ); - } - - const recordPromises = await values.map(async (value) => { - const record = await recordOperator({database, value}); - return record; - }); - - const results = await Promise.all(recordPromises); - return results; - }; - - /** - * executeInDatabase: This method uses the prepare records from the 'prepareRecords' method and send them as one transaction to the database. - * @param {ExecuteRecords} executor - * @param {RecordOperator} executor.recordOperator - * @param {string} executor.tableName - * @param {RecordValue[]} executor.values - * @returns {Promise} - */ - private executeInDatabase = async ({recordOperator, tableName, values}: ExecuteRecords) => { - if (!values.length) { - throw new DatabaseOperatorException( - 'An empty "values" array has been passed to the executeInDatabase method', - ); - } - - const database = await this.getDatabase(tableName); - - const models = ((await this.prepareRecords({ - database, - recordOperator, - tableName, - values, - })) as unknown) as Model[]; - - if (models?.length > 0) { - await this.batchOperations({database, models}); - } - }; - - /** - * getDatabase: Based on the table's name, it will return a database instance either from the 'DEFAULT' database or - * the 'SERVER' database - * @param {string} tableName - * @returns {Promise} - */ - private getDatabase = async (tableName: string) => { - const isDefaultConnection = Object.values(MM_TABLES.DEFAULT).some( - (tbName) => { - return tableName === tbName; - }, - ); - - const promise = isDefaultConnection ? this.getDefaultDatabase : this.getServerDatabase; - const connection = await promise(); - - return connection; - }; - - /** - * getDefaultDatabase: Returns the default database - * @returns {Promise} - */ - private getDefaultDatabase = async () => { - const connection = await DatabaseManager.getDefaultDatabase(); - if (connection === undefined) { - throw new DatabaseConnectionException( - 'An error occurred while retrieving the default database', - '', - ); - } - return connection; - }; - - /** - * getServerDatabase: Returns the current active server database or if a connection is passed to the constructor, it will return that one. - * @returns {Promise} - */ - private getServerDatabase = async () => { - // Third parties trying to update the database - if (this.serverDatabase) { - return this.serverDatabase; - } - - // NOTE: here we are getting the active server directly as in a multi-server support system, the current - // active server connection will already be set on application init - const connection = await DatabaseManager.getActiveServerDatabase(); - if (connection === undefined) { - throw new DatabaseConnectionException( - 'An error occurred while retrieving the server database', - '', - ); - } - return connection; - }; + /** + * handleIsolatedEntity: Handler responsible for the Create/Update operations on the isolated entities as described + * by the IsolatedEntities enum + * @param {HandleIsolatedEntityArgs} entityData + * @param {IsolatedEntities} entityData.tableName + * @param {Records} entityData.values + * @returns {Promise} + */ + handleIsolatedEntity = async ({tableName, values}: HandleIsolatedEntityArgs) => { + let comparator; + let fieldName; + let operator; + + if (!values.length) { + throw new DataOperatorException( + `An empty "values" array has been passed to the handleIsolatedEntity method for entity ${tableName}`, + ); + } + + switch (tableName) { + case IsolatedEntities.APP: { + comparator = isRecordAppEqualToRaw; + fieldName = 'version_number'; + operator = operateAppRecord; + break; + } + case IsolatedEntities.CUSTOM_EMOJI: { + comparator = isRecordCustomEmojiEqualToRaw; + fieldName = 'name'; + operator = operateCustomEmojiRecord; + break; + } + case IsolatedEntities.GLOBAL: { + comparator = isRecordGlobalEqualToRaw; + fieldName = 'name'; + operator = operateGlobalRecord; + break; + } + case IsolatedEntities.ROLE: { + comparator = isRecordRoleEqualToRaw; + fieldName = 'name'; + operator = operateRoleRecord; + break; + } + case IsolatedEntities.SERVERS: { + comparator = isRecordServerEqualToRaw; + fieldName = 'db_path'; + operator = operateServersRecord; + break; + } + case IsolatedEntities.SYSTEM: { + comparator = isRecordSystemEqualToRaw; + fieldName = 'name'; + operator = operateSystemRecord; + break; + } + case IsolatedEntities.TERMS_OF_SERVICE: { + comparator = isRecordTermsOfServiceEqualToRaw; + fieldName = 'accepted_at'; + operator = operateTermsOfServiceRecord; + break; + } + default: { + throw new DataOperatorException( + `handleIsolatedEntity was called with an invalid table name ${tableName}`, + ); + } + } + + if (operator && fieldName && comparator) { + await this.handleEntityRecords({ + comparator, + fieldName, + operator, + rawValues: values, + tableName, + }); + } + }; + + /** + * handleDraft: Handler responsible for the Create/Update operations occurring the Draft entity from the 'Server' schema + * @param {RawDraft[]} drafts + * @returns {Promise} + */ + handleDraft = async (drafts: RawDraft[]) => { + if (!drafts.length) { + throw new DataOperatorException( + 'An empty "drafts" array has been passed to the handleReactions method', + ); + } + + await this.handleEntityRecords({ + comparator: isRecordDraftEqualToRaw, + fieldName: 'channel_id', + operator: operateDraftRecord, + rawValues: drafts, + tableName: DRAFT, + }); + }; + + /** + * handleReactions: Handler responsible for the Create/Update operations occurring on the Reaction entity from the 'Server' schema + * @param {HandleReactionsArgs} handleReactions + * @param {RawReaction[]} handleReactions.reactions + * @param {boolean} handleReactions.prepareRowsOnly + * @throws DataOperatorException + * @returns {Promise<[] | (Reaction | CustomEmoji)[]>} + */ + handleReactions = async ({reactions, prepareRowsOnly}: HandleReactionsArgs) => { + if (!reactions.length) { + throw new DataOperatorException( + 'An empty "reactions" array has been passed to the handleReactions method', + ); + } + + const database = await this.getDatabase(REACTION); + + const { + createEmojis, + createReactions, + deleteReactions, + } = await sanitizeReactions({ + database, + post_id: reactions[0].post_id, + rawReactions: reactions, + }); + + let batchRecords: Model[] = []; + + if (createReactions.length) { + // Prepares record for model Reactions + const reactionsRecords = (await this.prepareRecords({ + createRaws: createReactions, + database, + recordOperator: operateReactionRecord, + tableName: REACTION, + })) as Reaction[]; + batchRecords = batchRecords.concat(reactionsRecords); + } + + if (createEmojis.length) { + // Prepares records for model CustomEmoji + const emojiRecords = (await this.prepareRecords({ + createRaws: getRawRecordPairs(createEmojis), + database, + recordOperator: operateCustomEmojiRecord, + tableName: CUSTOM_EMOJI, + })) as CustomEmoji[]; + batchRecords = batchRecords.concat(emojiRecords); + } + + batchRecords = batchRecords.concat(deleteReactions); + + if (prepareRowsOnly) { + return batchRecords; + } + + if (batchRecords?.length) { + await this.batchOperations({ + database, + models: batchRecords, + }); + } + + return []; + }; + + /** + * handlePosts: Handler responsible for the Create/Update operations occurring on the Post entity from the 'Server' schema + * @param {HandlePostsArgs} handlePosts + * @param {string[]} handlePosts.orders + * @param {RawPost[]} handlePosts.values + * @param {string | undefined} handlePosts.previousPostId + * @returns {Promise} + */ + handlePosts = async ({orders, values, previousPostId}: HandlePostsArgs) => { + const tableName = POST; + + // We rely on the order array; if it is empty, we stop processing + if (!orders.length) { + throw new DataOperatorException( + 'An empty "order" array has been passed to the handlePosts method', + ); + } + + // By sanitizing the values, we are separating 'posts' that needs updating ( i.e. un-ordered posts ) from those that need to be created in our database + const {postsOrdered, postsUnordered} = sanitizePosts({ + posts: values, + orders, + }); + + // Here we verify in our database that the postsOrdered truly need 'CREATION' + const futureEntries = await this.processInputs({ + rawValues: postsOrdered, + tableName, + comparator: isRecordPostEqualToRaw, + fieldName: 'id', + }); + + if (futureEntries.createRaws?.length) { + let batch: Model[] = []; + let files: RawFile[] = []; + const postsInThread = []; + let reactions: RawReaction[] = []; + let emojis: RawCustomEmoji[] = []; + const images: { images: Dictionary; postId: string }[] = []; + const embeds: { embed: RawEmbed[]; postId: string }[] = []; + + // We create the 'chain of posts' by linking each posts' previousId to the post before it in the order array + const linkedRawPosts: MatchExistingRecord[] = createPostsChain({ + orders, + previousPostId: previousPostId || '', + rawPosts: postsOrdered, + }); + + const database = await this.getDatabase(tableName); + + // Prepares records for batch processing onto the 'Post' entity for the server schema + const posts = (await this.prepareRecords({ + createRaws: linkedRawPosts, + database, + recordOperator: operatePostRecord, + tableName, + }))as Post[]; + + // Appends the processed records into the final batch array + batch = batch.concat(posts); + + // Starts extracting information from each post to build up for related entities' data + for (let i = 0; i < postsOrdered.length; i++) { + const post = postsOrdered[i] as RawPost; + + // PostInThread handler: checks for id === root_id , if so, then call PostsInThread operator + if (!post.root_id) { + postsInThread.push({ + earliest: post.create_at, + post_id: post.id, + }); + } + + if (post?.metadata && Object.keys(post?.metadata).length > 0) { + const metadata = post.metadata; + + // Extracts reaction from post's metadata + reactions = reactions.concat(metadata?.reactions ?? []); + + // Extracts emojis from post's metadata + emojis = emojis.concat(metadata?.emojis ?? []); + + // Extracts files from post's metadata + files = files.concat(metadata?.files ?? []); + + // Extracts images and embeds from post's metadata + if (metadata?.images) { + images.push({images: metadata.images, postId: post.id}); + } + + if (metadata?.embeds) { + embeds.push({embed: metadata.embeds, postId: post.id}); + } + } + } + + if (reactions.length) { + // calls handler for Reactions + const postReactions = (await this.handleReactions({ + reactions, + prepareRowsOnly: true, + })) as Reaction[]; + + batch = batch.concat(postReactions); + } + + if (files.length) { + // calls handler for Files + const postFiles = (await this.handleFiles({ + files, + prepareRowsOnly: true, + })) as File[]; + + batch = batch.concat(postFiles); + } + + if (images.length || embeds.length) { + // calls handler for postMetadata ( embeds and images ) + const postMetadata = (await this.handlePostMetadata({ + images, + embeds, + prepareRowsOnly: true, + })) as PostMetadata[]; + + batch = batch.concat(postMetadata); + } + + if (batch.length) { + await this.batchOperations({database, models: batch}); + } + + // LAST: calls handler for CustomEmojis, PostsInThread, PostsInChannel + if (emojis.length) { + await this.handleIsolatedEntity({tableName: IsolatedEntities.CUSTOM_EMOJI, values: emojis}); + } + + if (postsInThread.length) { + await this.handlePostsInThread(postsInThread); + } + + if (postsOrdered.length) { + await this.handlePostsInChannel(postsOrdered); + } + } + + if (postsUnordered.length) { + // Truly update those posts that have a different update_at value + await this.handleEntityRecords({ + comparator: isRecordPostEqualToRaw, + fieldName: 'id', + operator: operatePostRecord, + rawValues: postsUnordered, + tableName: POST, + }); + } + }; + + /** + * handleFiles: Handler responsible for the Create/Update operations occurring on the File entity from the 'Server' schema + * @param {HandleFilesArgs} handleFiles + * @param {RawFile[]} handleFiles.files + * @param {boolean} handleFiles.prepareRowsOnly + * @returns {Promise} + */ + private handleFiles = async ({files, prepareRowsOnly}: HandleFilesArgs) => { + if (!files.length) { + return []; + } + + const database = await this.getDatabase(FILE); + + const postFiles = (await this.prepareRecords({ + createRaws: getRawRecordPairs(files), + database, + recordOperator: operateFileRecord, + tableName: FILE, + })) as File[]; + + if (prepareRowsOnly) { + return postFiles; + } + + if (postFiles?.length) { + await this.batchOperations({database, models: [...postFiles]}); + } + + return []; + }; + + /** + * handlePostMetadata: Handler responsible for the Create/Update operations occurring on the PostMetadata entity from the 'Server' schema + * @param {HandlePostMetadataArgs} handlePostMetadata + * @param {{embed: RawEmbed[], postId: string}[] | undefined} handlePostMetadata.embeds + * @param {{images: Dictionary, postId: string}[] | undefined} handlePostMetadata.images + * @param {boolean} handlePostMetadata.prepareRowsOnly + * @returns {Promise} + */ + private handlePostMetadata = async ({embeds, images, prepareRowsOnly}: HandlePostMetadataArgs) => { + const metadata: RawPostMetadata[] = []; + + if (images?.length) { + images.forEach((image) => { + const imageEntry = Object.entries(image.images); + metadata.push({ + data: {...imageEntry?.[0]?.[1], url: imageEntry?.[0]?.[0]}, + type: 'images', + postId: image.postId, + }); + }); + } + + if (embeds?.length) { + embeds.forEach((postEmbed) => { + postEmbed.embed.forEach((embed: RawEmbed) => { + metadata.push({ + data: {...embed.data}, + type: embed.type, + postId: postEmbed.postId, + }); + }); + }); + } + + if (!metadata.length) { + return []; + } + + const database = await this.getDatabase(POST_METADATA); + + const postMetas = (await this.prepareRecords({ + createRaws: getRawRecordPairs(metadata), + database, + recordOperator: operatePostMetadataRecord, + tableName: POST_METADATA, + })) as PostMetadata[]; + + if (prepareRowsOnly) { + return postMetas; + } + + if (postMetas?.length) { + await this.batchOperations({database, models: [...postMetas]}); + } + + return []; + }; + + /** + * handlePostsInThread: Handler responsible for the Create/Update operations occurring on the PostsInThread entity from the 'Server' schema + * @param {RawPostsInThread[]} rootPosts + * @returns {Promise} + */ + private handlePostsInThread = async (rootPosts: RawPostsInThread[]) => { + if (!rootPosts.length) { + return; + } + + const postIds = rootPosts.map((postThread) => postThread.post_id); + const rawPostsInThreads: RawPostsInThread[] = []; + + const database = await this.getDatabase(POSTS_IN_THREAD); + + // Retrieves all threads whereby their root_id can be one of the element in the postIds array + const threads = (await database.collections. + get(POST). + query(Q.where('root_id', Q.oneOf(postIds))). + fetch()) as Post[]; + + // The aim here is to find the last reply in that thread; hence the latest create_at value + rootPosts.forEach((rootPost) => { + const childPosts = []; + let maxCreateAt = 0; + for (let i = 0; i < threads.length; i++) { + const thread = threads[i]; + if (thread?.rootId === rootPost.post_id) { + // Creates a sub-array of threads relating to rootPost.post_id + childPosts.push(thread); + } + + // Retrieves max createAt date of all posts whose root_id is rootPost.post_id + maxCreateAt = thread.createAt > maxCreateAt ? thread.createAt : maxCreateAt; + } + + // Collects all 'raw' postInThreads objects that will be sent to the operatePostsInThread function + rawPostsInThreads.push({...rootPost, latest: maxCreateAt}); + }); + + if (rawPostsInThreads.length) { + const postInThreadRecords = (await this.prepareRecords({ + createRaws: getRawRecordPairs(rawPostsInThreads), + database, + recordOperator: operatePostInThreadRecord, + tableName: POSTS_IN_THREAD, + })) as PostsInThread[]; + + if (postInThreadRecords?.length) { + await this.batchOperations({database, models: postInThreadRecords}); + } + } + }; + + /** + * handlePostsInChannel: Handler responsible for the Create/Update operations occurring on the PostsInChannel entity from the 'Server' schema + * @param {RawPost[]} posts + * @returns {Promise} + */ + private handlePostsInChannel = async (posts: RawPost[]) => { + // At this point, the parameter 'posts' is already a chain of posts. Now, we have to figure out how to plug it + // into existing chains in the PostsInChannel table + + if (!posts.length) { + return []; + } + + // Sort a clone of 'posts' array by create_at + const sortedPosts = [...posts].sort((a, b) => { + return a.create_at - b.create_at; + }); + + // The first element (beginning of chain) + const tipOfChain: RawPost = sortedPosts[0]; + + // Channel Id for this chain of posts + const channelId = tipOfChain.channel_id; + + // Find smallest 'create_at' value in chain + const earliest = tipOfChain.create_at; + + // Find highest 'create_at' value in chain; -1 means we are dealing with one item in the posts array + const latest = sortedPosts[sortedPosts.length - 1].create_at; + + const database = await this.getDatabase(POSTS_IN_CHANNEL); + + // Find the records in the PostsInChannel table that have a matching channel_id + // const chunks = (await database.collections.get(POSTS_IN_CHANNEL).query(Q.where('channel_id', channelId)).fetch()) as PostsInChannel[]; + const chunks = (await retrieveRecords({ + database, + tableName: POSTS_IN_CHANNEL, + condition: Q.where('channel_id', channelId), + })) as PostsInChannel[]; + + const createPostsInChannelRecord = async () => { + const createPostsInChannel = {channel_id: channelId, earliest, latest}; + await this.executeInDatabase({ + createRaws: [{record: undefined, raw: createPostsInChannel}], + tableName: POSTS_IN_CHANNEL, + recordOperator: operatePostsInChannelRecord, + }); + }; + + // chunk length 0; then it's a new chunk to be added to the PostsInChannel table + if (chunks.length === 0) { + await createPostsInChannelRecord(); + return []; + } + + // Sort chunks (in-place) by earliest field ( oldest to newest ) + chunks.sort((a, b) => { + return a.earliest - b.earliest; + }); + + let found = false; + let targetChunk: PostsInChannel; + for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { + // find if we should plug the chain before + const chunk = chunks[chunkIndex]; + if (earliest < chunk.earliest) { + found = true; + targetChunk = chunk; + } + + if (found) { + break; + } + } + + if (found) { + // We have a potential chunk to plug nearby + const potentialPosts = await retrieveRecords({database, tableName: POST, condition: Q.where('create_at', earliest)}) as Post[]; + + if (potentialPosts?.length > 0) { + const targetPost = potentialPosts[0]; + + // now we decide if we need to operate on the targetChunk or just create a new chunk + const isChainable = tipOfChain.prev_post_id === targetPost.previousPostId; + + if (isChainable) { + // Update this chunk's data in PostsInChannel table. earliest comes from tipOfChain while latest comes from chunk + await database.action(async () => { + await targetChunk.update((postInChannel) => { + postInChannel.earliest = earliest; + }); + }); + } else { + await createPostsInChannelRecord(); + return []; + } + } + } else { + await createPostsInChannelRecord(); + return []; + } + + return []; + }; + + /** + * handleUsers: Handler responsible for the Create/Update operations occurring on the User entity from the 'Server' schema + * @param {RawUser[]} users + * @throws DataOperatorException + * @returns {Promise} + */ + handleUsers = async (users: RawUser[]) => { + if (!users.length) { + throw new DataOperatorException( + 'An empty "users" array has been passed to the handleUsers method', + ); + } + await this.handleEntityRecords({ + comparator: isRecordUserEqualToRaw, + fieldName: 'id', + operator: operateUserRecord, + rawValues: users, + tableName: USER, + }); + }; + + /** + * handlePreferences: Handler responsible for the Create/Update operations occurring on the PREFERENCE entity from the 'Server' schema + * @param {RawPreference[]} preferences + * @throws DataOperatorException + * @returns {Promise} + */ + handlePreferences = async (preferences: RawPreference[]) => { + if (!preferences.length) { + throw new DataOperatorException( + 'An empty "preferences" array has been passed to the handlePreferences method', + ); + } + + await this.handleEntityRecords({ + comparator: isRecordPreferenceEqualToRaw, + fieldName: 'user_id', + operator: operatePreferenceRecord, + rawValues: preferences, + tableName: PREFERENCE, + }); + }; + + /** + * handleTeamMemberships: Handler responsible for the Create/Update operations occurring on the TEAM_MEMBERSHIP entity from the 'Server' schema + * @param {RawTeamMembership[]} teamMemberships + * @throws DataOperatorException + * @returns {Promise} + */ + handleTeamMemberships = async (teamMemberships: RawTeamMembership[]) => { + if (!teamMemberships.length) { + throw new DataOperatorException( + 'An empty "teamMemberships" array has been passed to the handleTeamMemberships method', + ); + } + await this.handleEntityRecords({ + comparator: isRecordTeamMembershipEqualToRaw, + fieldName: 'user_id', + operator: operateTeamMembershipRecord, + rawValues: teamMemberships, + tableName: TEAM_MEMBERSHIP, + }); + }; + + /** + * handleGroupMembership: Handler responsible for the Create/Update operations occurring on the GROUP_MEMBERSHIP entity from the 'Server' schema + * @param {RawGroupMembership[]} groupMemberships + * @throws DataOperatorException + * @returns {Promise} + */ + handleGroupMembership = async (groupMemberships: RawGroupMembership[]) => { + if (!groupMemberships.length) { + throw new DataOperatorException( + 'An empty "groupMemberships" array has been passed to the handleGroupMembership method', + ); + } + + await this.handleEntityRecords({ + comparator: isRecordGroupMembershipEqualToRaw, + fieldName: 'user_id', + operator: operateGroupMembershipRecord, + rawValues: groupMemberships, + tableName: GROUP_MEMBERSHIP, + }); + }; + + /** + * handleChannelMembership: Handler responsible for the Create/Update operations occurring on the CHANNEL_MEMBERSHIP entity from the 'Server' schema + * @param {RawChannelMembership[]} channelMemberships + * @throws DataOperatorException + * @returns {Promise} + */ + handleChannelMembership = async (channelMemberships: RawChannelMembership[]) => { + if (!channelMemberships.length) { + throw new DataOperatorException( + 'An empty "channelMemberships" array has been passed to the handleChannelMembership method', + ); + } + + await this.handleEntityRecords({ + comparator: isRecordChannelMembershipEqualToRaw, + fieldName: 'user_id', + operator: operateChannelMembershipRecord, + rawValues: channelMemberships, + tableName: CHANNEL_MEMBERSHIP, + }); + }; + + /** + * handleEntityRecords : Utility that processes some entities' data against values already present in the database so as to avoid duplicity. + * @param {HandleEntityRecordsArgs} handleEntityRecords + * @param {(existing: Model, newElement: RawValue) => boolean} handleEntityRecords.comparator + * @param {string} handleEntityRecords.fieldName + * @param {(DataFactoryArgs) => Promise} handleEntityRecords.operator + * @param {RawValue[]} handleEntityRecords.rawValues + * @param {string} handleEntityRecords.tableName + * @returns {Promise} + */ + private handleEntityRecords = async ({comparator, fieldName, operator, rawValues, tableName}: HandleEntityRecordsArgs) => { + if (!rawValues.length) { + return null; + } + + const {createRaws, updateRaws} = await this.processInputs({ + rawValues, + tableName, + comparator, + fieldName, + }); + + const records = await this.executeInDatabase({ + recordOperator: operator, + tableName, + createRaws, + updateRaws, + }); + + return records; + }; + + // TODO : Add jest to processInputs + /** + * processInputs: 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 {ProcessInputsArgs} prepareRecords + * @param {RawValue[]} prepareRecords.rawValues + * @param {string} prepareRecords.tableName + * @param {string} prepareRecords.fieldName + * @param {(existing: Model, newElement: RawValue) => boolean} prepareRecords.comparator + */ + private processInputs = async ({rawValues, tableName, comparator, fieldName}: ProcessInputsArgs) => { + // We will query an entity 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}); + + const database = await this.getDatabase(tableName); + + const existingRecords = (await retrieveRecords({ + database, + tableName, + condition: Q.where(fieldName, Q.oneOf(columnValues)), + })) as Model[]; + + const createRaws: MatchExistingRecord[] = []; + const updateRaws: MatchExistingRecord[] = []; + + if (existingRecords.length > 0) { + rawValues.map((newElement: RawValue) => { + const findIndex = existingRecords.findIndex((existing) => { + return comparator(existing, newElement); + }); + + // We found a record in the database that matches this element; hence, we'll proceed for an UPDATE operation + if (findIndex > -1) { + const existingRecord = existingRecords[findIndex]; + + // 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 isUpdateAtSimilar = hasSimilarUpdateAt({ + tableName, + existingRecord, + newValue: newElement, + }); + + if (!isUpdateAtSimilar) { + return updateRaws.push({ + record: existingRecord, + raw: newElement, + }); + } + } else { + // This RawValue is not present in the database; hence, we need to create it + return createRaws.push({record: undefined, raw: newElement}); + } + return null; + }); + + return { + createRaws, + updateRaws, + }; + } + + return { + createRaws: getRawRecordPairs(rawValues), + updateRaws, + }; + }; + + /** + * batchOperations: Accepts an instance of Database (either Default or Server) and an array of + * prepareCreate/prepareUpdate 'models' and executes the actions on the database. + * @param {BatchOperationsArgs} operation + * @param {Database} operation.database + * @param {Array} operation.models + * @throws {DataOperatorException} + * @returns {Promise} + */ + private batchOperations = async ({database, models}: BatchOperationsArgs) => { + try { + if (models.length > 0) { + await database.action(async () => { + await database.batch(...models); + }); + } + } catch (e) { + throw new DataOperatorException('batchOperations error ', e); + } + }; + + /** + * prepareRecords: Utility method that actually calls the operators for the handlers + * @param {PrepareRecordsArgs} prepareRecord + * @param {Database} prepareRecord.database + * @param {string} prepareRecord.tableName + * @param {RawValue[]} prepareRecord.createRaws + * @param {RawValue[]} prepareRecord.updateRaws + * @param {(DataFactoryArgs) => void;} prepareRecord.recordOperator + * @throws {DataOperatorException} + * @returns {Promise} + */ + private prepareRecords = async ({database, tableName, createRaws, updateRaws, recordOperator}: PrepareRecordsArgs) => { + if (!database) { + throw new DataOperatorException( + 'prepareRecords accepts only rawPosts of type RawValue[] or valid database connection', + ); + } + + let prepareCreate: Model[] = []; + let prepareUpdate: Model[] = []; + + // create operation + if (createRaws?.length) { + const recordPromises = await createRaws.map( + async (createRecord: MatchExistingRecord) => { + const record = await recordOperator({database, tableName, value: createRecord, action: OperationType.CREATE}); + return record; + }, + ); + + const results = ((await Promise.all(recordPromises)) as unknown) as Model[]; + prepareCreate = prepareCreate.concat(results); + } + + // update operation + if (updateRaws?.length) { + const recordPromises = await updateRaws.map( + async (updateRecord: MatchExistingRecord) => { + const record = await recordOperator({database, tableName, value: updateRecord, action: OperationType.UPDATE}); + return record; + }, + ); + + const results = ((await Promise.all(recordPromises)) as unknown) as Model[]; + prepareUpdate = prepareUpdate.concat(results); + } + + return [...prepareCreate, ...prepareUpdate]; + }; + + /** + * executeInDatabase: Handles the Create/Update operations on an entity. + * @param {PrepareForDatabaseArgs} executeInDatabase + * @param {string} executeInDatabase.tableName + * @param {RecordValue[]} executeInDatabase.createRaws + * @param {RecordValue[]} executeInDatabase.updateRaws + * @param {(DataFactoryArgs) => void} executeInDatabase.recordOperator + * @returns {Promise} + */ + private executeInDatabase = async ({createRaws, recordOperator, tableName, updateRaws}: PrepareForDatabaseArgs) => { + const database = await this.getDatabase(tableName); + + const models = (await this.prepareRecords({ + database, + tableName, + createRaws, + updateRaws, + recordOperator, + })) as Model[]; + + if (models?.length > 0) { + await this.batchOperations({database, models}); + } + }; + + /** + * getDatabase: Based on the table's name, it will return a database instance either from the 'DEFAULT' database or + * the 'SERVER' database + * @param {string} tableName + * @returns {Promise} + */ + private getDatabase = async (tableName: string) => { + const isDefaultConnection = Object.values(MM_TABLES.DEFAULT).some( + (tbName) => { + return tableName === tbName; + }, + ); + + const promise = isDefaultConnection ? this.getDefaultDatabase : this.getServerDatabase; + const connection = await promise(); + + return connection; + }; + + /** + * getDefaultDatabase: Returns the default database + * @throws {DatabaseConnectionException} + * @returns {Promise} + */ + private getDefaultDatabase = async () => { + const connection = await DatabaseManager.getDefaultDatabase(); + if (connection === undefined) { + throw new DatabaseConnectionException( + 'An error occurred while retrieving the default database', + '', + ); + } + return connection; + }; + + /** + * getServerDatabase: Returns the current active server database (multi-server support) + * @throws {DatabaseConnectionException} + * @returns {Promise} + */ + private getServerDatabase = async () => { + // Third parties trying to update the database + if (this.serverDatabase) { + return this.serverDatabase; + } + + // NOTE: here we are getting the active server directly as in a multi-server support system, the current + // active server connection will already be set on application init + const connection = await DatabaseManager.getActiveServerDatabase(); + if (connection === undefined) { + throw new DatabaseConnectionException( + 'An error occurred while retrieving the server database', + '', + ); + } + return connection; + }; } export default DataOperator; diff --git a/app/database/admin/data_operator/handlers/test.ts b/app/database/admin/data_operator/handlers/test.ts index 9cc5fd6e05..fdef21069d 100644 --- a/app/database/admin/data_operator/handlers/test.ts +++ b/app/database/admin/data_operator/handlers/test.ts @@ -1,14 +1,21 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {MM_TABLES} from '@constants/database'; import DatabaseManager from '@database/admin/database_manager'; import DataOperator from '@database/admin/data_operator'; +import { + isRecordAppEqualToRaw, + isRecordDraftEqualToRaw, + isRecordGlobalEqualToRaw, + isRecordRoleEqualToRaw, + isRecordServerEqualToRaw, + isRecordSystemEqualToRaw, + isRecordTermsOfServiceEqualToRaw, +} from '@database/admin/data_operator/comparators'; +import DataOperatorException from '@database/admin/exceptions/data_operator_exception'; import {DatabaseType, IsolatedEntities} from '@typings/database/enums'; - import { operateAppRecord, - operateCustomEmojiRecord, operateDraftRecord, operateGlobalRecord, operateRoleRecord, @@ -19,7 +26,7 @@ import { jest.mock('@database/admin/database_manager'); -const {DRAFT} = MM_TABLES.SERVER; +/* eslint-disable @typescript-eslint/no-explicit-any */ describe('*** DataOperator: Handlers tests ***', () => { const createConnection = async (setActive = false) => { @@ -51,31 +58,31 @@ describe('*** DataOperator: Handlers tests ***', () => { const defaultDB = await DatabaseManager.getDefaultDatabase(); expect(defaultDB).toBeTruthy(); - const spyOnHandleBase = jest.spyOn(DataOperator as any, 'executeInDatabase'); + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); - const data = { - tableName: IsolatedEntities.APP, - values: [ - { - buildNumber: 'build-10x', - createdAt: 1, - id: 'id-21', - versionNumber: 'version-10', - }, - { - buildNumber: 'build-11y', - createdAt: 1, - id: 'id-22', - versionNumber: 'version-11', - }, - ], - }; + const values = [ + { + buildNumber: 'build-10x', + createdAt: 1, + id: 'id-21', + versionNumber: 'version-10', + }, + { + buildNumber: 'build-11y', + createdAt: 1, + id: 'id-22', + versionNumber: 'version-11', + }, + ]; - await DataOperator.handleIsolatedEntity(data); + await DataOperator.handleIsolatedEntity({tableName: IsolatedEntities.APP, values}); - expect(spyOnHandleBase).toHaveBeenCalledWith({ - ...data, - recordOperator: operateAppRecord, + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + fieldName: 'version_number', + operator: operateAppRecord, + comparator: isRecordAppEqualToRaw, + rawValues: values, + tableName: 'app', }); }); @@ -85,20 +92,17 @@ describe('*** DataOperator: Handlers tests ***', () => { const defaultDB = await DatabaseManager.getDefaultDatabase(); expect(defaultDB).toBeTruthy(); - const spyOnHandleBase = jest.spyOn(DataOperator as any, 'executeInDatabase'); + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + const values = [{id: 'global-1-id', name: 'global-1-name', value: 'global-1-value'}]; - const data = { - tableName: IsolatedEntities.GLOBAL, - values: [ - {id: 'global-1-id', name: 'global-1-name', value: 'global-1-value'}, - ], - }; + await DataOperator.handleIsolatedEntity({tableName: IsolatedEntities.GLOBAL, values}); - await DataOperator.handleIsolatedEntity(data); - - expect(spyOnHandleBase).toHaveBeenCalledWith({ - ...data, - recordOperator: operateGlobalRecord, + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + comparator: isRecordGlobalEqualToRaw, + fieldName: 'name', + operator: operateGlobalRecord, + rawValues: values, + tableName: 'global', }); }); @@ -108,53 +112,25 @@ describe('*** DataOperator: Handlers tests ***', () => { const defaultDB = await DatabaseManager.getDefaultDatabase(); expect(defaultDB).toBeTruthy(); - const spyOnHandleBase = jest.spyOn(DataOperator as any, 'executeInDatabase'); + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + const values = [ + { + dbPath: 'server.db', + displayName: 'community', + id: 'server-id-1', + mentionCount: 0, + unreadCount: 0, + url: 'https://community.mattermost.com', + }, + ]; + await DataOperator.handleIsolatedEntity({tableName: IsolatedEntities.SERVERS, values}); - const data = { - tableName: IsolatedEntities.SERVERS, - values: [ - { - dbPath: 'server.db', - displayName: 'community', - id: 'server-id-1', - mentionCount: 0, - unreadCount: 0, - url: 'https://community.mattermost.com', - }, - ], - }; - - await DataOperator.handleIsolatedEntity(data); - - expect(spyOnHandleBase).toHaveBeenCalledWith({ - ...data, - recordOperator: operateServersRecord, - }); - }); - - it('=> HandleCustomEmoji: should write to CUSTOM_EMOJI entity', async () => { - expect.assertions(2); - - const database = await createConnection(true); - expect(database).toBeTruthy(); - - const spyOnHandleBase = jest.spyOn(DataOperator as any, 'executeInDatabase'); - - const data = { - tableName: IsolatedEntities.CUSTOM_EMOJI, - values: [ - { - id: 'custom-emoji-id-1', - name: 'custom-emoji-1', - }, - ], - }; - - await DataOperator.handleIsolatedEntity(data); - - expect(spyOnHandleBase).toHaveBeenCalledWith({ - ...data, - recordOperator: operateCustomEmojiRecord, + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + comparator: isRecordServerEqualToRaw, + fieldName: 'db_path', + operator: operateServersRecord, + rawValues: values, + tableName: 'servers', }); }); @@ -164,24 +140,26 @@ describe('*** DataOperator: Handlers tests ***', () => { const database = await createConnection(true); expect(database).toBeTruthy(); - const spyOnHandleBase = jest.spyOn(DataOperator as any, 'executeInDatabase'); + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + const values = [ + { + id: 'custom-emoji-id-1', + name: 'custom-emoji-1', + permissions: ['custom-emoji-1'], + }, + ]; - const data = { + await DataOperator.handleIsolatedEntity({ tableName: IsolatedEntities.ROLE, - values: [ - { - id: 'custom-emoji-id-1', - name: 'custom-emoji-1', - permissions: ['custom-emoji-1'], - }, - ], - }; + values, + }); - await DataOperator.handleIsolatedEntity(data); - - expect(spyOnHandleBase).toHaveBeenCalledWith({ - ...data, - recordOperator: operateRoleRecord, + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + comparator: isRecordRoleEqualToRaw, + fieldName: 'name', + operator: operateRoleRecord, + rawValues: values, + tableName: 'Role', }); }); @@ -191,18 +169,16 @@ describe('*** DataOperator: Handlers tests ***', () => { const database = await createConnection(true); expect(database).toBeTruthy(); - const spyOnHandleBase = jest.spyOn(DataOperator as any, 'executeInDatabase'); + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + const values = [{id: 'system-id-1', name: 'system-1', value: 'system-1'}]; + await DataOperator.handleIsolatedEntity({tableName: IsolatedEntities.SYSTEM, values}); - const data = { - tableName: IsolatedEntities.SYSTEM, - values: [{id: 'system-id-1', name: 'system-1', value: 'system-1'}], - }; - - await DataOperator.handleIsolatedEntity(data); - - expect(spyOnHandleBase).toHaveBeenCalledWith({ - ...data, - recordOperator: operateSystemRecord, + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + comparator: isRecordSystemEqualToRaw, + fieldName: 'name', + operator: operateSystemRecord, + rawValues: values, + tableName: 'System', }); }); @@ -212,18 +188,29 @@ describe('*** DataOperator: Handlers tests ***', () => { const database = await createConnection(true); expect(database).toBeTruthy(); - const spyOnHandleBase = jest.spyOn(DataOperator as any, 'executeInDatabase'); + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); - const data = { + const values = [ + { + id: 'tos-1', + acceptedAt: 1, + create_at: 1613667352029, + user_id: 'user1613667352029', + text: '', + }, + ]; + + await DataOperator.handleIsolatedEntity({ tableName: IsolatedEntities.TERMS_OF_SERVICE, - values: [{id: 'tos-1', acceptedAt: 1}], - }; + values, + }); - await DataOperator.handleIsolatedEntity(data); - - expect(spyOnHandleBase).toHaveBeenCalledWith({ - ...data, - recordOperator: operateTermsOfServiceRecord, + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + comparator: isRecordTermsOfServiceEqualToRaw, + fieldName: 'accepted_at', + operator: operateTermsOfServiceRecord, + rawValues: values, + tableName: 'TermsOfService', }); }); @@ -233,16 +220,16 @@ describe('*** DataOperator: Handlers tests ***', () => { const defaultDB = await DatabaseManager.getDefaultDatabase(); expect(defaultDB).toBeTruthy(); - const spyOnHandleBase = jest.spyOn(DataOperator as any, 'executeInDatabase'); - - await DataOperator.handleIsolatedEntity({ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - tableName: 'INVALID_TABLE_NAME', - values: [{id: 'tos-1', acceptedAt: 1}], - }); - - expect(spyOnHandleBase).toHaveBeenCalledTimes(0); + await expect( + DataOperator.handleIsolatedEntity({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + tableName: 'INVALID_TABLE_NAME', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + values: [{id: 'tos-1', acceptedAt: 1}], + }), + ).rejects.toThrow(DataOperatorException); }); it('=> HandleReactions: should write to both Reactions and CustomEmoji entities', async () => { @@ -251,7 +238,7 @@ describe('*** DataOperator: Handlers tests ***', () => { const database = await createConnection(true); expect(database).toBeTruthy(); - const spyOnPrepareBase = jest.spyOn(DataOperator as any, 'prepareRecords'); + const spyOnPrepareRecords = jest.spyOn(DataOperator as any, 'prepareRecords'); const spyOnBatchOperation = jest.spyOn(DataOperator as any, 'batchOperations'); await DataOperator.handleReactions({ @@ -269,7 +256,7 @@ describe('*** DataOperator: Handlers tests ***', () => { }); // Called twice: Once for Reaction record and once for CustomEmoji record - expect(spyOnPrepareBase).toHaveBeenCalledTimes(2); + expect(spyOnPrepareRecords).toHaveBeenCalledTimes(2); // Only one batch operation for both entities expect(spyOnBatchOperation).toHaveBeenCalledTimes(1); @@ -281,9 +268,8 @@ describe('*** DataOperator: Handlers tests ***', () => { const database = await createConnection(true); expect(database).toBeTruthy(); - const spyOnHandleBase = jest.spyOn(DataOperator as any, 'executeInDatabase'); - - const data = [ + const spyOnHandleEntityRecords = jest.spyOn(DataOperator as any, 'handleEntityRecords'); + const values = [ { channel_id: '4r9jmr7eqt8dxq3f9woypzurrychannelid', files: [ @@ -307,13 +293,15 @@ describe('*** DataOperator: Handlers tests ***', () => { root_id: '', }, ]; - await DataOperator.handleDraft(data); - // Only one batch operation for both entities - expect(spyOnHandleBase).toHaveBeenCalledWith({ - tableName: DRAFT, - values: data, - recordOperator: operateDraftRecord, + await DataOperator.handleDraft(values); + + expect(spyOnHandleEntityRecords).toHaveBeenCalledWith({ + comparator: isRecordDraftEqualToRaw, + fieldName: 'channel_id', + operator: operateDraftRecord, + rawValues: values, + tableName: 'Draft', }); }); @@ -323,7 +311,7 @@ describe('*** DataOperator: Handlers tests ***', () => { const database = await createConnection(true); expect(database).toBeTruthy(); - const spyOnPrepareBase = jest.spyOn(DataOperator as any, 'prepareRecords'); + const spyOnPrepareRecords = jest.spyOn(DataOperator as any, 'prepareRecords'); const spyOnBatchOperation = jest.spyOn(DataOperator as any, 'batchOperations'); // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -348,7 +336,7 @@ describe('*** DataOperator: Handlers tests ***', () => { prepareRowsOnly: false, }); - expect(spyOnPrepareBase).toHaveBeenCalledTimes(1); + expect(spyOnPrepareRecords).toHaveBeenCalledTimes(1); expect(spyOnBatchOperation).toHaveBeenCalledTimes(1); }); @@ -508,10 +496,10 @@ describe('*** DataOperator: Handlers tests ***', () => { }, ]; - const spyOnHandleReactions = jest.spyOn(DataOperator as any, 'handleReactions'); const spyOnHandleFiles = jest.spyOn(DataOperator as any, 'handleFiles'); const spyOnHandlePostMetadata = jest.spyOn(DataOperator as any, 'handlePostMetadata'); - const spyOnHandleIsolatedEntity = jest.spyOn(DataOperator as any, 'handleIsolatedEntity'); + const spyOnHandleReactions = jest.spyOn(DataOperator as any, 'handleReactions'); + const spyOnHandleCustomEmojis = jest.spyOn(DataOperator as any, 'handleIsolatedEntity'); const spyOnHandlePostsInThread = jest.spyOn(DataOperator as any, 'handlePostsInThread'); const spyOnHandlePostsInChannel = jest.spyOn(DataOperator as any, 'handlePostsInChannel'); @@ -617,8 +605,8 @@ describe('*** DataOperator: Handlers tests ***', () => { prepareRowsOnly: true, }); - expect(spyOnHandleIsolatedEntity).toHaveBeenCalledTimes(1); - expect(spyOnHandleIsolatedEntity).toHaveBeenCalledWith({ + expect(spyOnHandleCustomEmojis).toHaveBeenCalledTimes(1); + expect(spyOnHandleCustomEmojis).toHaveBeenCalledWith({ tableName: 'CustomEmoji', values: [ { @@ -633,9 +621,225 @@ describe('*** DataOperator: Handlers tests ***', () => { }); expect(spyOnHandlePostsInThread).toHaveBeenCalledTimes(1); - expect(spyOnHandlePostsInThread).toHaveBeenCalledWith([{earliest: 1596032651747, post_id: '8swgtrrdiff89jnsiwiip3y1eoe'}]); + expect(spyOnHandlePostsInThread).toHaveBeenCalledWith([ + {earliest: 1596032651747, post_id: '8swgtrrdiff89jnsiwiip3y1eoe'}, + ]); expect(spyOnHandlePostsInChannel).toHaveBeenCalledTimes(1); expect(spyOnHandlePostsInChannel).toHaveBeenCalledWith(posts.slice(0, 3)); }); + + it('=> HandleUsers: should write to User entity', async () => { + expect.assertions(1); + + const users = [ + { + id: '9ciscaqbrpd6d8s68k76xb9bte', + create_at: 1599457495881, + update_at: 1607683720173, + delete_at: 0, + username: 'a.l', + auth_service: 'saml', + email: 'a.l@mattermost.com', + email_verified: true, + is_bot: false, + nickname: '', + first_name: 'A', + last_name: 'L', + position: 'Mobile Engineer', + roles: 'system_user', + props: {}, + notify_props: { + desktop: 'all', + desktop_sound: true, + email: true, + first_name: true, + mention_keys: '', + push: 'mention', + channel: true, + auto_responder_active: false, + auto_responder_message: + 'Hello, I am out of office and unable to respond to messages.', + comments: 'never', + desktop_notification_sound: 'Hello', + push_status: 'online', + }, + last_password_update: 1604323112537, + last_picture_update: 1604686302260, + locale: 'en', + timezone: { + automaticTimezone: 'Indian/Mauritius', + manualTimezone: '', + useAutomaticTimezone: true, + }, + }, + ]; + + const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); + + await createConnection(true); + + await DataOperator.handleUsers(users); + + expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); + }); + + it('=> HandlePreferences: should write to PREFERENCE entity', async () => { + expect.assertions(1); + + const preferences = [ + { + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + category: 'group_channel_show', + name: 'qj91hepgjfn6xr4acm5xzd8zoc', + value: 'true', + }, + { + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + category: 'notifications', + name: 'email_interval', + value: '30', + }, + { + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + category: 'theme', + name: '', + value: + '{"awayIndicator":"#c1b966","buttonBg":"#4cbba4","buttonColor":"#ffffff","centerChannelBg":"#2f3e4e","centerChannelColor":"#dddddd","codeTheme":"solarized-dark","dndIndicator":"#e81023","errorTextColor":"#ff6461","image":"/static/files/0b8d56c39baf992e5e4c58d74fde0fd6.png","linkColor":"#a4ffeb","mentionBg":"#b74a4a","mentionColor":"#ffffff","mentionHighlightBg":"#984063","mentionHighlightLink":"#a4ffeb","newMessageSeparator":"#5de5da","onlineIndicator":"#65dcc8","sidebarBg":"#1b2c3e","sidebarHeaderBg":"#1b2c3e","sidebarHeaderTextColor":"#ffffff","sidebarText":"#ffffff","sidebarTextActiveBorder":"#66b9a7","sidebarTextActiveColor":"#ffffff","sidebarTextHoverBg":"#4a5664","sidebarUnreadText":"#ffffff","type":"Mattermost Dark"}', + }, + { + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + category: 'tutorial_step', + name: '9ciscaqbrpd6d8s68k76xb9bte', + value: '2', + }, + ]; + + const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); + + await createConnection(true); + + await DataOperator.handlePreferences(preferences); + + expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); + }); + + it('=> HandleTeamMemberships: should write to TEAM_MEMBERSHIP entity', async () => { + expect.assertions(1); + + const teamMembership = [ + { + team_id: 'a', + user_id: 'ab', + roles: '3ngdqe1e7tfcbmam4qgnxp91bw', + delete_at: 0, + scheme_guest: false, + scheme_user: true, + scheme_admin: false, + explicit_roles: '', + }, + ]; + + const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); + + await createConnection(true); + + await DataOperator.handleTeamMemberships(teamMembership); + + expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); + }); + + it('=> HandleCustomEmojis: should write to CUSTOM_EMOJI entity', async () => { + expect.assertions(1); + const emojis = [ + { + id: 'i', + create_at: 1580913641769, + update_at: 1580913641769, + delete_at: 0, + creator_id: '4cprpki7ri81mbx8efixcsb8jo', + name: 'boomI', + }, + ]; + + const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); + + await createConnection(true); + + await DataOperator.handleIsolatedEntity({tableName: IsolatedEntities.CUSTOM_EMOJI, values: emojis}); + + expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); + }); + + it('=> HandleGroupMembership: should write to GROUP_MEMBERSHIP entity', async () => { + expect.assertions(1); + const groupMemberships = [ + { + user_id: 'u4cprpki7ri81mbx8efixcsb8jo', + group_id: 'g4cprpki7ri81mbx8efixcsb8jo', + }, + ]; + + const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); + + await createConnection(true); + + await DataOperator.handleGroupMembership(groupMemberships); + + expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); + }); + + it('=> HandleChannelMembership: should write to CHANNEL_MEMBERSHIP entity', async () => { + expect.assertions(1); + const channelMemberships = [ + { + channel_id: '17bfnb1uwb8epewp4q3x3rx9go', + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + roles: 'wqyby5r5pinxxdqhoaomtacdhc', + last_viewed_at: 1613667352029, + msg_count: 3864, + mention_count: 0, + notify_props: { + desktop: 'default', + email: 'default', + ignore_channel_mentions: 'default', + mark_unread: 'mention', + push: 'default', + }, + last_update_at: 1613667352029, + scheme_guest: false, + scheme_user: true, + scheme_admin: false, + explicit_roles: '', + }, + { + channel_id: '1yw6gxfr4bn1jbyp9nr7d53yew', + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + roles: 'channel_user', + last_viewed_at: 1615300540549, + msg_count: 16, + mention_count: 0, + notify_props: { + desktop: 'default', + email: 'default', + ignore_channel_mentions: 'default', + mark_unread: 'all', + push: 'default', + }, + last_update_at: 1615300540549, + scheme_guest: false, + scheme_user: true, + scheme_admin: false, + explicit_roles: '', + }, + ]; + + const spyOnExecuteInDatabase = jest.spyOn(DataOperator as any, 'executeInDatabase'); + + await createConnection(true); + + await DataOperator.handleChannelMembership(channelMemberships); + + expect(spyOnExecuteInDatabase).toHaveBeenCalledTimes(1); + }); }); diff --git a/app/database/admin/data_operator/index.ts b/app/database/admin/data_operator/index.ts index d64ccce41b..b4a742c817 100644 --- a/app/database/admin/data_operator/index.ts +++ b/app/database/admin/data_operator/index.ts @@ -1,5 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import DataOperator from '@database/admin/data_operator/handlers'; + +import DataOperator from './handlers'; export default new DataOperator(); diff --git a/app/database/admin/data_operator/operators/index.ts b/app/database/admin/data_operator/operators/index.ts index 89ea3b497f..4943b4d1d0 100644 --- a/app/database/admin/data_operator/operators/index.ts +++ b/app/database/admin/data_operator/operators/index.ts @@ -5,124 +5,147 @@ import {Q} from '@nozbe/watermelondb'; import Model from '@nozbe/watermelondb/Model'; import {MM_TABLES} from '@constants/database'; +import {User} from '@database/server/models'; import App from '@typings/database/app'; +import ChannelMembership from '@typings/database/channel_membership'; import CustomEmoji from '@typings/database/custom_emoji'; import { - BaseOperator, - IdenticalRecord, - Operator, + DataFactoryArgs, RawApp, + RawChannelMembership, RawCustomEmoji, RawDraft, RawFile, RawGlobal, + RawGroupMembership, RawPost, RawPostMetadata, RawPostsInChannel, RawPostsInThread, + RawPreference, RawReaction, RawRole, RawServers, RawSystem, + RawTeamMembership, RawTermsOfService, + RawUser, } from '@typings/database/database'; import Draft from '@typings/database/draft'; +import {OperationType} from '@typings/database/enums'; import File from '@typings/database/file'; import Global from '@typings/database/global'; +import GroupMembership from '@typings/database/group_membership'; import Post from '@typings/database/post'; import PostMetadata from '@typings/database/post_metadata'; import PostsInChannel from '@typings/database/posts_in_channel'; import PostsInThread from '@typings/database/posts_in_thread'; +import Preference from '@typings/database/preference'; import Reaction from '@typings/database/reaction'; import Role from '@typings/database/role'; import Servers from '@typings/database/servers'; import System from '@typings/database/system'; +import TeamMembership from '@typings/database/team_membership'; import TermsOfService from '@typings/database/terms_of_service'; const {APP, GLOBAL, SERVERS} = MM_TABLES.DEFAULT; const { + CHANNEL_MEMBERSHIP, CUSTOM_EMOJI, DRAFT, FILE, + GROUP_MEMBERSHIP, POST, - POST_METADATA, POSTS_IN_CHANNEL, POSTS_IN_THREAD, + POST_METADATA, + PREFERENCE, REACTION, ROLE, SYSTEM, + TEAM_MEMBERSHIP, TERMS_OF_SERVICE, + USER, } = MM_TABLES.SERVER; /** * operateAppRecord: Prepares record of entity 'App' from the DEFAULT database for update or create actions. - * @param {Operator} operator + * @param {DataFactoryArgs} operator * @param {Database} operator.database - * @param {RecordValue} operator.value - * @returns {Promise} + * @param {MatchExistingRecord} operator.value + * @returns {Promise} */ -export const operateAppRecord = async ({database, value}: Operator) => { - const record = value as RawApp; +export const operateAppRecord = async ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawApp; + const record = value.record as App; + const isCreateAction = action === OperationType.CREATE; const generator = (app: App) => { - app._raw.id = record?.id ?? app.id; - app.buildNumber = record?.buildNumber; - app.createdAt = record?.createdAt; - app.versionNumber = record?.versionNumber; + app._raw.id = isCreateAction ? app.id : record.id; + app.buildNumber = raw?.buildNumber; + app.createdAt = raw?.createdAt; + app.versionNumber = raw?.versionNumber; }; return operateBaseRecord({ + action, database, + generator, tableName: APP, value, - generator, }); }; /** * operateGlobalRecord: Prepares record of entity 'Global' from the DEFAULT database for update or create actions. - * @param {Operator} operator + * @param {DataFactoryArgs} operator * @param {Database} operator.database - * @param {RecordValue} operator.value - * @returns {Promise} + * @param {MatchExistingRecord} operator.value + * @returns {Promise} */ -export const operateGlobalRecord = async ({database, value}: Operator) => { - const record = value as RawGlobal; +export const operateGlobalRecord = async ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawGlobal; + const record = value.record as Global; + const isCreateAction = action === OperationType.CREATE; const generator = (global: Global) => { - global._raw.id = record?.id ?? global.id; - global.name = record?.name; - global.value = record?.value; + global._raw.id = isCreateAction ? global.id : record.id; + global.name = raw?.name; + global.value = raw?.value; }; return operateBaseRecord({ + action, database, + generator, tableName: GLOBAL, value, - generator, }); }; /** * operateServersRecord: Prepares record of entity 'Servers' from the DEFAULT database for update or create actions. - * @param {Operator} operator + * @param {DataFactoryArgs} operator * @param {Database} operator.database - * @param {RecordValue} operator.value - * @returns {Promise} + * @param {MatchExistingRecord} operator.value + * @returns {Promise} */ -export const operateServersRecord = async ({database, value}: Operator) => { - const record = value as RawServers; +export const operateServersRecord = async ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawServers; + const record = value.record as Servers; + const isCreateAction = action === OperationType.CREATE; const generator = (servers: Servers) => { - servers._raw.id = record?.id ?? servers.id; - servers.dbPath = record?.dbPath; - servers.displayName = record?.displayName; - servers.mentionCount = record?.mentionCount; - servers.unreadCount = record?.unreadCount; - servers.url = record?.url; + servers._raw.id = isCreateAction ? servers.id : record.id; + servers.dbPath = raw?.dbPath; + servers.displayName = raw?.displayName; + servers.mentionCount = raw?.mentionCount; + servers.unreadCount = raw?.unreadCount; + servers.url = raw?.url; }; return operateBaseRecord({ + action, database, tableName: SERVERS, value, @@ -132,29 +155,24 @@ export const operateServersRecord = async ({database, value}: Operator) => { /** * operateCustomEmojiRecord: Prepares record of entity 'CustomEmoji' from the SERVER database for update or create actions. - * @param {Operator} operator + * @param {DataFactoryArgs} operator * @param {Database} operator.database - * @param {RecordValue} operator.value - * @returns {Promise} + * @param {MatchExistingRecord} operator.value + * @returns {Promise} */ -export const operateCustomEmojiRecord = async ({database, value}: Operator) => { - const record = value as RawCustomEmoji; +export const operateCustomEmojiRecord = async ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawCustomEmoji; + const record = value.record as CustomEmoji; + const isCreateAction = action === OperationType.CREATE; + + // id of emoji comes from server response const generator = (emoji: CustomEmoji) => { - emoji._raw.id = record?.id ?? emoji.id; - emoji.name = record.name; + emoji._raw.id = isCreateAction ? (raw?.id ?? emoji.id) : record.id; + emoji.name = raw.name; }; - const appRecord = (await database.collections. - get(CUSTOM_EMOJI!). - query(Q.where('name', record.name)). - fetch()) as Model[]; - const isPresent = appRecord.length > 0; - - if (isPresent) { - return null; - } - return operateBaseRecord({ + action, database, tableName: CUSTOM_EMOJI, value, @@ -164,21 +182,25 @@ export const operateCustomEmojiRecord = async ({database, value}: Operator) => { /** * operateRoleRecord: Prepares record of entity 'Role' from the SERVER database for update or create actions. - * @param {Operator} operator + * @param {DataFactoryArgs} operator * @param {Database} operator.database - * @param {RecordValue} operator.value - * @returns {Promise} + * @param {MatchExistingRecord} operator.value + * @returns {Promise} */ -export const operateRoleRecord = async ({database, value}: Operator) => { - const record = value as RawRole; +export const operateRoleRecord = async ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawRole; + const record = value.record as Role; + const isCreateAction = action === OperationType.CREATE; + // id of role comes from server response const generator = (role: Role) => { - role._raw.id = record?.id ?? role.id; - role.name = record?.name; - role.permissions = record?.permissions; + role._raw.id = isCreateAction ? (raw?.id ?? role.id) : record.id; + role.name = raw?.name; + role.permissions = raw?.permissions; }; return operateBaseRecord({ + action, database, tableName: ROLE, value, @@ -188,21 +210,25 @@ export const operateRoleRecord = async ({database, value}: Operator) => { /** * operateSystemRecord: Prepares record of entity 'System' from the SERVER database for update or create actions. - * @param {Operator} operator + * @param {DataFactoryArgs} operator * @param {Database} operator.database - * @param {RecordValue} operator.value - * @returns {Promise} + * @param {MatchExistingRecord} operator.value + * @returns {Promise} */ -export const operateSystemRecord = async ({database, value}: Operator) => { - const record = value as RawSystem; +export const operateSystemRecord = async ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawSystem; + const record = value.record as System; + const isCreateAction = action === OperationType.CREATE; + // id of system comes from server response const generator = (system: System) => { - system._raw.id = record?.id ?? system.id; - system.name = record?.name; - system.value = record?.value; + system._raw.id = isCreateAction ? (raw?.id ?? system.id) : record?.id; + system.name = raw?.name; + system.value = raw?.value; }; return operateBaseRecord({ + action, database, tableName: SYSTEM, value, @@ -212,20 +238,24 @@ export const operateSystemRecord = async ({database, value}: Operator) => { /** * operateTermsOfServiceRecord: Prepares record of entity 'TermsOfService' from the SERVER database for update or create actions. - * @param {Operator} operator + * @param {DataFactoryArgs} operator * @param {Database} operator.database - * @param {RecordValue} operator.value - * @returns {Promise} + * @param {MatchExistingRecord} operator.value + * @returns {Promise} */ -export const operateTermsOfServiceRecord = async ({database, value}: Operator) => { - const record = value as RawTermsOfService; +export const operateTermsOfServiceRecord = async ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawTermsOfService; + const record = value.record as TermsOfService; + const isCreateAction = action === OperationType.CREATE; + // id of TOS comes from server response const generator = (tos: TermsOfService) => { - tos._raw.id = record?.id ?? tos.id; - tos.acceptedAt = record?.acceptedAt; + tos._raw.id = isCreateAction ? (raw?.id ?? tos.id) : record?.id; + tos.acceptedAt = raw?.acceptedAt; }; return operateBaseRecord({ + action, database, tableName: TERMS_OF_SERVICE, value, @@ -235,33 +265,37 @@ export const operateTermsOfServiceRecord = async ({database, value}: Operator) = /** * operatePostRecord: Prepares record of entity 'Post' from the SERVER database for update or create actions. - * @param {Operator} operator + * @param {DataFactoryArgs} operator * @param {Database} operator.database - * @param {RecordValue} operator.value - * @returns {Promise} + * @param {MatchExistingRecord} operator.value + * @returns {Promise} */ -export const operatePostRecord = async ({database, value}: Operator) => { - const record = value as RawPost; +export const operatePostRecord = async ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawPost; + const record = value.record as Post; + const isCreateAction = action === OperationType.CREATE; + // id of post comes from server response const generator = (post: Post) => { - post._raw.id = record?.id; - post.channelId = record?.channel_id; - post.createAt = record?.create_at; - post.deleteAt = record?.delete_at || record?.delete_at === 0 ? record?.delete_at : 0; - post.editAt = record?.edit_at; - post.updateAt = record?.update_at; - post.isPinned = record!.is_pinned!; - post.message = Q.sanitizeLikeString(record?.message); - post.userId = record?.user_id; - post.originalId = record?.original_id ?? ''; - post.pendingPostId = record?.pending_post_id ?? ''; - post.previousPostId = record?.prev_post_id ?? ''; - post.rootId = record?.root_id ?? ''; - post.type = record?.type ?? ''; - post.props = record?.props ?? {}; + post._raw.id = isCreateAction ? (raw?.id ?? post.id) : record?.id; + post.channelId = raw?.channel_id; + post.createAt = raw?.create_at; + post.deleteAt = raw?.delete_at || raw?.delete_at === 0 ? raw?.delete_at : 0; + post.editAt = raw?.edit_at; + post.updateAt = raw?.update_at; + post.isPinned = raw!.is_pinned!; + post.message = Q.sanitizeLikeString(raw?.message); + post.userId = raw?.user_id; + post.originalId = raw?.original_id ?? ''; + post.pendingPostId = raw?.pending_post_id ?? ''; + post.previousPostId = raw?.prev_post_id ?? ''; + post.rootId = raw?.root_id ?? ''; + post.type = raw?.type ?? ''; + post.props = raw?.props ?? {}; }; return operateBaseRecord({ + action, database, tableName: POST, value, @@ -271,22 +305,24 @@ export const operatePostRecord = async ({database, value}: Operator) => { /** * operatePostInThreadRecord: Prepares record of entity 'POSTS_IN_THREAD' from the SERVER database for update or create actions. - * @param {Operator} operator + * @param {DataFactoryArgs} operator * @param {Database} operator.database - * @param {RecordValue} operator.value - * @returns {Promise} + * @param {MatchExistingRecord} operator.value + * @returns {Promise} */ -export const operatePostInThreadRecord = async ({database, value}: Operator) => { - const record = value as RawPostsInThread; +export const operatePostInThreadRecord = async ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawPostsInThread; + const record = value.record as PostsInThread; + const isCreateAction = action === OperationType.CREATE; const generator = (postsInThread: PostsInThread) => { - postsInThread._raw.id = postsInThread.id; - postsInThread.postId = record.post_id; - postsInThread.earliest = record.earliest; - postsInThread.latest = record.latest!; + postsInThread.postId = isCreateAction ? raw.post_id : record.id; + postsInThread.earliest = raw.earliest; + postsInThread.latest = raw.latest!; }; return operateBaseRecord({ + action, database, tableName: POSTS_IN_THREAD, value, @@ -296,23 +332,27 @@ export const operatePostInThreadRecord = async ({database, value}: Operator) => /** * operateReactionRecord: Prepares record of entity 'REACTION' from the SERVER database for update or create actions. - * @param {Operator} operator + * @param {DataFactoryArgs} operator * @param {Database} operator.database - * @param {RecordValue} operator.value - * @returns {Promise} + * @param {MatchExistingRecord} operator.value + * @returns {Promise} */ -export const operateReactionRecord = async ({database, value}: Operator) => { - const record = value as RawReaction; +export const operateReactionRecord = async ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawReaction; + const record = value.record as Reaction; + const isCreateAction = action === OperationType.CREATE; + // id of reaction comes from server response const generator = (reaction: Reaction) => { - reaction._raw.id = reaction.id; - reaction.userId = record.user_id; - reaction.postId = record.post_id; - reaction.emojiName = record.emoji_name; - reaction.createAt = record.create_at; + reaction._raw.id = isCreateAction ? (raw?.id ?? reaction.id) : record?.id; + reaction.userId = raw.user_id; + reaction.postId = raw.post_id; + reaction.emojiName = raw.emoji_name; + reaction.createAt = raw.create_at; }; return operateBaseRecord({ + action, database, tableName: REACTION, value, @@ -322,28 +362,32 @@ export const operateReactionRecord = async ({database, value}: Operator) => { /** * operateFileRecord: Prepares record of entity 'FILE' from the SERVER database for update or create actions. - * @param {Operator} operator + * @param {DataFactoryArgs} operator * @param {Database} operator.database - * @param {RecordValue} operator.value - * @returns {Promise} + * @param {MatchExistingRecord} operator.value + * @returns {Promise} */ -export const operateFileRecord = async ({database, value}: Operator) => { - const record = value as RawFile; +export const operateFileRecord = async ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawFile; + const record = value.record as File; + const isCreateAction = action === OperationType.CREATE; + // id of file comes from server response const generator = (file: File) => { - file._raw.id = record?.id ?? file.id; - file.postId = record.post_id; - file.name = record.name; - file.extension = record.extension; - file.size = record.size; - file.mimeType = record?.mime_type ?? ''; - file.width = record?.width ?? 0; - file.height = record?.height ?? 0; - file.imageThumbnail = record?.mini_preview ?? ''; - file.localPath = record?.localPath ?? ''; + file._raw.id = isCreateAction ? (raw?.id ?? file.id) : record?.id; + file.postId = raw.post_id; + file.name = raw.name; + file.extension = raw.extension; + file.size = raw.size; + file.mimeType = raw?.mime_type ?? ''; + file.width = raw?.width ?? 0; + file.height = raw?.height ?? 0; + file.imageThumbnail = raw?.mini_preview ?? ''; + file.localPath = raw?.localPath ?? ''; }; return operateBaseRecord({ + action, database, tableName: FILE, value, @@ -353,22 +397,25 @@ export const operateFileRecord = async ({database, value}: Operator) => { /** * operatePostMetadataRecord: Prepares record of entity 'POST_METADATA' from the SERVER database for update or create actions. - * @param {Operator} operator + * @param {DataFactoryArgs} operator * @param {Database} operator.database - * @param {RecordValue} operator.value - * @returns {Promise} + * @param {MatchExistingRecord} operator.value + * @returns {Promise} */ -export const operatePostMetadataRecord = async ({database, value}: Operator) => { - const record = value as RawPostMetadata; +export const operatePostMetadataRecord = async ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawPostMetadata; + const record = value.record as PostMetadata; + const isCreateAction = action === OperationType.CREATE; const generator = (postMeta: PostMetadata) => { - postMeta._raw.id = postMeta.id; - postMeta.data = record.data; - postMeta.postId = record.postId; - postMeta.type = record.type; + postMeta._raw.id = isCreateAction ? postMeta.id : record.id; + postMeta.data = raw.data; + postMeta.postId = raw.postId; + postMeta.type = raw.type; }; return operateBaseRecord({ + action, database, tableName: POST_METADATA, value, @@ -378,24 +425,26 @@ export const operatePostMetadataRecord = async ({database, value}: Operator) => /** * operateDraftRecord: Prepares record of entity 'DRAFT' from the SERVER database for update or create actions. - * @param {Operator} operator + * @param {DataFactoryArgs} operator * @param {Database} operator.database - * @param {RecordValue} operator.value - * @returns {Promise} + * @param {MatchExistingRecord} operator.value + * @returns {Promise} */ -export const operateDraftRecord = async ({database, value}: Operator) => { - const record = value as RawDraft; +export const operateDraftRecord = async ({action, database, value}: DataFactoryArgs) => { const emptyFileInfo: FileInfo[] = []; + const raw = value.raw as RawDraft; + // Draft is client side only; plus you would only be creating/deleting one const generator = (draft: Draft) => { - draft._raw.id = record?.id ?? draft.id; - draft.rootId = record?.root_id ?? ''; - draft.message = record?.message ?? ''; - draft.channelId = record?.channel_id ?? ''; - draft.files = record?.files ?? emptyFileInfo; + draft._raw.id = draft.id; + draft.rootId = raw?.root_id ?? ''; + draft.message = raw?.message ?? ''; + draft.channelId = raw?.channel_id ?? ''; + draft.files = raw?.files ?? emptyFileInfo; }; return operateBaseRecord({ + action, database, tableName: DRAFT, value, @@ -405,22 +454,23 @@ export const operateDraftRecord = async ({database, value}: Operator) => { /** * operatePostsInChannelRecord: Prepares record of entity 'POSTS_IN_CHANNEL' from the SERVER database for update or create actions. - * @param {Operator} operator + * @param {DataFactoryArgs} operator * @param {Database} operator.database - * @param {RecordValue} operator.value - * @returns {Promise} + * @param {MatchExistingRecord} operator.value + * @returns {Promise} */ -export const operatePostsInChannelRecord = async ({database, value}: Operator) => { - const record = value as RawPostsInChannel; +export const operatePostsInChannelRecord = async ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawPostsInChannel; const generator = (postsInChannel: PostsInChannel) => { - postsInChannel._raw.id = record?.id ?? postsInChannel.id; - postsInChannel.channelId = record.channel_id; - postsInChannel.earliest = record.earliest; - postsInChannel.latest = record.latest; + postsInChannel._raw.id = raw?.id ?? postsInChannel.id; + postsInChannel.channelId = raw.channel_id; + postsInChannel.earliest = raw.earliest; + postsInChannel.latest = raw.latest; }; return operateBaseRecord({ + action, database, tableName: POSTS_IN_CHANNEL, value, @@ -429,49 +479,180 @@ export const operatePostsInChannelRecord = async ({database, value}: Operator) = }; /** - * operateBaseRecord: The 'id' of a record is key to this function. Please note that - at the moment - if WatermelonDB - * encounters an existing record during a CREATE operation, it silently fails the operation. + * operateUserRecord: Prepares record of entity 'USER' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {MatchExistingRecord} operator.value + * @returns {Promise} + */ +export const operateUserRecord = async ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawUser; + const record = value.record as User; + const isCreateAction = action === OperationType.CREATE; + + // id of user comes from server response + const generator = (user: User) => { + user._raw.id = isCreateAction ? (raw?.id ?? user.id) : record?.id; + user.authService = raw.auth_service; + user.deleteAt = raw.delete_at; + user.updateAt = raw.update_at; + user.email = raw.email; + user.firstName = raw.first_name; + user.isGuest = raw.roles.includes('system_guest'); + user.lastName = raw.last_name; + user.lastPictureUpdate = raw.last_picture_update; + user.locale = raw.locale; + user.nickname = raw.nickname; + user.position = raw?.position ?? ''; + user.roles = raw.roles; + user.username = raw.username; + user.notifyProps = raw.notify_props; + user.props = raw.props; + user.timezone = raw.timezone; + user.isBot = raw.is_bot; + }; + + return operateBaseRecord({ + action, + database, + tableName: USER, + value, + generator, + }); +}; + +/** + * operatePreferenceRecord: Prepares record of entity 'PREFERENCE' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {MatchExistingRecord} operator.value + * @returns {Promise} + */ +export const operatePreferenceRecord = async ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawPreference; + const record = value.record as Preference; + const isCreateAction = action === OperationType.CREATE; + + // id of preference comes from server response + const generator = (preference: Preference) => { + preference._raw.id = isCreateAction ? (raw?.id ?? preference.id) : record?.id; + preference.category = raw.category; + preference.name = raw.name; + preference.userId = raw.user_id; + preference.value = raw.value; + }; + + return operateBaseRecord({ + action, + database, + tableName: PREFERENCE, + value, + generator, + }); +}; + +/** + * operatePreferenceRecord: Prepares record of entity 'TEAM_MEMBERSHIP' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {MatchExistingRecord} operator.value + * @returns {Promise} + */ +export const operateTeamMembershipRecord = async ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawTeamMembership; + const record = value.record as TeamMembership; + const isCreateAction = action === OperationType.CREATE; + + // id of preference comes from server response + const generator = (teamMembership: TeamMembership) => { + teamMembership._raw.id = isCreateAction ? (raw?.id ?? teamMembership.id) : record?.id; + teamMembership.teamId = raw.team_id; + teamMembership.userId = raw.user_id; + }; + + return operateBaseRecord({ + action, + database, + tableName: TEAM_MEMBERSHIP, + value, + generator, + }); +}; + +/** + * operateGroupMembershipRecord: Prepares record of entity 'GROUP_MEMBERSHIP' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {MatchExistingRecord} operator.value + * @returns {Promise} + */ +export const operateGroupMembershipRecord = async ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawGroupMembership; + const record = value.record as GroupMembership; + const isCreateAction = action === OperationType.CREATE; + + // id of preference comes from server response + const generator = (groupMember: GroupMembership) => { + groupMember._raw.id = isCreateAction ? (raw?.id ?? groupMember.id) : record?.id; + groupMember.groupId = raw.group_id; + groupMember.userId = raw.user_id; + }; + + return operateBaseRecord({ + action, + database, + tableName: GROUP_MEMBERSHIP, + value, + generator, + }); +}; + +/** + * operateChannelMembershipRecord: Prepares record of entity 'CHANNEL_MEMBERSHIP' from the SERVER database for update or create actions. + * @param {DataFactoryArgs} operator + * @param {Database} operator.database + * @param {MatchExistingRecord} operator.value + * @returns {Promise} + */ +export const operateChannelMembershipRecord = async ({action, database, value}: DataFactoryArgs) => { + const raw = value.raw as RawChannelMembership; + const record = value.record as ChannelMembership; + const isCreateAction = action === OperationType.CREATE; + + // id of preference comes from server response + const generator = (channelMember: ChannelMembership) => { + channelMember._raw.id = isCreateAction ? (raw?.id ?? channelMember.id) : record?.id; + channelMember.channelId = raw.channel_id; + channelMember.userId = raw.user_id; + }; + + return operateBaseRecord({ + action, + database, + tableName: CHANNEL_MEMBERSHIP, + value, + generator, + }); +}; + +/** + * operateBaseRecord: This is the last step for each operator and depending on the 'action', it will either prepare an + * existing record for UPDATE or prepare a collection for CREATE * - * This operator decides to go through an UPDATE action if we have an existing record in the table bearing the same id. - * If not, it will go for a CREATE operation. - * - * However, if the tableName points to a major entity ( like Post, User or Channel, etc.), it verifies first if the - * update_at value of the existing record is different from the parameter 'value' own update_at. Only if they differ, - * that it prepares the record for update. - * - * @param {Operator} operatorBase + * @param {DataFactoryArgs} operatorBase * @param {Database} operatorBase.database * @param {string} operatorBase.tableName - * @param {RecordValue} operatorBase.value + * @param {MatchExistingRecord} operatorBase.value * @param {((model: Model) => void)} operatorBase.generator - * @returns {Promise} + * @returns {Promise} */ -const operateBaseRecord = async ({database, tableName, value, generator}: BaseOperator) => { - // We query first to see if we have a record on that entity with the current value.id - const appRecord = (await database.collections. - get(tableName!). - query(Q.where('id', value.id!)). - fetch()) as Model[]; +const operateBaseRecord = async ({action, database, tableName, value, generator}: DataFactoryArgs) => { + if (action === OperationType.UPDATE) { + // Two possible scenarios: + // 1. We are dealing with either duplicates here and if so, we'll update instead of create + // 2. This is just a normal update operation - const isPresent = appRecord.length > 0; - - if (isPresent) { - const record = appRecord[0]; - - // We avoid unnecessary updates if we already have a record with the same update_at value for this model/entity - const isRecordIdentical = checkForIdenticalRecord({ - tableName: tableName!, - newValue: value, - existingRecord: record, - }); - - if (isRecordIdentical) { - return null; - } - - // Two possible scenarios: - // 1. We are dealing with either duplicates here and if so, we'll update instead of create - // 2. This is just a normal update operation + const record = value.record as Model; return record.prepareUpdate(() => generator!(record)); } @@ -480,28 +661,3 @@ const operateBaseRecord = async ({database, tableName, value, generator}: BaseOp // 2. This is just a normal create operation return database.collections.get(tableName!).prepareCreate(generator); }; - -/** - * checkForIdenticalRecord: - * @param {IdenticalRecord} identicalRecord - * @param {string} identicalRecord.tableName - * @param {RecordValue} identicalRecord.newValue - * @param {Model} identicalRecord.existingRecord - * @returns {boolean} - */ -const checkForIdenticalRecord = ({tableName, newValue, existingRecord}: IdenticalRecord) => { - const guardTables = [POST]; - if (guardTables.includes(tableName)) { - switch (tableName) { - case POST: { - const tempPost = newValue as RawPost; - const currentRecord = (existingRecord as unknown) as Post; - return tempPost.update_at === currentRecord.updateAt; - } - default: { - return false; - } - } - } - return false; -}; diff --git a/app/database/admin/data_operator/operators/test.ts b/app/database/admin/data_operator/operators/test.ts index 5e6c94c588..02953ef41e 100644 --- a/app/database/admin/data_operator/operators/test.ts +++ b/app/database/admin/data_operator/operators/test.ts @@ -1,33 +1,34 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Q} from '@nozbe/watermelondb'; -import {MM_TABLES} from '@constants/database'; import DatabaseManager from '@database/admin/database_manager'; -import DataOperator from '@database/admin/data_operator'; -import App from '@typings/database/app'; -import {DatabaseType, IsolatedEntities} from '@typings/database/enums'; +import {DatabaseType, OperationType} from '@typings/database/enums'; import { operateAppRecord, + operateChannelMembershipRecord, operateCustomEmojiRecord, operateDraftRecord, operateFileRecord, operateGlobalRecord, + operateGroupMembershipRecord, operatePostInThreadRecord, operatePostMetadataRecord, operatePostRecord, operatePostsInChannelRecord, + operatePreferenceRecord, operateReactionRecord, operateRoleRecord, operateServersRecord, operateSystemRecord, + operateTeamMembershipRecord, operateTermsOfServiceRecord, + operateUserRecord, } from './index'; jest.mock('@database/admin/database_manager'); -const {APP} = MM_TABLES.DEFAULT; +/* eslint-disable @typescript-eslint/no-explicit-any */ describe('*** DataOperator: Operators tests ***', () => { const createConnection = async (setActive = false) => { @@ -60,12 +61,16 @@ describe('*** DataOperator: Operators tests ***', () => { expect(database).toBeTruthy(); const preparedRecords = await operateAppRecord({ + action: OperationType.CREATE, database: database!, value: { - buildNumber: 'build-7', - createdAt: 1, - id: 'id-18', - versionNumber: 'v-1', + record: undefined, + raw: { + buildNumber: 'build-7', + createdAt: 1, + id: 'id-18', + versionNumber: 'v-1', + }, }, }); @@ -80,8 +85,12 @@ describe('*** DataOperator: Operators tests ***', () => { expect(database).toBeTruthy(); const preparedRecords = await operateGlobalRecord({ + action: OperationType.CREATE, database: database!, - value: {id: 'g-1', name: 'g-n1', value: 'g-v1'}, + value: { + record: undefined, + raw: {id: 'g-1', name: 'g-n1', value: 'g-v1'}, + }, }); expect(preparedRecords).toBeTruthy(); @@ -95,14 +104,18 @@ describe('*** DataOperator: Operators tests ***', () => { expect(database).toBeTruthy(); const preparedRecords = await operateServersRecord({ + action: OperationType.CREATE, database: database!, value: { - dbPath: 'mm-server', - displayName: 's-displayName', - id: 's-1', - mentionCount: 1, - unreadCount: 0, - url: 'https://community.mattermost.com', + record: undefined, + raw: { + dbPath: 'mm-server', + displayName: 's-displayName', + id: 's-1', + mentionCount: 1, + unreadCount: 0, + url: 'https://community.mattermost.com', + }, }, }); @@ -110,21 +123,6 @@ describe('*** DataOperator: Operators tests ***', () => { expect(preparedRecords!.collection.modelClass.name).toMatch('Servers'); }); - it('=> operateCustomEmojiRecord: should return an array of type CustomEmoji', async () => { - expect.assertions(3); - - const database = await createConnection(); - expect(database).toBeTruthy(); - - const preparedRecords = await operateCustomEmojiRecord({ - database: database!, - value: {id: 'emo-1', name: 'emoji'}, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toMatch('CustomEmoji'); - }); - it('=> operateRoleRecord: should return an array of type Role', async () => { expect.assertions(3); @@ -132,8 +130,12 @@ describe('*** DataOperator: Operators tests ***', () => { expect(database).toBeTruthy(); const preparedRecords = await operateRoleRecord({ + action: OperationType.CREATE, database: database!, - value: {id: 'role-1', name: 'role-name-1', permissions: []}, + value: { + record: undefined, + raw: {id: 'role-1', name: 'role-name-1', permissions: []}, + }, }); expect(preparedRecords).toBeTruthy(); @@ -147,8 +149,12 @@ describe('*** DataOperator: Operators tests ***', () => { expect(database).toBeTruthy(); const preparedRecords = await operateSystemRecord({ + action: OperationType.CREATE, database: database!, - value: {id: 'system-1', name: 'system-name-1', value: 'system'}, + value: { + record: undefined, + raw: {id: 'system-1', name: 'system-name-1', value: 'system'}, + }, }); expect(preparedRecords).toBeTruthy(); @@ -162,8 +168,18 @@ describe('*** DataOperator: Operators tests ***', () => { expect(database).toBeTruthy(); const preparedRecords = await operateTermsOfServiceRecord({ + action: OperationType.CREATE, database: database!, - value: {id: 'system-1', acceptedAt: 1}, + value: { + record: undefined, + raw: { + id: 'tos-1', + acceptedAt: 1, + create_at: 1613667352029, + user_id: 'user1613667352029', + text: '', + }, + }, }); expect(preparedRecords).toBeTruthy(); @@ -172,216 +188,6 @@ describe('*** DataOperator: Operators tests ***', () => { ); }); - it('=> should create a record in the App table in the default database', async () => { - expect.assertions(2); - - // Creates a record in the App table - await DataOperator.handleIsolatedEntity({ - tableName: IsolatedEntities.APP, - values: [ - { - buildNumber: 'build-1', - createdAt: 1, - id: 'id-1', - versionNumber: 'version-1', - }, - ], - }); - - // Do a query and find out if the value has been registered in the App table of the default database - const connection = await DatabaseManager.getDefaultDatabase(); - expect(connection).toBeTruthy(); - - const records = (await connection!.collections. - get(APP). - query(Q.where('id', 'id-1')). - fetch()) as App[]; - - // We should expect to have a record returned as dictated by our query - expect(records.length).toBe(1); - }); - - it('=> should create several records in the App table in the default database', async () => { - expect.assertions(2); - - // Creates a record in the App table - await DataOperator.handleIsolatedEntity({ - tableName: IsolatedEntities.APP, - values: [ - { - buildNumber: 'build-10', - createdAt: 1, - id: 'id-10', - versionNumber: 'version-10', - }, - { - buildNumber: 'build-11', - createdAt: 1, - id: 'id-11', - versionNumber: 'version-11', - }, - { - buildNumber: 'build-12', - createdAt: 1, - id: 'id-12', - versionNumber: 'version-12', - }, - { - buildNumber: 'build-13', - createdAt: 1, - id: 'id-13', - versionNumber: 'version-13', - }, - ], - }); - - // Do a query and find out if the value has been registered in the App table of the default database - const defaultDB = await DatabaseManager.getDefaultDatabase(); - expect(defaultDB).toBeTruthy(); - - const records = (await defaultDB!.collections. - get(APP). - query(Q.where('id', Q.oneOf(['id-10', 'id-11', 'id-12', 'id-13']))). - fetch()) as App[]; - - // We should expect to have 4 records created - expect(records.length).toBe(4); - }); - - it('=> should update a record in the App table in the default database', async () => { - expect.assertions(3); - - const defaultDB = await DatabaseManager.getDefaultDatabase(); - expect(defaultDB).toBeTruthy(); - - // Update record having id 'id-1' - await DataOperator.handleIsolatedEntity({ - tableName: IsolatedEntities.APP, - values: [ - { - buildNumber: 'build-13-13', - createdAt: 1, - id: 'id-1', - versionNumber: 'version-1', - }, - ], - }); - - const records = (await defaultDB!.collections. - get(APP). - query(Q.where('id', 'id-1')). - fetch()) as App[]; - expect(records.length).toBeGreaterThan(0); - - // Verify if the buildNumber for this record has been updated - expect(records[0].buildNumber).toMatch('build-13-13'); - }); - - it('=> should update several records in the App table in the default database', async () => { - expect.assertions(4); - - const defaultDB = await DatabaseManager.getDefaultDatabase(); - expect(defaultDB).toBeTruthy(); - - // Update records having id 'id-10' and 'id-11' - await DataOperator.handleIsolatedEntity({ - tableName: IsolatedEntities.APP, - values: [ - { - buildNumber: 'build-10x', - createdAt: 1, - id: 'id-10', - versionNumber: 'version-10', - }, - { - buildNumber: 'build-11y', - createdAt: 1, - id: 'id-11', - versionNumber: 'version-11', - }, - ], - }); - - const records = (await defaultDB!.collections. - get(APP). - query(Q.where('id', Q.oneOf(['id-10', 'id-11']))). - fetch()) as App[]; - expect(records.length).toBe(2); - - // Verify if the buildNumber for those two record has been updated - expect(records[0].buildNumber).toMatch('build-10x'); - expect(records[1].buildNumber).toMatch('build-11y'); - }); - - it('=> [EDGE CASE] should UPDATE instead of CREATE record for existing id', async () => { - expect.assertions(3); - - const defaultDB = await DatabaseManager.getDefaultDatabase(); - expect(defaultDB).toBeTruthy(); - - await DataOperator.handleIsolatedEntity({ - tableName: IsolatedEntities.APP, - values: [ - { - buildNumber: 'build-10x', - createdAt: 1, - id: 'id-10', - versionNumber: 'version-10', - }, - { - buildNumber: 'build-11x', - createdAt: 1, - id: 'id-11', - versionNumber: 'version-11', - }, - ], - }); - - const records = (await defaultDB!.collections. - get(APP). - query(Q.where('id', Q.oneOf(['id-10', 'id-11']))). - fetch()) as App[]; - - // Verify if the buildNumber for those two record has been updated - expect(records[0].buildNumber).toMatch('build-10x'); - expect(records[1].buildNumber).toMatch('build-11x'); - }); - - it('=> [EDGE CASE] should CREATE instead of UPDATE record for non-existing id', async () => { - expect.assertions(3); - - const defaultDB = await DatabaseManager.getDefaultDatabase(); - expect(defaultDB).toBeTruthy(); - - // id-15 and id-16 do not exist but yet the optType is UPDATE. The operator should then prepareCreate the records instead of prepareUpdate - await DataOperator.handleIsolatedEntity({ - tableName: IsolatedEntities.APP, - values: [ - { - buildNumber: 'build-10x', - createdAt: 1, - id: 'id-15', - versionNumber: 'version-10', - }, - { - buildNumber: 'build-11x', - createdAt: 1, - id: 'id-16', - versionNumber: 'version-11', - }, - ], - }); - - const records = (await defaultDB!.collections. - get(APP). - query(Q.where('id', Q.oneOf(['id-15', 'id-16']))). - fetch()) as App[]; - - // Verify if the buildNumber for those two record has been created - expect(records[0].buildNumber).toMatch('build-10x'); - expect(records[1].buildNumber).toMatch('build-11x'); - }); - it('=> operatePostRecord: should return an array of type Post', async () => { expect.assertions(3); @@ -389,28 +195,32 @@ describe('*** DataOperator: Operators tests ***', () => { expect(database).toBeTruthy(); const preparedRecords = await operatePostRecord({ + action: OperationType.CREATE, database: database!, value: { - id: '8swgtrrdiff89jnsiwiip3y1eoe', - create_at: 1596032651748, - update_at: 1596032651748, - edit_at: 0, - delete_at: 0, - is_pinned: false, - user_id: 'q3mzxua9zjfczqakxdkowc6u6yy', - channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', - root_id: 'ps81iqbesfby8jayz7owg4yypoo', - parent_id: 'ps81iqbddesfby8jayz7owg4yypoo', - original_id: '', - message: 'Testing operator post', - type: '', - props: {}, - hashtags: '', - pending_post_id: '', - reply_count: 4, - last_reply_at: 0, - participants: null, - metadata: {}, + record: undefined, + raw: { + id: '8swgtrrdiff89jnsiwiip3y1eoe', + create_at: 1596032651748, + update_at: 1596032651748, + edit_at: 0, + delete_at: 0, + is_pinned: false, + user_id: 'q3mzxua9zjfczqakxdkowc6u6yy', + channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', + root_id: 'ps81iqbesfby8jayz7owg4yypoo', + parent_id: 'ps81iqbddesfby8jayz7owg4yypoo', + original_id: '', + message: 'Testing operator post', + type: '', + props: {}, + hashtags: '', + pending_post_id: '', + reply_count: 4, + last_reply_at: 0, + participants: null, + metadata: {}, + }, }, }); @@ -425,17 +235,23 @@ describe('*** DataOperator: Operators tests ***', () => { expect(database).toBeTruthy(); const preparedRecords = await operatePostInThreadRecord({ + action: OperationType.CREATE, database: database!, value: { - id: 'ps81iqbddesfby8jayz7owg4yypoo', - post_id: '8swgtrrdiff89jnsiwiip3y1eoe', - earliest: 1596032651748, - latest: 1597032651748, + record: undefined, + raw: { + id: 'ps81iqbddesfby8jayz7owg4yypoo', + post_id: '8swgtrrdiff89jnsiwiip3y1eoe', + earliest: 1596032651748, + latest: 1597032651748, + }, }, }); expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toMatch('PostsInThread'); + expect(preparedRecords!.collection.modelClass.name).toMatch( + 'PostsInThread', + ); }); it('=> operateReactionRecord: should return an array of type Reaction', async () => { @@ -445,15 +261,19 @@ describe('*** DataOperator: Operators tests ***', () => { expect(database).toBeTruthy(); const preparedRecords = await operateReactionRecord({ + action: OperationType.CREATE, database: database!, value: { - id: 'ps81iqbddesfby8jayz7owg4yypoo', - user_id: 'q3mzxua9zjfczqakxdkowc6u6yy', - post_id: 'ps81iqbddesfby8jayz7owg4yypoo', - emoji_name: 'thumbsup', - create_at: 1596032651748, - update_at: 1608253011321, - delete_at: 0, + record: undefined, + raw: { + id: 'ps81iqbddesfby8jayz7owg4yypoo', + user_id: 'q3mzxua9zjfczqakxdkowc6u6yy', + post_id: 'ps81iqbddesfby8jayz7owg4yypoo', + emoji_name: 'thumbsup', + create_at: 1596032651748, + update_at: 1608253011321, + delete_at: 0, + }, }, }); @@ -468,12 +288,21 @@ describe('*** DataOperator: Operators tests ***', () => { expect(database).toBeTruthy(); const preparedRecords = await operateFileRecord({ + action: OperationType.CREATE, database: database!, value: { - post_id: 'ps81iqbddesfby8jayz7owg4yypoo', - name: 'test_file', - extension: '.jpg', - size: 1000, + record: undefined, + raw: { + post_id: 'ps81iqbddesfby8jayz7owg4yypoo', + name: 'test_file', + extension: '.jpg', + size: 1000, + create_at: 1609253011321, + delete_at: 1609253011321, + height: 20, + update_at: 1609253011321, + user_id: 'wqyby5r5pinxxdqhoaomtacdhc', + }, }, }); @@ -488,12 +317,16 @@ describe('*** DataOperator: Operators tests ***', () => { expect(database).toBeTruthy(); const preparedRecords = await operatePostMetadataRecord({ + action: OperationType.CREATE, database: database!, value: { - id: 'ps81i4yypoo', - data: {}, - postId: 'ps81iqbddesfby8jayz7owg4yypoo', - type: 'opengraph', + record: undefined, + raw: { + id: 'ps81i4yypoo', + data: {}, + postId: 'ps81iqbddesfby8jayz7owg4yypoo', + type: 'opengraph', + }, }, }); @@ -508,13 +341,17 @@ describe('*** DataOperator: Operators tests ***', () => { expect(database).toBeTruthy(); const preparedRecords = await operateDraftRecord({ + action: OperationType.CREATE, database: database!, value: { - id: 'ps81i4yypoo', - root_id: 'ps81iqbddesfby8jayz7owg4yypoo', - message: 'draft message', - channel_id: 'channel_idp23232e', - files: [], + record: undefined, + raw: { + id: 'ps81i4yypoo', + root_id: 'ps81iqbddesfby8jayz7owg4yypoo', + message: 'draft message', + channel_id: 'channel_idp23232e', + files: [], + }, }, }); @@ -529,16 +366,213 @@ describe('*** DataOperator: Operators tests ***', () => { expect(database).toBeTruthy(); const preparedRecords = await operatePostsInChannelRecord({ + action: OperationType.CREATE, database: database!, value: { - id: 'ps81i4yypoo', - channel_id: 'channel_idp23232e', - earliest: 1608253011321, - latest: 1609253011321, + record: undefined, + raw: { + id: 'ps81i4yypoo', + channel_id: 'channel_idp23232e', + earliest: 1608253011321, + latest: 1609253011321, + }, }, }); expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toMatch('PostsInChannel'); + expect(preparedRecords!.collection.modelClass.name).toMatch( + 'PostsInChannel', + ); + }); + + it('=> operateUserRecord: should return an array of type User', async () => { + expect.assertions(3); + + const database = await createConnection(); + expect(database).toBeTruthy(); + + const preparedRecords = await operateUserRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: '9ciscaqbrpd6d8s68k76xb9bte', + is_bot: false, + create_at: 1599457495881, + update_at: 1607683720173, + delete_at: 0, + username: 'a.l', + auth_service: 'saml', + email: 'a.l@mattermost.com', + email_verified: true, + nickname: '', + first_name: 'A', + last_name: 'L', + position: 'Mobile Engineer', + roles: 'system_user', + props: {}, + notify_props: { + desktop: 'all', + desktop_sound: true, + email: true, + first_name: true, + mention_keys: '', + push: 'mention', + channel: true, + auto_responder_active: false, + auto_responder_message: 'Hello, I am out of office and unable to respond to messages.', + comments: 'never', + desktop_notification_sound: 'Hello', + push_status: 'online', + }, + last_password_update: 1604323112537, + last_picture_update: 1604686302260, + locale: 'en', + timezone: { + automaticTimezone: 'Indian/Mauritius', + manualTimezone: '', + useAutomaticTimezone: true, + }, + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toMatch('User'); + }); + + it('=> operatePreferenceRecord: should return an array of type Preference', async () => { + expect.assertions(3); + + const database = await createConnection(); + expect(database).toBeTruthy(); + + const preparedRecords = await operatePreferenceRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: {user_id: '9ciscaqbrpd6d8s68k76xb9bte', category: 'tutorial_step', name: '9ciscaqbrpd6d8s68k76xb9bte', value: '2'}, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toMatch('Preference'); + }); + + it('=> operatePreferenceRecord: should return an array of type TEAM_MEMBERSHIP', async () => { + expect.assertions(3); + + const database = await createConnection(); + expect(database).toBeTruthy(); + + const preparedRecords = await operateTeamMembershipRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + team_id: 'a', + user_id: 'ab', + roles: '3ngdqe1e7tfcbmam4qgnxp91bw', + delete_at: 0, + scheme_guest: false, + scheme_user: true, + scheme_admin: false, + explicit_roles: '', + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toMatch('TeamMembership'); + }); + + it('=> operateCustomEmojiRecord: should return an array of type CustomEmoji', async () => { + expect.assertions(3); + + const database = await createConnection(); + expect(database).toBeTruthy(); + + const preparedRecords = await operateCustomEmojiRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + id: 'i', + create_at: 1580913641769, + update_at: 1580913641769, + delete_at: 0, + creator_id: '4cprpki7ri81mbx8efixcsb8jo', + name: 'boomI', + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toMatch('CustomEmoji'); + }); + + it('=> operateGroupMembershipRecord: should return an array of type GroupMembership', async () => { + expect.assertions(3); + + const database = await createConnection(); + expect(database).toBeTruthy(); + + const preparedRecords = await operateGroupMembershipRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + user_id: 'u4cprpki7ri81mbx8efixcsb8jo', + group_id: 'g4cprpki7ri81mbx8efixcsb8jo', + + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toMatch('GroupMembership'); + }); + + it('=> operateChannelMembershipRecord: should return an array of type ChannelMembership', async () => { + expect.assertions(3); + + const database = await createConnection(); + expect(database).toBeTruthy(); + + const preparedRecords = await operateChannelMembershipRecord({ + action: OperationType.CREATE, + database: database!, + value: { + record: undefined, + raw: { + channel_id: '17bfnb1uwb8epewp4q3x3rx9go', + user_id: '9ciscaqbrpd6d8s68k76xb9bte', + roles: 'wqyby5r5pinxxdqhoaomtacdhc', + last_viewed_at: 1613667352029, + msg_count: 3864, + mention_count: 0, + notify_props: { + desktop: 'default', + email: 'default', + ignore_channel_mentions: 'default', + mark_unread: 'mention', + push: 'default', + }, + last_update_at: 1613667352029, + scheme_guest: false, + scheme_user: true, + scheme_admin: false, + explicit_roles: '', + }, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toMatch('ChannelMembership'); }); }); diff --git a/app/database/admin/data_operator/utils/index.ts b/app/database/admin/data_operator/utils/index.ts index f5a8231225..8e2cebe9e2 100644 --- a/app/database/admin/data_operator/utils/index.ts +++ b/app/database/admin/data_operator/utils/index.ts @@ -2,21 +2,36 @@ // See LICENSE.txt for license information. import {Q} from '@nozbe/watermelondb'; +import Model from '@nozbe/watermelondb/Model'; import {MM_TABLES} from '@constants/database'; -import {ChainPosts, SanitizePosts, SanitizeReactions, RawPost, RawReaction} from '@typings/database/database'; +import { + ChainPostsArgs, + IdenticalRecordArgs, + MatchExistingRecord, + RangeOfValueArgs, + RawPost, + RawReaction, + RawUser, RawValue, + RecordPair, + RetrieveRecordsArgs, + SanitizePostsArgs, + SanitizeReactionsArgs, +} from '@typings/database/database'; import Reaction from '@typings/database/reaction'; +import Post from '@typings/database/post'; +import {User} from '@database/server/models'; -const {REACTION} = MM_TABLES.SERVER; +const {POST, USER, REACTION} = MM_TABLES.SERVER; /** * sanitizePosts: Creates arrays of ordered and unordered posts. Unordered posts are those posts that are not * present in the orders array - * @param {SanitizePosts} sanitizePosts + * @param {SanitizePostsArgs} sanitizePosts * @param {RawPost[]} sanitizePosts.posts * @param {string[]} sanitizePosts.orders */ -export const sanitizePosts = ({posts, orders}: SanitizePosts) => { +export const sanitizePosts = ({posts, orders}: SanitizePostsArgs) => { const orderedPosts:RawPost[] = []; const unOrderedPosts:RawPost[] = []; @@ -29,22 +44,23 @@ export const sanitizePosts = ({posts, orders}: SanitizePosts) => { }); return { - orderedPosts, - unOrderedPosts, + postsOrdered: orderedPosts, + postsUnordered: unOrderedPosts, }; }; /** * createPostsChain: Basically creates the 'chain of posts' using the 'orders' array; each post is linked to the other * by the previous_post_id field. - * @param {ChainPosts} chainPosts + * @param {ChainPostsArgs} chainPosts * @param {string[]} chainPosts.orders * @param {RawPost[]} chainPosts.rawPosts * @param {string} chainPosts.previousPostId * @returns {RawPost[]} */ -export const createPostsChain = ({orders, rawPosts, previousPostId = ''}: ChainPosts) => { - const posts: RawPost[] = []; +export const createPostsChain = ({orders, rawPosts, previousPostId = ''}: ChainPostsArgs) => { + const posts: MatchExistingRecord[] = []; + rawPosts.forEach((post) => { const postId = post.id; const orderIndex = orders.findIndex((order) => { @@ -55,9 +71,9 @@ export const createPostsChain = ({orders, rawPosts, previousPostId = ''}: ChainP // This case will not occur as we are using 'ordered' posts for this step. However, if this happens, that // implies that we might be dealing with an unordered post and in which case we do not action on it. } else if (orderIndex === 0) { - posts.push({...post, prev_post_id: previousPostId}); + posts.push({record: undefined, raw: {...post, prev_post_id: previousPostId}}); } else { - posts.push({...post, prev_post_id: orders[orderIndex - 1]}); + posts.push({record: undefined, raw: {...post, prev_post_id: orders[orderIndex - 1]}}); } }); @@ -68,13 +84,13 @@ export const createPostsChain = ({orders, rawPosts, previousPostId = ''}: ChainP * sanitizeReactions: Treats reactions happening on a Post. For example, a user can add/remove an emoji. Hence, this function * tell us which reactions to create/delete in the Reaction table and which custom-emoji to create in our database. * For more information, please have a look at https://community.mattermost.com/core/pl/rq9e8jnonpyrmnyxpuzyc4d6ko - * @param {SanitizeReactions} sanitizeReactions + * @param {SanitizeReactionsArgs} sanitizeReactions * @param {Database} sanitizeReactions.database * @param {string} sanitizeReactions.post_id * @param {RawReaction[]} sanitizeReactions.rawReactions * @returns {Promise<{createReactions: RawReaction[], createEmojis: {name: string}[], deleteReactions: Reaction[]}>} */ -export const sanitizeReactions = async ({database, post_id, rawReactions}: SanitizeReactions) => { +export const sanitizeReactions = async ({database, post_id, rawReactions}: SanitizeReactionsArgs) => { const reactions = (await database.collections. get(REACTION). query(Q.where('post_id', post_id)). @@ -83,7 +99,7 @@ export const sanitizeReactions = async ({database, post_id, rawReactions}: Sanit // similarObjects: Contains objects that are in both the RawReaction array and in the Reaction entity const similarObjects: Reaction[] = []; - const createReactions: RawReaction[] = []; + const createReactions: MatchExistingRecord[] = []; const emojiSet = new Set(); @@ -100,7 +116,7 @@ export const sanitizeReactions = async ({database, post_id, rawReactions}: Sanit if (idxPresent === -1) { // So, we don't have a similar Reaction object. That one is new...so we'll create it - createReactions.push(rawReaction); + createReactions.push({record: undefined, raw: rawReaction}); // If that reaction is new, that implies that the emoji might also be new emojiSet.add(rawReaction.emoji_name); @@ -121,3 +137,66 @@ export const sanitizeReactions = async ({database, post_id, rawReactions}: Sanit return {createReactions, createEmojis, deleteReactions}; }; + +/** + * retrieveRecords: Retrieves records from the database + * @param {RetrieveRecordsArgs} records + * @param {Database} records.database + * @param {string} records.tableName + * @param {any} records.condition + * @returns {Promise} + */ +export const retrieveRecords = async ({database, tableName, condition}: RetrieveRecordsArgs) => { + const records = (await database.collections.get(tableName).query(condition).fetch()) as Model[]; + return records; +}; + +/** + * hasSimilarUpdateAt: Database Operations on some entities are expensive. As such, we would like to operate if and only if we are + * 100% sure that the records are actually different from what we already have in the database. + * @param {IdenticalRecordArgs} identicalRecord + * @param {string} identicalRecord.tableName + * @param {RecordValue} identicalRecord.newValue + * @param {Model} identicalRecord.existingRecord + * @returns {boolean} + */ +export const hasSimilarUpdateAt = ({tableName, newValue, existingRecord}: IdenticalRecordArgs) => { + const guardTables = [POST, USER]; + + if (guardTables.includes(tableName)) { + type Raw = RawPost | RawUser + type ExistingRecord = Post | User + + return (newValue as Raw).update_at === (existingRecord as ExistingRecord).updateAt; + } + return false; +}; + +/** + * This method extracts one particular field 'fieldName' from the raw values and returns them as a string array + * @param {RangeOfValueArgs} range + * @param {string} range.fieldName + * @param {RawValue[]} range.raws + * @returns {string[]} + */ +export const getRangeOfValues = ({fieldName, raws}: RangeOfValueArgs) => { + return raws.reduce((oneOfs, current: RawValue) => { + const key = fieldName as keyof typeof current; + const value: string = current[key] as string; + if (value) { + oneOfs.push(value); + } + return oneOfs; + }, [] as string[]); +}; + +/** + * getRawRecordPairs: Utility method that maps over the raws array to create an array of RecordPair + * @param {any[]} raws + * @returns {{record: undefined, raw: any}[]} + */ +export const getRawRecordPairs = (raws: any[]): RecordPair[] => { + return raws.map((raw) => { + return {raw, record: undefined}; + }); +}; diff --git a/app/database/admin/data_operator/utils/test.ts b/app/database/admin/data_operator/utils/test.ts index 8639fac24b..ed34ec94d6 100644 --- a/app/database/admin/data_operator/utils/test.ts +++ b/app/database/admin/data_operator/utils/test.ts @@ -1,23 +1,24 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import DatabaseManager from '@database/admin/database_manager'; import DataOperator from '@database/admin/data_operator'; +import DatabaseManager from '@database/admin/database_manager'; import {DatabaseType} from '@typings/database/enums'; +import {RawPost} from '@typings/database/database'; import {createPostsChain, sanitizePosts, sanitizeReactions} from './index'; import {mockedPosts, mockedReactions} from './mock'; -jest.mock('../../database_manager'); +jest.mock('@database/admin/database_manager'); describe('DataOperator: Utils tests', () => { it('=> sanitizePosts: should filter between ordered and unordered posts', () => { - const {orderedPosts, unOrderedPosts} = sanitizePosts({ + const {postsOrdered, postsUnordered} = sanitizePosts({ posts: Object.values(mockedPosts.posts), orders: mockedPosts.order, }); - expect(orderedPosts.length).toBe(4); - expect(unOrderedPosts.length).toBe(2); + expect(postsOrdered.length).toBe(4); + expect(postsUnordered.length).toBe(2); }); it('=> createPostsChain: should link posts amongst each other based on order array', () => { @@ -30,29 +31,37 @@ describe('DataOperator: Utils tests', () => { // eslint-disable-next-line max-nested-callbacks const post1 = chainedOfPosts.find((post) => { - return post.id === '8swgtrrdiff89jnsiwiip3y1eoe'; - }); + const p = post.raw as unknown as RawPost; + return p.id === '8swgtrrdiff89jnsiwiip3y1eoe'; + })?.raw as unknown as RawPost; + expect(post1).toBeTruthy(); - expect(post1!.prev_post_id).toBe(previousPostId); + expect(post1?.prev_post_id).toBe(previousPostId); // eslint-disable-next-line max-nested-callbacks const post2 = chainedOfPosts.find((post) => { - return post.id === '8fcnk3p1jt8mmkaprgajoxz115a'; - }); + const p = post.raw as unknown as RawPost; + return p.id === '8fcnk3p1jt8mmkaprgajoxz115a'; + })?.raw as unknown as RawPost; + expect(post2).toBeTruthy(); expect(post2!.prev_post_id).toBe('8swgtrrdiff89jnsiwiip3y1eoe'); // eslint-disable-next-line max-nested-callbacks const post3 = chainedOfPosts.find((post) => { - return post.id === '3y3w3a6gkbg73bnj3xund9o5ic'; - }); + const p = post.raw as unknown as RawPost; + return p.id === '3y3w3a6gkbg73bnj3xund9o5ic'; + })?.raw as unknown as RawPost; + expect(post3).toBeTruthy(); - expect(post3!.prev_post_id).toBe('8fcnk3p1jt8mmkaprgajoxz115a'); + expect(post3?.prev_post_id).toBe('8fcnk3p1jt8mmkaprgajoxz115a'); // eslint-disable-next-line max-nested-callbacks const post4 = chainedOfPosts.find((post) => { - return post.id === '4btbnmticjgw7ewd3qopmpiwqw'; - }); + const p = post.raw as unknown as RawPost; + return p.id === '4btbnmticjgw7ewd3qopmpiwqw'; + })?.raw as unknown as RawPost; + expect(post4).toBeTruthy(); expect(post4!.prev_post_id).toBe('3y3w3a6gkbg73bnj3xund9o5ic'); }); diff --git a/app/database/admin/data_operator/wrapper/index.ts b/app/database/admin/data_operator/wrapper/index.ts index 4b566e10af..7e51ac9f5b 100644 --- a/app/database/admin/data_operator/wrapper/index.ts +++ b/app/database/admin/data_operator/wrapper/index.ts @@ -1,10 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {Database} from '@nozbe/watermelondb'; + import DataOperator from '@database/admin/data_operator/handlers'; import DatabaseManager from '@database/admin/database_manager'; import DatabaseConnectionException from '@database/admin/exceptions/database_connection_exception'; -import {Database} from '@nozbe/watermelondb'; export const createDataOperator = async (serverUrl: string) => { // Retrieves the connection matching serverUrl diff --git a/app/database/admin/data_operator/wrapper/test.ts b/app/database/admin/data_operator/wrapper/test.ts index 80ea672654..c36197857f 100644 --- a/app/database/admin/data_operator/wrapper/test.ts +++ b/app/database/admin/data_operator/wrapper/test.ts @@ -8,6 +8,8 @@ import {DatabaseType} from '@typings/database/enums'; jest.mock('@database/admin/database_manager'); +/* eslint-disable @typescript-eslint/no-explicit-any */ + describe('*** DataOperator Wrapper ***', () => { it('=> wrapper should return an instance of DataOperator ', async () => { expect.assertions(1); @@ -219,13 +221,12 @@ describe('*** DataOperator Wrapper ***', () => { serverUrl: 'https://appv1.mattermost.com', }, }); - const dataOperator = await createDataOperator('https://appv1.mattermost.com'); const spyOnHandleReactions = jest.spyOn(dataOperator as any, 'handleReactions'); const spyOnHandleFiles = jest.spyOn(dataOperator as any, 'handleFiles'); const spyOnHandlePostMetadata = jest.spyOn(dataOperator as any, 'handlePostMetadata'); - const spyOnHandleIsolatedEntity = jest.spyOn(dataOperator as any, 'handleIsolatedEntity'); + const spyOnHandleCustomEmojis = jest.spyOn(dataOperator as any, 'handleIsolatedEntity'); const spyOnHandlePostsInThread = jest.spyOn(dataOperator as any, 'handlePostsInThread'); const spyOnHandlePostsInChannel = jest.spyOn(dataOperator as any, 'handlePostsInChannel'); @@ -329,8 +330,8 @@ describe('*** DataOperator Wrapper ***', () => { prepareRowsOnly: true, }); - expect(spyOnHandleIsolatedEntity).toHaveBeenCalledTimes(1); - expect(spyOnHandleIsolatedEntity).toHaveBeenCalledWith({ + expect(spyOnHandleCustomEmojis).toHaveBeenCalledTimes(1); + expect(spyOnHandleCustomEmojis).toHaveBeenCalledWith({ tableName: 'CustomEmoji', values: [ { @@ -345,7 +346,9 @@ describe('*** DataOperator Wrapper ***', () => { }); expect(spyOnHandlePostsInThread).toHaveBeenCalledTimes(1); - expect(spyOnHandlePostsInThread).toHaveBeenCalledWith([{earliest: 1596032651747, post_id: '8swgtrrdiff89jnsiwiip3y1eoe'}]); + expect(spyOnHandlePostsInThread).toHaveBeenCalledWith([ + {earliest: 1596032651747, post_id: '8swgtrrdiff89jnsiwiip3y1eoe'}, + ]); expect(spyOnHandlePostsInChannel).toHaveBeenCalledTimes(1); expect(spyOnHandlePostsInChannel).toHaveBeenCalledWith(posts.slice(0, 3)); diff --git a/app/database/admin/database_manager/__mocks__/index.ts b/app/database/admin/database_manager/__mocks__/index.ts index 55a933de17..c9ac3f2f3b 100644 --- a/app/database/admin/database_manager/__mocks__/index.ts +++ b/app/database/admin/database_manager/__mocks__/index.ts @@ -41,296 +41,298 @@ import { } from '@database/server/models'; import {serverSchema} from '@database/server/schema'; import logger from '@nozbe/watermelondb/utils/common/logger'; -import type {DatabaseInstance, DefaultNewServer, DatabaseConnection, Models, ActiveServerDatabase} from '@typings/database/database'; +import type { + ActiveServerDatabaseArgs, + DatabaseConnectionArgs, + DatabaseInstance, + DatabaseInstances, + DefaultNewServerArgs, + Models, +} from '@typings/database/database'; import {DatabaseType} from '@typings/database/enums'; import IServers from '@typings/database/servers'; const {SERVERS} = MM_TABLES.DEFAULT; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -logger.silence(); +if (__DEV__) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + logger.silence(); +} class DatabaseManager { - private activeDatabase: DatabaseInstance; - private defaultDatabase: DatabaseInstance; - private readonly defaultModels: Models; - private readonly iOSAppGroupDatabase: string | null; - private readonly androidFilesDirectory: string | null; - private readonly serverModels: Models; + private activeDatabase: DatabaseInstance; + private defaultDatabase: DatabaseInstance; + private readonly defaultModels: Models; + private readonly iOSAppGroupDatabase: string | null; + private readonly androidFilesDirectory: string | null; + private readonly serverModels: Models; - constructor() { - this.defaultModels = [App, Global, Servers]; - this.serverModels = [ - Channel, - ChannelInfo, - ChannelMembership, - CustomEmoji, - Draft, - File, - Group, - GroupMembership, - GroupsInChannel, - GroupsInTeam, - MyChannel, - MyChannelSettings, - MyTeam, - Post, - PostMetadata, - PostsInChannel, - PostsInThread, - Preference, - Reaction, - Role, - SlashCommand, - System, - Team, - TeamChannelHistory, - TeamMembership, - TeamSearchHistory, - TermsOfService, - User, - ]; + constructor() { + this.defaultModels = [App, Global, Servers]; + this.serverModels = [ + Channel, + ChannelInfo, + ChannelMembership, + CustomEmoji, + Draft, + File, + Group, + GroupMembership, + GroupsInChannel, + GroupsInTeam, + MyChannel, + MyChannelSettings, + MyTeam, + Post, + PostMetadata, + PostsInChannel, + PostsInThread, + Preference, + Reaction, + Role, + SlashCommand, + System, + Team, + TeamChannelHistory, + TeamMembership, + TeamSearchHistory, + TermsOfService, + User, + ]; - this.iOSAppGroupDatabase = null; - this.androidFilesDirectory = null; - } + this.iOSAppGroupDatabase = null; + this.androidFilesDirectory = null; + } - /** - * createDatabaseConnection: Creates database connection and registers the new connection into the default database. However, - * if a database connection could not be created, it will return undefined. - * @param {DatabaseConfigs} databaseConnection - * @param {boolean} shouldAddToDefaultDatabase - * - * @returns {Promise} - */ - createDatabaseConnection = async ({ - configs, - shouldAddToDefaultDatabase = true, - }: DatabaseConnection): Promise => { - const { - actionsEnabled = true, - dbName = 'default', - dbType = DatabaseType.DEFAULT, - serverUrl = undefined, - } = configs; + /** + * createDatabaseConnection: Creates database connection and registers the new connection into the default database. However, + * if a database connection could not be created, it will return undefined. + * @param {DatabaseConfigs} databaseConnection + * @param {boolean} shouldAddToDefaultDatabase + * + * @returns {Promise} + */ + createDatabaseConnection = async ({configs, shouldAddToDefaultDatabase = true}: DatabaseConnectionArgs): Promise => { + const { + actionsEnabled = true, + dbName = 'default', + dbType = DatabaseType.DEFAULT, + serverUrl = undefined, + } = configs; - try { - const databaseName = dbType === DatabaseType.DEFAULT ? 'default' : dbName; + try { + const databaseName = dbType === DatabaseType.DEFAULT ? 'default' : dbName; - // const databaseFilePath = this.getDatabaseDirectory(databaseName); - const migrations = dbType === DatabaseType.DEFAULT ? DefaultMigration : ServerMigration; - const modelClasses = dbType === DatabaseType.DEFAULT ? this.defaultModels : this.serverModels; - const schema = dbType === DatabaseType.DEFAULT ? defaultSchema : serverSchema; + // const databaseFilePath = this.getDatabaseDirectory(databaseName); + const migrations = dbType === DatabaseType.DEFAULT ? DefaultMigration : ServerMigration; + const modelClasses = dbType === DatabaseType.DEFAULT ? this.defaultModels : this.serverModels; + const schema = dbType === DatabaseType.DEFAULT ? defaultSchema : serverSchema; - const adapter = new LokiJSAdapter({ - dbName: databaseName, - migrations, - schema, - }); + const adapter = new LokiJSAdapter({ + dbName: databaseName, + migrations, + schema, + }); - // Registers the new server connection into the DEFAULT database - if (serverUrl && shouldAddToDefaultDatabase) { - await this.addServerToDefaultDatabase({ - databaseFilePath: databaseName, - displayName: dbName, - serverUrl, - }); - } - return new Database({adapter, actionsEnabled, modelClasses}); - } catch (e) { - // console.log(e); - } + // Registers the new server connection into the DEFAULT database + if (serverUrl && shouldAddToDefaultDatabase) { + await this.addServerToDefaultDatabase({ + databaseFilePath: databaseName, + displayName: dbName, + serverUrl, + }); + } + return new Database({adapter, actionsEnabled, modelClasses}); + } catch (e) { + // console.log(e); + } - return undefined; - }; + return undefined; + }; - /** - * setActiveServerDatabase: Set the new active server database. The serverUrl is used to ensure that we do not duplicate entries in the default database. - * This method should be called when switching to another server. - * @param {string} displayName - * @param {string} serverUrl - * @returns {Promise} - */ - setActiveServerDatabase = async ({displayName, serverUrl}: ActiveServerDatabase) => { - const isServerPresent = await this.isServerPresent(serverUrl); + /** + * setActiveServerDatabase: Set the new active server database. The serverUrl is used to ensure that we do not duplicate entries in the default database. + * This method should be called when switching to another server. + * @param {string} displayName + * @param {string} serverUrl + * @returns {Promise} + */ + setActiveServerDatabase = async ({displayName, serverUrl}: ActiveServerDatabaseArgs) => { + const isServerPresent = await this.isServerPresent(serverUrl); - this.activeDatabase = await this.createDatabaseConnection({ - configs: { - actionsEnabled: true, - dbName: displayName, - dbType: DatabaseType.SERVER, - serverUrl, - }, - shouldAddToDefaultDatabase: Boolean(!isServerPresent), - }); - }; + this.activeDatabase = await this.createDatabaseConnection({ + configs: { + actionsEnabled: true, + dbName: displayName, + dbType: DatabaseType.SERVER, + serverUrl, + }, + shouldAddToDefaultDatabase: Boolean(!isServerPresent), + }); + }; - /** - * isServerPresent : Confirms if the current serverUrl does not already exist in the database - * @param {String} serverUrl - * @returns {Promise} - */ - isServerPresent = async (serverUrl: String) => { - const allServers = await this.getAllServers(); - const existingServer = allServers?.filter((server) => { - return server.url === serverUrl; - }); - return existingServer && existingServer.length > 0; - }; + /** + * isServerPresent : Confirms if the current serverUrl does not already exist in the database + * @param {String} serverUrl + * @returns {Promise} + */ + isServerPresent = async (serverUrl: String) => { + const allServers = await this.getAllServers(); + const existingServer = allServers?.filter((server) => { + return server.url === serverUrl; + }); + return existingServer && existingServer.length > 0; + }; - /** - * getActiveServerDatabase: The DatabaseManager should be the only one setting the active database. Hence, we have made the activeDatabase property private. - * Use this getter method to retrieve the active database if it has been set in your code. - * @returns {DatabaseInstance} - */ - getActiveServerDatabase = (): DatabaseInstance => { - return this.activeDatabase; - }; + /** + * getActiveServerDatabase: The DatabaseManager should be the only one setting the active database. Hence, we have made the activeDatabase property private. + * Use this getter method to retrieve the active database if it has been set in your code. + * @returns {DatabaseInstance} + */ + getActiveServerDatabase = (): DatabaseInstance => { + return this.activeDatabase; + }; - /** - * getDefaultDatabase : Returns the default database. - * @returns {Database} default database - */ - getDefaultDatabase = async (): Promise => { - if (!this.defaultDatabase) { - await this.setDefaultDatabase(); - } - return this.defaultDatabase; - }; + /** + * getDefaultDatabase : Returns the default database. + * @returns {Database} default database + */ + getDefaultDatabase = async (): Promise => { + if (!this.defaultDatabase) { + await this.setDefaultDatabase(); + } + return this.defaultDatabase; + }; - /** - * retrieveDatabaseInstances: Using an array of server URLs, this method creates a database connection for each URL - * and return them to the caller. - * - * @param {string[]} serverUrls - * @returns {Promise<{url: string, dbInstance: DatabaseInstance}[] | null>} - */ - retrieveDatabaseInstances = async ( - serverUrls?: string[], - ): Promise<{ url: string; dbInstance: DatabaseInstance }[] | null> => { - if (serverUrls?.length) { - // Retrieve all server records from the default db - const allServers = await this.getAllServers(); + /** + * retrieveDatabaseInstances: Using an array of server URLs, this method creates a database connection for each URL + * and return them to the caller. + * + * @param {string[]} serverUrls + * @returns {Promise} + */ + retrieveDatabaseInstances = async (serverUrls?: string[]): Promise => { + if (serverUrls?.length) { + // Retrieve all server records from the default db + const allServers = await this.getAllServers(); - // Filter only those servers that are present in the serverUrls array - const servers = allServers!.filter((server: IServers) => { - return serverUrls.includes(server.url); - }); + // Filter only those servers that are present in the serverUrls array + const servers = allServers!.filter((server: IServers) => { + return serverUrls.includes(server.url); + }); - // Creates server database instances - if (servers.length) { - const databasePromises = servers.map(async (server: IServers) => { - const {displayName, url} = server; + // Creates server database instances + if (servers.length) { + const databasePromises = servers.map(async (server: IServers) => { + const {displayName, url} = server; - // Since we are retrieving existing URL ( and so database connections ) from the 'DEFAULT' database, shouldAddToDefaultDatabase is set to false - const dbInstance = await this.createDatabaseConnection({ - configs: { - actionsEnabled: true, - dbName: displayName, - dbType: DatabaseType.SERVER, - serverUrl: url, - }, - shouldAddToDefaultDatabase: false, - }); + // Since we are retrieving existing URL ( and so database connections ) from the 'DEFAULT' database, shouldAddToDefaultDatabase is set to false + const dbInstance = await this.createDatabaseConnection({ + configs: { + actionsEnabled: true, + dbName: displayName, + dbType: DatabaseType.SERVER, + serverUrl: url, + }, + shouldAddToDefaultDatabase: false, + }); - return {url, dbInstance}; - }); + return {url, dbInstance}; + }); - const databaseInstances = await Promise.all(databasePromises); - return databaseInstances; - } - return null; - } - return null; - }; + const databaseInstances = await Promise.all(databasePromises); + return databaseInstances; + } + return null; + } + return null; + }; - /** - * deleteDatabase: Removes the *.db file from the App-Group directory for iOS or the files directory on Android. - * Also, it removes its entry in the 'servers' table from the DEFAULT database - * @param {string} serverUrl - * @returns {Promise} - */ - deleteDatabase = async (serverUrl: string): Promise => { - try { - const defaultDB = await this.getDefaultDatabase(); - let server: IServers; + /** + * deleteDatabase: Removes the *.db file from the App-Group directory for iOS or the files directory on Android. + * Also, it removes its entry in the 'servers' table from the DEFAULT database + * @param {string} serverUrl + * @returns {Promise} + */ + deleteDatabase = async (serverUrl: string): Promise => { + try { + const defaultDB = await this.getDefaultDatabase(); + let server: IServers; - if (defaultDB) { - const serversRecords = (await defaultDB.collections. - get(SERVERS). - query(Q.where('url', serverUrl)). - fetch()) as IServers[]; - server = serversRecords?.[0] ?? undefined; + if (defaultDB) { + const serversRecords = (await defaultDB.collections.get(SERVERS).query(Q.where('url', serverUrl)).fetch()) as IServers[]; + server = serversRecords?.[0] ?? undefined; - if (server) { - // Perform a delete operation for this server record on the 'servers' table in default database - await defaultDB.action(async () => { - await server.destroyPermanently(); - }); + if (server) { + // Perform a delete operation for this server record on the 'servers' table in default database + await defaultDB.action(async () => { + await server.destroyPermanently(); + }); - return true; - } - return false; - } - return false; - } catch (e) { - // console.log('An error occurred while trying to delete database with name ', databaseName); - return false; - } - }; + return true; + } + return false; + } + return false; + } catch (e) { + // console.log('An error occurred while trying to delete database with name ', databaseName); + return false; + } + }; - /** - * getAllServers : Retrieves all the servers registered in the default database - * @returns {Promise} - */ - private getAllServers = async () => { - // Retrieve all server records from the default db - const defaultDatabase = await this.getDefaultDatabase(); - const allServers = defaultDatabase && ((await defaultDatabase.collections.get(MM_TABLES.DEFAULT.SERVERS).query().fetch()) as IServers[]); - return allServers; - }; + /** + * getAllServers : Retrieves all the servers registered in the default database + * @returns {Promise} + */ + private getAllServers = async () => { + // Retrieve all server records from the default db + const defaultDatabase = await this.getDefaultDatabase(); + const allServers = defaultDatabase && ((await defaultDatabase.collections.get(MM_TABLES.DEFAULT.SERVERS).query().fetch()) as IServers[]); + return allServers; + }; - /** - * setDefaultDatabase : Sets the default database. - * @returns {Promise} - */ - private setDefaultDatabase = async (): Promise => { - this.defaultDatabase = await this.createDatabaseConnection({ - configs: {dbName: 'default'}, - shouldAddToDefaultDatabase: false, - }); - return this.defaultDatabase; - }; + /** + * setDefaultDatabase : Sets the default database. + * @returns {Promise} + */ + private setDefaultDatabase = async (): Promise => { + this.defaultDatabase = await this.createDatabaseConnection({ + configs: {dbName: 'default'}, + shouldAddToDefaultDatabase: false, + }); - /** - * addServerToDefaultDatabase: Adds a record into the 'default' database - into the 'servers' table - for this new server connection - * @param {string} databaseFilePath - * @param {string} displayName - * @param {string} serverUrl - * @returns {Promise} - */ - private addServerToDefaultDatabase = async ({databaseFilePath, displayName, serverUrl}: DefaultNewServer) => { - try { - const defaultDatabase = await this.getDefaultDatabase(); - const isServerPresent = await this.isServerPresent(serverUrl); + return this.defaultDatabase; + }; - if (defaultDatabase && !isServerPresent) { - await defaultDatabase.action(async () => { - const serversCollection = defaultDatabase.collections.get('servers'); - await serversCollection.create((server: IServers) => { - server.dbPath = databaseFilePath; - server.displayName = displayName; - server.mentionCount = 0; - server.unreadCount = 0; - server.url = serverUrl; - }); - }); - } - } catch (e) { - // console.log({catchError: e}); - } - }; + /** + * addServerToDefaultDatabase: Adds a record into the 'default' database - into the 'servers' table - for this new server connection + * @param {string} databaseFilePath + * @param {string} displayName + * @param {string} serverUrl + * @returns {Promise} + */ + private addServerToDefaultDatabase = async ({databaseFilePath, displayName, serverUrl}: DefaultNewServerArgs) => { + try { + const defaultDatabase = await this.getDefaultDatabase(); + const isServerPresent = await this.isServerPresent(serverUrl); + + if (defaultDatabase && !isServerPresent) { + await defaultDatabase.action(async () => { + const serversCollection = defaultDatabase.collections.get('servers'); + await serversCollection.create((server: IServers) => { + server.dbPath = databaseFilePath; + server.displayName = displayName; + server.mentionCount = 0; + server.unreadCount = 0; + server.url = serverUrl; + }); + }); + } + } catch (e) { + // console.log({catchError: e}); + } + }; } export default new DatabaseManager(); diff --git a/app/database/admin/database_manager/index.ts b/app/database/admin/database_manager/index.ts index 85e1a76635..a797bae7ba 100644 --- a/app/database/admin/database_manager/index.ts +++ b/app/database/admin/database_manager/index.ts @@ -44,10 +44,11 @@ import { } from '@database/server/models'; import {serverSchema} from '@database/server/schema'; import type { - ActiveServerDatabase, - DatabaseConnection, + ActiveServerDatabaseArgs, + DatabaseConnectionArgs, DatabaseInstance, - DefaultNewServer, + DatabaseInstances, + DefaultNewServerArgs, MigrationEvents, Models, } from '@typings/database/database'; @@ -57,370 +58,368 @@ import {deleteIOSDatabase, getIOSAppGroupDetails} from '@utils/mattermost_manage const {SERVERS} = MM_TABLES.DEFAULT; -if (!__DEV__) { - // To prevent logs leaking in production environment - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - logger.silence(); -} - class DatabaseManager { - private activeDatabase: DatabaseInstance; - private defaultDatabase: DatabaseInstance; - private readonly defaultModels: Models; - private readonly iOSAppGroupDatabase: string | null; - private readonly androidFilesDirectory: string | null; - private readonly serverModels: Models; + private activeDatabase: DatabaseInstance; + private defaultDatabase: DatabaseInstance; + private readonly defaultModels: Models; + private readonly iOSAppGroupDatabase: string | null; + private readonly androidFilesDirectory: string | null; + private readonly serverModels: Models; - constructor() { - this.defaultModels = [App, Global, Servers]; - this.serverModels = [ - Channel, - ChannelInfo, - ChannelMembership, - CustomEmoji, - Draft, - File, - Group, - GroupMembership, - GroupsInChannel, - GroupsInTeam, - MyChannel, - MyChannelSettings, - MyTeam, - Post, - PostMetadata, - PostsInChannel, - PostsInThread, - Preference, - Reaction, - Role, - SlashCommand, - System, - Team, - TeamChannelHistory, - TeamMembership, - TeamSearchHistory, - TermsOfService, - User, - ]; + constructor() { + this.defaultModels = [App, Global, Servers]; + this.serverModels = [ + Channel, + ChannelInfo, + ChannelMembership, + CustomEmoji, + Draft, + File, + Group, + GroupMembership, + GroupsInChannel, + GroupsInTeam, + MyChannel, + MyChannelSettings, + MyTeam, + Post, + PostMetadata, + PostsInChannel, + PostsInThread, + Preference, + Reaction, + Role, + SlashCommand, + System, + Team, + TeamChannelHistory, + TeamMembership, + TeamSearchHistory, + TermsOfService, + User, + ]; - this.iOSAppGroupDatabase = Platform.OS === 'ios' ? getIOSAppGroupDetails().appGroupDatabase : null; - this.androidFilesDirectory = Platform.OS === 'android' ? FileSystem.documentDirectory : null; - } + this.iOSAppGroupDatabase = Platform.OS === 'ios' ? getIOSAppGroupDetails().appGroupDatabase : null; + this.androidFilesDirectory = Platform.OS === 'android' ? FileSystem.documentDirectory : null; + } - /** - * createDatabaseConnection: Creates database connection and registers the new connection into the default database. However, - * if a database connection could not be created, it will return undefined. - * @param {MMDatabaseConnection} databaseConnection - * @param {boolean} shouldAddToDefaultDatabase - * - * @returns {Promise} - */ - createDatabaseConnection = async ({ - configs, - shouldAddToDefaultDatabase = true, - }: DatabaseConnection): Promise => { - const { - actionsEnabled = true, - dbName = 'default', - dbType = DatabaseType.DEFAULT, - serverUrl = undefined, - } = configs; + /** + * createDatabaseConnection: Creates database connection and registers the new connection into the default database. However, + * if a database connection could not be created, it will return undefined. + * @param {MMDatabaseConnection} databaseConnection + * @param {boolean} shouldAddToDefaultDatabase + * + * @returns {Promise} + */ + createDatabaseConnection = async ({configs, shouldAddToDefaultDatabase = true}: DatabaseConnectionArgs): Promise => { + const { + actionsEnabled = true, + dbName = 'default', + dbType = DatabaseType.DEFAULT, + serverUrl = undefined, + } = configs; - try { - const databaseName = dbType === DatabaseType.DEFAULT ? 'default' : dbName; - const databaseFilePath = this.getDatabaseDirectory(databaseName); - const migrations = dbType === DatabaseType.DEFAULT ? DefaultMigration : ServerMigration; - const modelClasses = dbType === DatabaseType.DEFAULT ? this.defaultModels : this.serverModels; - const schema = dbType === DatabaseType.DEFAULT ? defaultSchema : serverSchema; + try { + const databaseName = dbType === DatabaseType.DEFAULT ? 'default' : dbName; + const databaseFilePath = this.getDatabaseDirectory(databaseName); + const migrations = dbType === DatabaseType.DEFAULT ? DefaultMigration : ServerMigration; + const modelClasses = dbType === DatabaseType.DEFAULT ? this.defaultModels : this.serverModels; + const schema = dbType === DatabaseType.DEFAULT ? defaultSchema : serverSchema; - const adapter = new SQLiteAdapter({ - dbName: databaseFilePath, - migrationEvents: this.buildMigrationCallbacks(databaseName), - migrations, - schema, - }); + const adapter = new SQLiteAdapter({ + dbName: databaseFilePath, + migrationEvents: this.buildMigrationCallbacks(databaseName), + migrations, + schema, + }); - // Registers the new server connection into the DEFAULT database - if (serverUrl && shouldAddToDefaultDatabase) { - await this.addServerToDefaultDatabase({databaseFilePath, displayName: dbName, serverUrl}); - } + // Registers the new server connection into the DEFAULT database + if (serverUrl && shouldAddToDefaultDatabase) { + await this.addServerToDefaultDatabase({ + databaseFilePath, + displayName: dbName, + serverUrl, + }); + } - return new Database({adapter, actionsEnabled, modelClasses}); - } catch (e) { - // console.log(e); - } + return new Database({adapter, actionsEnabled, modelClasses}); + } catch (e) { + // TODO : report to sentry? Show something on the UI ? + } - return undefined; - }; + return undefined; + }; - /** - * setActiveServerDatabase: Set the new active server database. The serverUrl is used to ensure that we do not duplicate entries in the default database. - * This method should be called when switching to another server. - * @param {string} displayName - * @param {string} serverUrl - * @returns {Promise} - */ - setActiveServerDatabase = async ({displayName, serverUrl}: ActiveServerDatabase) => { - const isServerPresent = await this.isServerPresent(serverUrl); + /** + * setActiveServerDatabase: Set the new active server database. The serverUrl is used to ensure that we do not duplicate entries in the default database. + * This method should be called when switching to another server. + * @param {string} displayName + * @param {string} serverUrl + * @returns {Promise} + */ + setActiveServerDatabase = async ({displayName, serverUrl}: ActiveServerDatabaseArgs) => { + const isServerPresent = await this.isServerPresent(serverUrl); - this.activeDatabase = await this.createDatabaseConnection({ - configs: { - actionsEnabled: true, - dbName: displayName, - dbType: DatabaseType.SERVER, - serverUrl, - }, - shouldAddToDefaultDatabase: Boolean(!isServerPresent), - }); - }; + this.activeDatabase = await this.createDatabaseConnection({ + configs: { + actionsEnabled: true, + dbName: displayName, + dbType: DatabaseType.SERVER, + serverUrl, + }, + shouldAddToDefaultDatabase: Boolean(!isServerPresent), + }); + }; - /** - * isServerPresent : Confirms if the current serverUrl does not already exist in the database - * @param {String} serverUrl - * @returns {Promise} - */ - isServerPresent = async (serverUrl: String) => { - const allServers = await this.getAllServers(); + /** + * isServerPresent : Confirms if the current serverUrl does not already exist in the database + * @param {String} serverUrl + * @returns {Promise} + */ + private isServerPresent = async (serverUrl: String) => { + const allServers = await this.getAllServers(); - const existingServer = allServers?.filter((server) => { - return server.url === serverUrl; - }); + const existingServer = allServers?.filter((server) => { + return server.url === serverUrl; + }); - return existingServer && existingServer.length > 0; - }; + return existingServer && existingServer?.length > 0; + }; - /** - * getActiveServerDatabase: The DatabaseManager should be the only one setting the active database. Hence, we have made the activeDatabase property private. - * Use this getter method to retrieve the active database if it has been set in your code. - * @returns {DatabaseInstance} - */ - getActiveServerDatabase = (): DatabaseInstance => { - return this.activeDatabase; - }; + /** + * getActiveServerDatabase: The DatabaseManager should be the only one setting the active database. Hence, we have made the activeDatabase property private. + * Use this getter method to retrieve the active database if it has been set in your code. + * @returns {DatabaseInstance} + */ + getActiveServerDatabase = (): DatabaseInstance => { + return this.activeDatabase; + }; - /** - * getDefaultDatabase : Returns the default database. - * @returns {Database} default database - */ - getDefaultDatabase = async (): Promise => { - if (!this.defaultDatabase) { - await this.setDefaultDatabase(); - } - return this.defaultDatabase; - }; + /** + * getDefaultDatabase : Returns the default database. + * @returns {Database} default database + */ + getDefaultDatabase = async (): Promise => { + if (!this.defaultDatabase) { + await this.setDefaultDatabase(); + } + return this.defaultDatabase; + }; - /** - * retrieveDatabaseInstances: Using an array of server URLs, this method creates a database connection for each URL - * and return them to the caller. - * - * @param {string[]} serverUrls - * @returns {Promise<{url: string, dbInstance: DatabaseInstance}[] | null>} - */ - retrieveDatabaseInstances = async ( - serverUrls?: string[], - ): Promise<{ url: string; dbInstance: DatabaseInstance }[] | null> => { - if (serverUrls?.length) { - // Retrieve all server records from the default db - const allServers = await this.getAllServers(); + /** + * retrieveDatabaseInstances: Using an array of server URLs, this method creates a database connection for each URL + * and return them to the caller. + * + * @param {string[]} serverUrls + * @returns {Promise<{url: string, dbInstance: DatabaseInstance}[] | null>} + */ + retrieveDatabaseInstances = async (serverUrls?: string[]): Promise => { + if (serverUrls?.length) { + // Retrieve all server records from the default db + const allServers = await this.getAllServers(); - // Filter only those servers that are present in the serverUrls array - const servers = allServers!.filter((server: IServers) => { - return serverUrls.includes(server.url); - }); + // Filter only those servers that are present in the serverUrls array + const servers = allServers!.filter((server: IServers) => { + return serverUrls.includes(server.url); + }); - // Creates server database instances - if (servers.length) { - const databasePromises = servers.map(async (server: IServers) => { - const {displayName, url} = server; + // Creates server database instances + if (servers.length) { + const databasePromises = await servers.map(async (server: IServers) => { + const {displayName, url} = server; - // Since we are retrieving existing URL ( and so database connections ) from the 'DEFAULT' database, shouldAddToDefaultDatabase is set to false - const dbInstance = await this.createDatabaseConnection({ - configs: { - actionsEnabled: true, - dbName: displayName, - dbType: DatabaseType.SERVER, - serverUrl: url, - }, - shouldAddToDefaultDatabase: false, - }); + // Since we are retrieving existing URL ( and so database connections ) from the 'DEFAULT' database, shouldAddToDefaultDatabase is set to false + const dbInstance = await this.createDatabaseConnection({ + configs: { + actionsEnabled: true, + dbName: displayName, + dbType: DatabaseType.SERVER, + serverUrl: url, + }, + shouldAddToDefaultDatabase: false, + }); - return {url, dbInstance}; - }); + return {url, dbInstance}; + }); - const databaseInstances = await Promise.all(databasePromises); - return databaseInstances; - } - return null; - } - return null; - }; + const databaseInstances = await Promise.all(databasePromises); + return databaseInstances; + } + return null; + } + return null; + }; - /** - * deleteDatabase: Removes the *.db file from the App-Group directory for iOS or the files directory on Android. - * Also, it removes its entry in the 'servers' table from the DEFAULT database - * @param {string} serverUrl - * @returns {Promise} - */ - deleteDatabase = async (serverUrl: string): Promise => { - try { - const defaultDB = await this.getDefaultDatabase(); - let server: IServers; + /** + * deleteDatabase: Removes the *.db file from the App-Group directory for iOS or the files directory on Android. + * Also, it removes its entry in the 'servers' table from the DEFAULT database + * @param {string} serverUrl + * @returns {Promise} + */ + deleteDatabase = async (serverUrl: string): Promise => { + try { + const defaultDB = await this.getDefaultDatabase(); + let server: IServers; - if (defaultDB) { - const serversRecords = (await defaultDB.collections. - get(SERVERS). - query(Q.where('url', serverUrl)). - fetch()) as IServers[]; - server = serversRecords?.[0] ?? undefined; + if (defaultDB) { + const serversRecords = (await defaultDB.collections. + get(SERVERS). + query(Q.where('url', serverUrl)). + fetch()) as IServers[]; + server = serversRecords?.[0] ?? undefined; - if (server) { - // Perform a delete operation for this server record on the 'servers' table in default database - await defaultDB.action(async () => { - await server.destroyPermanently(); - }); + if (server) { + // Perform a delete operation for this server record on the 'servers' table in default database + await defaultDB.action(async () => { + await server.destroyPermanently(); + }); - const databaseName = server.displayName; + const databaseName = server.displayName; - if (Platform.OS === 'ios') { - // On iOS, we'll delete the *.db file under the shared app-group/databases folder - deleteIOSDatabase({databaseName}); - return true; - } + if (Platform.OS === 'ios') { + // On iOS, we'll delete the *.db file under the shared app-group/databases folder + deleteIOSDatabase({databaseName}); + return true; + } - // On Android, we'll delete both the *.db file and the *.db-journal file - const androidFilesDir = `${this.androidFilesDirectory}databases/`; - const databaseFile = `${androidFilesDir}${databaseName}.db`; - const databaseJournal = `${androidFilesDir}${databaseName}.db-journal`; + // On Android, we'll delete both the *.db file and the *.db-journal file + const androidFilesDir = `${this.androidFilesDirectory}databases/`; + const databaseFile = `${androidFilesDir}${databaseName}.db`; + const databaseJournal = `${androidFilesDir}${databaseName}.db-journal`; - await FileSystem.deleteAsync(databaseFile); - await FileSystem.deleteAsync(databaseJournal); + await FileSystem.deleteAsync(databaseFile); + await FileSystem.deleteAsync(databaseJournal); - return true; - } - return false; - } - return false; - } catch (e) { - // console.log('An error occurred while trying to delete database with name ', databaseName); - return false; - } - }; + return true; + } + return false; + } + return false; + } catch (e) { + // console.log('An error occurred while trying to delete database with name ', databaseName); + return false; + } + }; - /** - * factoryReset: Removes the databases directory and all its contents on the respective platform - * @param {boolean} shouldRemoveDirectory - * @returns {Promise} - */ - factoryReset = async (shouldRemoveDirectory: boolean): Promise => { - try { - //On iOS, we'll delete the databases folder under the shared AppGroup folder - if (Platform.OS === 'ios') { - deleteIOSDatabase({shouldRemoveDirectory}); - return true; - } + /** + * factoryReset: Removes the databases directory and all its contents on the respective platform + * @param {boolean} shouldRemoveDirectory + * @returns {Promise} + */ + factoryReset = async (shouldRemoveDirectory: boolean): Promise => { + try { + //On iOS, we'll delete the databases folder under the shared AppGroup folder + if (Platform.OS === 'ios') { + deleteIOSDatabase({shouldRemoveDirectory}); + return true; + } - // On Android, we'll remove the databases folder under the Document Directory - const androidFilesDir = `${FileSystem.documentDirectory}databases/`; - await FileSystem.deleteAsync(androidFilesDir); - return true; - } catch (e) { - // console.log('An error occurred while trying to delete the databases directory); - return false; - } - }; + // On Android, we'll remove the databases folder under the Document Directory + const androidFilesDir = `${FileSystem.documentDirectory}databases/`; + await FileSystem.deleteAsync(androidFilesDir); + return true; + } catch (e) { + // console.log('An error occurred while trying to delete the databases directory); + return false; + } + }; - /** - * getAllServers : Retrieves all the servers registered in the default database - * @returns {Promise} - */ - private getAllServers = async () => { - // Retrieve all server records from the default db - const defaultDatabase = await this.getDefaultDatabase(); - const allServers = - defaultDatabase && - ((await defaultDatabase.collections.get(MM_TABLES.DEFAULT.SERVERS).query().fetch()) as IServers[]); - return allServers; - }; + /** + * getAllServers : Retrieves all the servers registered in the default database + * @returns {Promise} + */ + private getAllServers = async () => { + // Retrieve all server records from the default db + const defaultDatabase = await this.getDefaultDatabase(); + const allServers = defaultDatabase && ((await defaultDatabase.collections.get(MM_TABLES.DEFAULT.SERVERS).query().fetch()) as IServers[]); + return allServers; + }; - /** - * setDefaultDatabase : Sets the default database. - * @returns {Promise} - */ - private setDefaultDatabase = async (): Promise => { - this.defaultDatabase = await this.createDatabaseConnection({ - configs: {dbName: 'default'}, - shouldAddToDefaultDatabase: false, - }); - return this.defaultDatabase; - }; + /** + * setDefaultDatabase : Sets the default database. + * @returns {Promise} + */ + private setDefaultDatabase = async (): Promise => { + this.defaultDatabase = await this.createDatabaseConnection({ + configs: {dbName: 'default'}, + shouldAddToDefaultDatabase: false, + }); - /** - * addServerToDefaultDatabase: Adds a record into the 'default' database - into the 'servers' table - for this new server connection - * @param {string} databaseFilePath - * @param {string} displayName - * @param {string} serverUrl - * @returns {Promise} - */ - private addServerToDefaultDatabase = async ({databaseFilePath, displayName, serverUrl}: DefaultNewServer) => { - try { - const defaultDatabase = await this.getDefaultDatabase(); - const isServerPresent = await this.isServerPresent(serverUrl); + return this.defaultDatabase; + }; - if (defaultDatabase && !isServerPresent) { - await defaultDatabase.action(async () => { - const serversCollection = defaultDatabase.collections.get('servers'); - await serversCollection.create((server: IServers) => { - server.dbPath = databaseFilePath; - server.displayName = displayName; - server.mentionCount = 0; - server.unreadCount = 0; - server.url = serverUrl; - }); - }); - } - } catch (e) { - // console.log({catchError: e}); - } - }; + /** + * addServerToDefaultDatabase: Adds a record into the 'default' database - into the 'servers' table - for this new server connection + * @param {string} databaseFilePath + * @param {string} displayName + * @param {string} serverUrl + * @returns {Promise} + */ + private addServerToDefaultDatabase = async ({databaseFilePath, displayName, serverUrl}: DefaultNewServerArgs) => { + try { + const defaultDatabase = await this.getDefaultDatabase(); + const isServerPresent = await this.isServerPresent(serverUrl); - /** - * buildMigrationCallbacks: Creates a set of callbacks that can be used to monitor the migration process. - * For example, we can display a processing spinner while we have a migration going on. Moreover, we can also - * hook into those callbacks to assess how many of our servers successfully completed their migration. - * @param {string} dbName - * @returns {MigrationEvents} - */ - private buildMigrationCallbacks = (dbName: string) => { - const migrationEvents: MigrationEvents = { - onSuccess: () => { - return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_SUCCESS, {dbName}); - }, - onStarted: () => { - return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_STARTED, {dbName}); - }, - onFailure: (error) => { - return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_ERROR, {dbName, error}); - }, - }; + if (defaultDatabase && !isServerPresent) { + await defaultDatabase.action(async () => { + const serversCollection = defaultDatabase.collections.get('servers'); + await serversCollection.create((server: IServers) => { + server.dbPath = databaseFilePath; + server.displayName = displayName; + server.mentionCount = 0; + server.unreadCount = 0; + server.url = serverUrl; + }); + }); + } + } catch (e) { + // TODO : report to sentry? Show something on the UI ? + } + }; - return migrationEvents; - }; + /** + * buildMigrationCallbacks: Creates a set of callbacks that can be used to monitor the migration process. + * For example, we can display a processing spinner while we have a migration going on. Moreover, we can also + * hook into those callbacks to assess how many of our servers successfully completed their migration. + * @param {string} dbName + * @returns {MigrationEvents} + */ + private buildMigrationCallbacks = (dbName: string) => { + const migrationEvents: MigrationEvents = { + onSuccess: () => { + return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_SUCCESS, { + dbName, + }); + }, + onStarted: () => { + return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_STARTED, { + dbName, + }); + }, + onFailure: (error) => { + return DeviceEventEmitter.emit(MIGRATION_EVENTS.MIGRATION_ERROR, { + dbName, + error, + }); + }, + }; - /** - * getDatabaseDirectory: Using the database name, this method will return the database directory for each platform. - * On iOS, it will point towards the AppGroup shared directory while on Android, it will point towards the Files Directory. - * Please note that in each case, the *.db files will be created/grouped under a 'databases' sub-folder. - * iOS Simulator : appGroup => /Users/{username}/Library/Developer/CoreSimulator/Devices/DA6F1C73/data/Containers/Shared/AppGroup/ACA65327/databases"} - * Android Device: file:///data/user/0/com.mattermost.rnbeta/files/databases - * - * @param {string} dbName - * @returns {string} - */ - private getDatabaseDirectory = (dbName: string): string => { - return Platform.OS === 'ios' ? `${this.iOSAppGroupDatabase}/${dbName}.db` : `${FileSystem.documentDirectory}${dbName}.db`; - }; + return migrationEvents; + }; + + /** + * getDatabaseDirectory: Using the database name, this method will return the database directory for each platform. + * On iOS, it will point towards the AppGroup shared directory while on Android, it will point towards the Files Directory. + * Please note that in each case, the *.db files will be created/grouped under a 'databases' sub-folder. + * iOS Simulator : appGroup => /Users/{username}/Library/Developer/CoreSimulator/Devices/DA6F1C73/data/Containers/Shared/AppGroup/ACA65327/databases"} + * Android Device: file:///data/user/0/com.mattermost.rnbeta/files/databases + * + * @param {string} dbName + * @returns {string} + */ + private getDatabaseDirectory = (dbName: string): string => { + return Platform.OS === 'ios' ? `${this.iOSAppGroupDatabase}/${dbName}.db` : `${FileSystem.documentDirectory}${dbName}.db`; + }; } if (!__DEV__) { diff --git a/app/database/admin/database_manager/test.ts b/app/database/admin/database_manager/test.ts index b5903a9c50..875abe7543 100644 --- a/app/database/admin/database_manager/test.ts +++ b/app/database/admin/database_manager/test.ts @@ -18,6 +18,8 @@ const {SERVERS} = MM_TABLES.DEFAULT; // 1. Android/iOS file path // 2. Deletion of the 'databases' folder on those two platforms +/* eslint-disable @typescript-eslint/no-explicit-any */ + describe('*** Database Manager tests ***', () => { it('=> should return a default database', async () => { expect.assertions(2); @@ -49,7 +51,7 @@ describe('*** Database Manager tests ***', () => { }); it('=> should switch between active server connections', async () => { - expect.assertions(7); + expect.assertions(5); let activeServer: DatabaseInstance; let adapter; @@ -60,10 +62,7 @@ describe('*** Database Manager tests ***', () => { const setActiveServer = async ({displayName, serverUrl}:{displayName: string, serverUrl: string}) => { // now we set the active database - const server = await DatabaseManager.setActiveServerDatabase({displayName, serverUrl}); - - // setActiveServer should be undefined as the method does not actually return anything - expect(server).toBeUndefined(); + await DatabaseManager.setActiveServerDatabase({displayName, serverUrl}); }; await setActiveServer({displayName: 'community mattermost', serverUrl: 'https://appv1.mattermost.com'}); @@ -71,6 +70,7 @@ describe('*** Database Manager tests ***', () => { // let's verify if we now have a value for activeServer activeServer = await DatabaseManager.getActiveServerDatabase(); expect(activeServer).toBeDefined(); + adapter = activeServer!.adapter as any; const currentDBName = adapter.underlyingAdapter._dbName; expect(currentDBName).toStrictEqual('community mattermost'); @@ -79,6 +79,7 @@ describe('*** Database Manager tests ***', () => { await setActiveServer({displayName: 'appv2', serverUrl: 'https://appv2.mattermost.com'}); activeServer = await DatabaseManager.getActiveServerDatabase(); expect(activeServer).toBeDefined(); + adapter = activeServer!.adapter as any; const newDBName = adapter.underlyingAdapter._dbName; expect(newDBName).toStrictEqual('appv2'); diff --git a/app/database/admin/exceptions/database_operator_exception.ts b/app/database/admin/exceptions/data_operator_exception.ts similarity index 56% rename from app/database/admin/exceptions/database_operator_exception.ts rename to app/database/admin/exceptions/data_operator_exception.ts index 308d501996..42e3626394 100644 --- a/app/database/admin/exceptions/database_operator_exception.ts +++ b/app/database/admin/exceptions/data_operator_exception.ts @@ -2,9 +2,9 @@ // See LICENSE.txt for license information. /** - * DatabaseOperatorException: This exception can be used whenever an issue arises at the operator level. For example, if a required field is missing. + * DataOperatorException: This exception can be used whenever an issue arises at the operator level. For example, if a required field is missing. */ -class DatabaseOperatorException extends Error { +class DataOperatorException extends Error { error : Error | undefined; constructor(message: string, error?: Error) { super(message); @@ -12,4 +12,4 @@ class DatabaseOperatorException extends Error { this.error = error; } } -export default DatabaseOperatorException; +export default DataOperatorException; diff --git a/app/database/server/models/user.ts b/app/database/server/models/user.ts index 15bb9fb86a..c4e14b0a3f 100644 --- a/app/database/server/models/user.ts +++ b/app/database/server/models/user.ts @@ -60,6 +60,9 @@ export default class User extends Model { /** auth_service : The type of authentication service registered to that user */ @field('auth_service') authService!: string; + /** update_at : The timestamp at which this user account has been updated */ + @field('update_at') updateAt!: number; + /** delete_at : The timestamp at which this user account has been archived/deleted */ @field('delete_at') deleteAt!: number; @@ -90,7 +93,7 @@ export default class User extends Model { /** position : The user's position in the company */ @field('position') position!: string; - /** roles : The associated roles that this user has */ + /** roles : The associated roles that this user has. Multiple roles concatenated together with comma to form a single string. */ @field('roles') roles!: string; /** status : The presence status for the user */ diff --git a/app/database/server/schema/table_schemas/user.ts b/app/database/server/schema/table_schemas/user.ts index c879a1be01..82476b087b 100644 --- a/app/database/server/schema/table_schemas/user.ts +++ b/app/database/server/schema/table_schemas/user.ts @@ -11,6 +11,7 @@ export default tableSchema({ name: USER, columns: [ {name: 'auth_service', type: 'string'}, + {name: 'update_at', type: 'number'}, {name: 'delete_at', type: 'number'}, {name: 'email', type: 'string'}, {name: 'first_name', type: 'string'}, diff --git a/app/database/server/schema/test.ts b/app/database/server/schema/test.ts index 96a3e01979..52bbae2b00 100644 --- a/app/database/server/schema/test.ts +++ b/app/database/server/schema/test.ts @@ -464,6 +464,7 @@ describe('*** Test schema for SERVER database ***', () => { name: USER, columns: { auth_service: {name: 'auth_service', type: 'string'}, + update_at: {name: 'update_at', type: 'number'}, delete_at: {name: 'delete_at', type: 'number'}, email: {name: 'email', type: 'string'}, first_name: {name: 'first_name', type: 'string'}, @@ -484,6 +485,7 @@ describe('*** Test schema for SERVER database ***', () => { }, columnArray: [ {name: 'auth_service', type: 'string'}, + {name: 'update_at', type: 'number'}, {name: 'delete_at', type: 'number'}, {name: 'email', type: 'string'}, {name: 'first_name', type: 'string'}, diff --git a/types/database/database.d.ts b/types/database/database.d.ts index abf69e1ba8..329cecaaad 100644 --- a/types/database/database.d.ts +++ b/types/database/database.d.ts @@ -1,12 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {AppSchema, Database} from '@nozbe/watermelondb'; +import {Database} from '@nozbe/watermelondb'; import Model from '@nozbe/watermelondb/Model'; -import {Migration} from '@nozbe/watermelondb/Schema/migrations'; +import {Clause} from '@nozbe/watermelondb/QueryDescription'; import {Class} from '@nozbe/watermelondb/utils/common'; -import {DatabaseType, IsolatedEntities} from './enums'; +import {DatabaseType, IsolatedEntities, OperationType} from './enums'; export type MigrationEvents = { onSuccess: () => void; @@ -14,13 +14,6 @@ export type MigrationEvents = { onFailure: (error: string) => void; }; -export type MMAdaptorOptions = { - dbPath: string; - schema: AppSchema; - migrationSteps?: Migration[]; - migrationEvents?: MigrationEvents; -}; - export type DatabaseConfigs = { actionsEnabled?: boolean; dbName: string; @@ -28,7 +21,7 @@ export type DatabaseConfigs = { serverUrl?: string; }; -export type DefaultNewServer = { +export type DefaultNewServerArgs = { databaseFilePath: string; displayName: string; serverUrl: string; @@ -60,7 +53,7 @@ export type RawServers = { }; export type RawCustomEmoji = { - id?: string; + id: string; name: string; create_at?: number; update_at?: number; @@ -71,7 +64,10 @@ export type RawCustomEmoji = { export type RawRole = { id: string; name: string; - permissions: []; + display_name: string; + description: string; + permissions: string[]; + scheme_managed: boolean; }; export type RawSystem = { @@ -83,10 +79,12 @@ export type RawSystem = { export type RawTermsOfService = { id: string; acceptedAt: number; + create_at: number; + user_id: string; + text: string; }; export type RawDraft = { - id?: string; channel_id: string; files?: FileInfo[]; message?: string; @@ -196,6 +194,100 @@ export type RawPost = { }; }; +export type ChannelType = 'D' | 'O' | 'G' | 'P'; + +export type RawUser = { + id: string; + auth_service: string; + create_at: number; + delete_at: number; + email: string; + email_verified: boolean; + failed_attempts?: number; + first_name: string; + is_bot: boolean; + last_name: string; + last_password_update: number; + last_picture_update: number; + locale: string; + mfa_active?: boolean; + nickname: string; + notify_props: { + channel: boolean; + desktop: string; + desktop_sound: boolean; + email: boolean; + first_name: boolean; + mention_keys: string; + push: string; + auto_responder_active: boolean; + auto_responder_message: string; + desktop_notification_sound: string; // Not in use by the mobile app + push_status: string; + comments: string; + }; + position?: string; + props: UserProps; + roles: string; + timezone: { + useAutomaticTimezone: boolean; + manualTimezone: string; + automaticTimezone: string; + }; + terms_of_service_create_at?: number; + terms_of_service_id?: string; + update_at: number; + username: string; +}; + +export type RawPreference = { + id?: string; + user_id: string; + category: string; + name: string; + value: string; +}; + +export type RawTeamMembership = { + delete_at: number; + explicit_roles: string; + id?: string; + roles: string; + scheme_admin: boolean; + scheme_guest: boolean; + scheme_user: boolean; + team_id: string; + user_id: string; +}; + +export type RawGroupMembership = { + id?: string; + user_id: string; + group_id: string; +}; + +export type RawChannelMembership = { + id?: string; + channel_id: string; + user_id: string; + roles: string; + last_viewed_at: number; + msg_count: number; + mention_count: number; + notify_props: { + desktop: string; + email: string; + ignore_channel_mentions: string; + mark_unread: string; + push: string; + }; + last_update_at: number; + scheme_guest: boolean; + scheme_user: boolean; + scheme_admin: boolean; + explicit_roles: string; +}; + export type RawChannelMembers = { channel_id: string; explicit_roles: string; @@ -211,8 +303,6 @@ export type RawChannelMembers = { user_id: string; }; -export type ChannelType = 'D' | 'O' | 'G' | 'P'; - export type RawChannel = { create_at: number; creator_id: string; @@ -241,101 +331,141 @@ export type RawPostsInThread = { post_id: string; }; -export type RecordValue = +export type RawValue = | RawApp + | RawChannelMembership | RawCustomEmoji | RawDraft | RawFile | RawGlobal + | RawGroupMembership | RawPost | RawPostMetadata | RawPostsInChannel | RawPostsInThread + | RawPreference | RawReaction | RawRole | RawServers | RawSystem - | RawTermsOfService; + | RawTeamMembership + | RawTermsOfService + | RawUser; -export type Operator = { +export type MatchExistingRecord = { record?: Model; raw: RawValue }; + +export type DataFactoryArgs = { database: Database; - value: RecordValue; + generator?: (model: Model) => void; + tableName?: string; + value: MatchExistingRecord; + action: OperationType; }; -export type RecordOperator = (operator: Operator) => Promise; - -export type BaseOperator = Operator & { - generator: (model: Model) => void; +export type PrepareForDatabaseArgs = { tableName: string; + createRaws?: MatchExistingRecord[]; + updateRaws?: MatchExistingRecord[]; + recordOperator: (DataFactoryArgs) => void; }; -export type ExecuteRecords = { - tableName: string; - values: RecordValue[]; - recordOperator: RecordOperator; -}; +export type PrepareRecordsArgs = PrepareForDatabaseArgs & { database: Database; }; -export type PrepareRecords = ExecuteRecords & { database: Database }; +export type BatchOperationsArgs = { database: Database; models: Model[] }; -export type BatchOperations = { database: Database; models: Model[] }; - -export type HandleIsolatedEntityData = { +export type HandleIsolatedEntityArgs = { tableName: IsolatedEntities; - values: RecordValue[]; + values: RawValue[]; }; export type Models = Class[]; // The elements needed to create a new connection -export type DatabaseConnection = { +export type DatabaseConnectionArgs = { configs: DatabaseConfigs; shouldAddToDefaultDatabase: boolean; }; // The elements required to switch to another active server database -export type ActiveServerDatabase = { displayName: string; serverUrl: string }; +export type ActiveServerDatabaseArgs = { displayName: string; serverUrl: string }; -export type HandleReactions = { +export type HandleReactionsArgs = { reactions: RawReaction[]; prepareRowsOnly: boolean; }; -export type HandleFiles = { +export type HandleFilesArgs = { files: RawFile[]; prepareRowsOnly: boolean; }; -export type HandlePostMetadata = { +export type HandlePostMetadataArgs = { embeds?: { embed: RawEmbed[]; postId: string }[]; images?: { images: Dictionary; postId: string }[]; prepareRowsOnly: boolean; }; -export type HandlePosts = { +export type HandlePostsArgs = { orders: string[]; values: RawPost[]; previousPostId?: string; }; -export type SanitizeReactions = { +export type SanitizeReactionsArgs = { database: Database; post_id: string; rawReactions: RawReaction[]; }; -export type ChainPosts = { +export type ChainPostsArgs = { orders: string[]; rawPosts: RawPost[]; previousPostId: string; }; -export type SanitizePosts = { +export type SanitizePostsArgs = { posts: RawPost[]; orders: string[]; }; -export type IdenticalRecord = { +export type IdenticalRecordArgs = { existingRecord: Model; - newValue: RecordValue; + newValue: RawValue; tableName: string; }; + +export type RetrieveRecordsArgs = { + database: Database; + tableName: string; + condition: Clause; +}; + +export type ProcessInputsArgs = { + rawValues: RawValue[]; + tableName: string; + fieldName: string; + comparator: (existing: Model, newElement: RawValue) => boolean; +}; + +export type HandleEntityRecordsArgs = { + comparator: (existing: Model, newElement: RawValue) => boolean; + fieldName: string; + operator: (DataFactoryArgs) => Promise; + rawValues: RawValue[]; + tableName: string; +}; + +export type DatabaseInstances = { + url: string; + dbInstance: DatabaseInstance; +}; + +export type RangeOfValueArgs = { + raws: RawValue[]; + fieldName: string; +}; + +export type RecordPair = { + record?: Model; + raw: RawValue; +} diff --git a/types/database/enums.ts b/types/database/enums.ts index 012d345ec0..3c4b4d8f6d 100644 --- a/types/database/enums.ts +++ b/types/database/enums.ts @@ -2,23 +2,23 @@ // See LICENSE.txt for license information. export enum OperationType { - CREATE = 'CREATE', - UPDATE = 'UPDATE', - DELETE = 'DELETE', + CREATE = 'CREATE', + UPDATE = 'UPDATE', + DELETE = 'DELETE', } export enum IsolatedEntities { - APP = 'app', - GLOBAL = 'global', - SERVERS = 'servers', - CUSTOM_EMOJI = 'CustomEmoji', - ROLE = 'Role', - SYSTEM = 'System', - TERMS_OF_SERVICE = 'TermsOfService', + APP = 'app', + CUSTOM_EMOJI = 'CustomEmoji', + GLOBAL = 'global', + SERVERS = 'servers', + ROLE = 'Role', + SYSTEM = 'System', + TERMS_OF_SERVICE = 'TermsOfService', } // The only two types of databases in the app export enum DatabaseType { - DEFAULT, - SERVER, + DEFAULT, + SERVER, } diff --git a/types/database/index.d.ts b/types/database/index.d.ts index e65a1a99f3..838aa367db 100644 --- a/types/database/index.d.ts +++ b/types/database/index.d.ts @@ -2,11 +2,11 @@ // See LICENSE.txt for license information. interface NotifyProps { - channel: true; + channel: boolean; desktop: string; - desktop_sound: true; - email: true; - first_name: true; + desktop_sound: boolean; + email: boolean; + first_name: boolean; mention_keys: string; push: string; } @@ -18,7 +18,7 @@ interface UserProps { interface Timezone { automaticTimezone: string; manualTimezone: string; - useAutomaticTimezone: true; + useAutomaticTimezone: boolean; } interface FileInfo { diff --git a/types/database/user.d.ts b/types/database/user.d.ts index f00facc99f..2affeafe7f 100644 --- a/types/database/user.d.ts +++ b/types/database/user.d.ts @@ -6,7 +6,6 @@ import Model, {Associations} from '@nozbe/watermelondb/Model'; import Channel from '@typings/database/channel'; import ChannelMembership from '@typings/database/channel_membership'; import GroupMembership from '@typings/database/group_membership'; -import {NotifyProps, Timezone, UserProps} from '@typings/database/index'; import Post from '@typings/database/post'; import Preference from '@typings/database/preference'; import Reaction from '@typings/database/reaction'; @@ -26,6 +25,9 @@ export default class User extends Model { /** auth_service : The type of authentication service registered to that user */ authService: string; + /** update_at : The timestamp at which this user account has been updated */ + updateAt!: number; + /** delete_at : The timestamp at which this user account has been archived/deleted */ deleteAt: number;