From 040bf222645a40e7320e378d1f581ffcb422fc3b Mon Sep 17 00:00:00 2001 From: Avinash Lingaloo Date: Fri, 26 Mar 2021 19:23:32 +0400 Subject: [PATCH] MM_33223 [v2] Database Operator - Post section (#5227) * 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 * Update app/database/admin/data_operator/index.ts * MM_33223 : Removed OptType as the operator can do without it. * MM_33223 : Code correction after review * MM_33223 : Refactored Data Operator following reviews * MM_33223 : Including a wrapper to DataOperator * MM_33223 : Completing tests for wrapper Co-authored-by: Elias Nahum Co-authored-by: Avinash Lingaloo <> Co-authored-by: Joseph Baylon Co-authored-by: Miguel Alatzar --- .eslintrc.json | 16 +- .../admin/data_operator/handlers/index.ts | 752 ++++++++++++++++++ .../admin/data_operator/handlers/test.ts | 641 +++++++++++++++ app/database/admin/data_operator/index.ts | 180 +---- app/database/admin/data_operator/operators.ts | 210 ----- .../admin/data_operator/operators/index.ts | 507 ++++++++++++ .../admin/data_operator/operators/test.ts | 544 +++++++++++++ app/database/admin/data_operator/test.ts | 481 ----------- .../admin/data_operator/utils/index.ts | 123 +++ .../admin/data_operator/utils/mock.ts | 210 +++++ .../admin/data_operator/utils/test.ts | 116 +++ .../admin/data_operator/wrapper/index.ts | 35 + .../admin/data_operator/wrapper/test.ts | 353 ++++++++ .../admin/database_manager/__mocks__/index.ts | 143 ++-- app/database/admin/database_manager/index.ts | 148 ++-- app/database/admin/database_manager/test.ts | 14 +- .../admin/database_manager/test_manual.ts | 5 +- .../database_connection_exception.ts | 15 + .../exceptions/database_operator_exception.ts | 15 + app/database/server/models/post.ts | 11 +- app/database/server/models/post_metadata.ts | 7 +- .../server/schema/table_schemas/post.ts | 1 + app/database/server/schema/test.ts | 2 + app/i18n/index.ts | 126 +-- app/init/global_event_handler.ts | 54 +- app/init/push_notifications.ts | 30 +- app/mattermost.ts | 14 +- app/notifications/index.ios.ts | 4 +- app/screens/index.ts | 2 +- babel.config.js | 1 + package-lock.json | 54 +- tsconfig.json | 1 + types/database/database.d.ts | 367 +++++++-- types/database/enums.ts | 24 + types/database/index.d.ts | 80 +- types/database/post.d.ts | 5 +- types/database/post_metadata.d.ts | 3 +- 37 files changed, 4009 insertions(+), 1285 deletions(-) create mode 100644 app/database/admin/data_operator/handlers/index.ts create mode 100644 app/database/admin/data_operator/handlers/test.ts delete mode 100644 app/database/admin/data_operator/operators.ts create mode 100644 app/database/admin/data_operator/operators/index.ts create mode 100644 app/database/admin/data_operator/operators/test.ts delete mode 100644 app/database/admin/data_operator/test.ts create mode 100644 app/database/admin/data_operator/utils/index.ts create mode 100644 app/database/admin/data_operator/utils/mock.ts create mode 100644 app/database/admin/data_operator/utils/test.ts create mode 100644 app/database/admin/data_operator/wrapper/index.ts create mode 100644 app/database/admin/data_operator/wrapper/test.ts create mode 100644 app/database/admin/exceptions/database_connection_exception.ts create mode 100644 app/database/admin/exceptions/database_operator_exception.ts create mode 100644 types/database/enums.ts diff --git a/.eslintrc.json b/.eslintrc.json index ed0e6b842f..ff2721c109 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,13 +1,13 @@ { "extends": [ - "plugin:mattermost/react", "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended" + "plugin:@typescript-eslint/recommended", + "plugin:mattermost/react" ], "parser": "@typescript-eslint/parser", "plugins": [ - "mattermost", - "@typescript-eslint" + "@typescript-eslint", + "mattermost" ], "settings": { "react": { @@ -46,7 +46,13 @@ "@typescript-eslint/no-var-requires": 0, "@typescript-eslint/explicit-function-return-type": 0, "@typescript-eslint/explicit-module-boundary-types": "off", - "no-underscore-dangle": "off" + "no-underscore-dangle": "off", + "indent": [2, 4, {"SwitchCase": 1}], + "key-spacing": [2, { + "singleLine": { + "beforeColon": false, + "afterColon": true + }}] }, "overrides": [ { diff --git a/app/database/admin/data_operator/handlers/index.ts b/app/database/admin/data_operator/handlers/index.ts new file mode 100644 index 0000000000..ac94cf2ca5 --- /dev/null +++ b/app/database/admin/data_operator/handlers/index.ts @@ -0,0 +1,752 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +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, + PostImage, + PrepareRecords, + RawCustomEmoji, + RawDraft, + RawEmbed, + RawFile, + RawPost, + RawPostMetadata, + RawPostsInThread, + RawReaction, +} from '@typings/database/database'; +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 DatabaseConnectionException from '../../exceptions/database_connection_exception'; +import DatabaseOperatorException from '../../exceptions/database_operator_exception'; +import { + operateAppRecord, + operateCustomEmojiRecord, + operateDraftRecord, + operateFileRecord, + operateGlobalRecord, + operatePostInThreadRecord, + operatePostMetadataRecord, + operatePostRecord, + operatePostsInChannelRecord, + operateReactionRecord, + operateRoleRecord, + operateServersRecord, + operateSystemRecord, + operateTermsOfServiceRecord, +} from '../operators'; +import {createPostsChain, sanitizePosts, sanitizeReactions} from '../utils'; + +const { + CUSTOM_EMOJI, + DRAFT, + FILE, + POST, + POST_METADATA, + POSTS_IN_THREAD, + POSTS_IN_CHANNEL, + REACTION, +} = 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 + + 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; + }; +} + +export default DataOperator; diff --git a/app/database/admin/data_operator/handlers/test.ts b/app/database/admin/data_operator/handlers/test.ts new file mode 100644 index 0000000000..9cc5fd6e05 --- /dev/null +++ b/app/database/admin/data_operator/handlers/test.ts @@ -0,0 +1,641 @@ +// 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 {DatabaseType, IsolatedEntities} from '@typings/database/enums'; + +import { + operateAppRecord, + operateCustomEmojiRecord, + operateDraftRecord, + operateGlobalRecord, + operateRoleRecord, + operateServersRecord, + operateSystemRecord, + operateTermsOfServiceRecord, +} from '../operators'; + +jest.mock('@database/admin/database_manager'); + +const {DRAFT} = MM_TABLES.SERVER; + +describe('*** DataOperator: Handlers tests ***', () => { + const createConnection = async (setActive = false) => { + const dbName = 'server_schema_connection'; + const serverUrl = 'https://appv2.mattermost.com'; + const database = await DatabaseManager.createDatabaseConnection({ + shouldAddToDefaultDatabase: true, + configs: { + actionsEnabled: true, + dbName, + dbType: DatabaseType.SERVER, + serverUrl, + }, + }); + + if (setActive) { + await DatabaseManager.setActiveServerDatabase({ + displayName: dbName, + serverUrl, + }); + } + + return database; + }; + + it('=> HandleApp: should write to APP entity', async () => { + expect.assertions(2); + + const defaultDB = await DatabaseManager.getDefaultDatabase(); + expect(defaultDB).toBeTruthy(); + + const spyOnHandleBase = jest.spyOn(DataOperator as any, 'executeInDatabase'); + + 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', + }, + ], + }; + + await DataOperator.handleIsolatedEntity(data); + + expect(spyOnHandleBase).toHaveBeenCalledWith({ + ...data, + recordOperator: operateAppRecord, + }); + }); + + it('=> HandleGlobal: should write to GLOBAL entity', async () => { + expect.assertions(2); + + const defaultDB = await DatabaseManager.getDefaultDatabase(); + expect(defaultDB).toBeTruthy(); + + const spyOnHandleBase = jest.spyOn(DataOperator as any, 'executeInDatabase'); + + const data = { + tableName: IsolatedEntities.GLOBAL, + values: [ + {id: 'global-1-id', name: 'global-1-name', value: 'global-1-value'}, + ], + }; + + await DataOperator.handleIsolatedEntity(data); + + expect(spyOnHandleBase).toHaveBeenCalledWith({ + ...data, + recordOperator: operateGlobalRecord, + }); + }); + + it('=> HandleServers: should write to SERVERS entity', async () => { + expect.assertions(2); + + const defaultDB = await DatabaseManager.getDefaultDatabase(); + expect(defaultDB).toBeTruthy(); + + const spyOnHandleBase = jest.spyOn(DataOperator as any, 'executeInDatabase'); + + 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, + }); + }); + + it('=> HandleRole: should write to ROLE 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.ROLE, + values: [ + { + id: 'custom-emoji-id-1', + name: 'custom-emoji-1', + permissions: ['custom-emoji-1'], + }, + ], + }; + + await DataOperator.handleIsolatedEntity(data); + + expect(spyOnHandleBase).toHaveBeenCalledWith({ + ...data, + recordOperator: operateRoleRecord, + }); + }); + + it('=> HandleSystem: should write to SYSTEM 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.SYSTEM, + values: [{id: 'system-id-1', name: 'system-1', value: 'system-1'}], + }; + + await DataOperator.handleIsolatedEntity(data); + + expect(spyOnHandleBase).toHaveBeenCalledWith({ + ...data, + recordOperator: operateSystemRecord, + }); + }); + + it('=> HandleTermsOfService: should write to TERMS_OF_SERVICE 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.TERMS_OF_SERVICE, + values: [{id: 'tos-1', acceptedAt: 1}], + }; + + await DataOperator.handleIsolatedEntity(data); + + expect(spyOnHandleBase).toHaveBeenCalledWith({ + ...data, + recordOperator: operateTermsOfServiceRecord, + }); + }); + + it('=> No table name: should not call executeInDatabase if tableName is invalid', async () => { + expect.assertions(2); + + 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); + }); + + it('=> HandleReactions: should write to both Reactions and CustomEmoji entities', async () => { + expect.assertions(3); + + const database = await createConnection(true); + expect(database).toBeTruthy(); + + const spyOnPrepareBase = jest.spyOn(DataOperator as any, 'prepareRecords'); + const spyOnBatchOperation = jest.spyOn(DataOperator as any, 'batchOperations'); + + await DataOperator.handleReactions({ + reactions: [ + { + create_at: 1608263728086, + delete_at: 0, + emoji_name: 'p4p1', + post_id: '4r9jmr7eqt8dxq3f9woypzurry', + update_at: 1608263728077, + user_id: 'ooumoqgq3bfiijzwbn8badznwc', + }, + ], + prepareRowsOnly: false, + }); + + // Called twice: Once for Reaction record and once for CustomEmoji record + expect(spyOnPrepareBase).toHaveBeenCalledTimes(2); + + // Only one batch operation for both entities + expect(spyOnBatchOperation).toHaveBeenCalledTimes(1); + }); + + it('=> HandleDraft: should write to the Draft entity', async () => { + expect.assertions(2); + + const database = await createConnection(true); + expect(database).toBeTruthy(); + + const spyOnHandleBase = jest.spyOn(DataOperator as any, 'executeInDatabase'); + + const data = [ + { + channel_id: '4r9jmr7eqt8dxq3f9woypzurrychannelid', + files: [ + { + user_id: 'user_id', + post_id: 'post_id', + create_at: 123, + update_at: 456, + delete_at: 789, + name: 'an_image', + extension: 'jpg', + size: 10, + mime_type: 'image', + width: 10, + height: 10, + has_preview_image: false, + clientId: 'clientId', + }, + ], + message: 'test draft message for post', + root_id: '', + }, + ]; + await DataOperator.handleDraft(data); + + // Only one batch operation for both entities + expect(spyOnHandleBase).toHaveBeenCalledWith({ + tableName: DRAFT, + values: data, + recordOperator: operateDraftRecord, + }); + }); + + it('=> HandleFiles: should write to File entity', async () => { + expect.assertions(3); + + const database = await createConnection(true); + expect(database).toBeTruthy(); + + const spyOnPrepareBase = jest.spyOn(DataOperator as any, 'prepareRecords'); + const spyOnBatchOperation = jest.spyOn(DataOperator as any, 'batchOperations'); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await DataOperator.handleFiles({ + files: [ + { + user_id: 'user_id', + post_id: 'post_id', + create_at: 12345, + update_at: 456, + delete_at: 789, + name: 'an_image', + extension: 'jpg', + size: 10, + mime_type: 'image', + width: 10, + height: 10, + has_preview_image: false, + }, + ], + prepareRowsOnly: false, + }); + + expect(spyOnPrepareBase).toHaveBeenCalledTimes(1); + expect(spyOnBatchOperation).toHaveBeenCalledTimes(1); + }); + + it('=> HandlePosts: should write to Post and its sub-child entities', async () => { + expect.assertions(12); + + const posts = [ + { + id: '8swgtrrdiff89jnsiwiip3y1eoe', + create_at: 1596032651747, + update_at: 1596032651747, + edit_at: 0, + delete_at: 0, + is_pinned: false, + user_id: 'q3mzxua9zjfczqakxdkowc6u6yy', + channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', + root_id: '', + parent_id: 'ps81iqbddesfby8jayz7owg4yypoo', + original_id: '', + message: "I'll second these kudos! Thanks m!", + type: '', + props: {}, + hashtags: '', + pending_post_id: '', + reply_count: 4, + last_reply_at: 0, + participants: null, + metadata: { + images: { + 'https://community-release.mattermost.com/api/v4/image?url=https%3A%2F%2Favatars1.githubusercontent.com%2Fu%2F6913320%3Fs%3D400%26v%3D4': { + width: 400, + height: 400, + format: 'png', + frame_count: 0, + }, + }, + reactions: [ + { + user_id: 'njic1w1k5inefp848jwk6oukio', + post_id: 'a7ebyw883trm884p1qcgt8yw4a', + emoji_name: 'clap', + create_at: 1608252965442, + update_at: 1608252965442, + delete_at: 0, + }, + ], + embeds: [ + { + type: 'opengraph', + url: + 'https://github.com/mickmister/mattermost-plugin-default-theme', + data: { + type: 'object', + url: + 'https://github.com/mickmister/mattermost-plugin-default-theme', + title: 'mickmister/mattermost-plugin-default-theme', + description: + 'Contribute to mickmister/mattermost-plugin-default-theme development by creating an account on GitHub.', + determiner: '', + site_name: 'GitHub', + locale: '', + locales_alternate: null, + images: [ + { + url: '', + secure_url: + 'https://community-release.mattermost.com/api/v4/image?url=https%3A%2F%2Favatars1.githubusercontent.com%2Fu%2F6913320%3Fs%3D400%26v%3D4', + type: '', + width: 0, + height: 0, + }, + ], + audios: null, + videos: null, + }, + }, + ], + emojis: [ + { + id: 'dgwyadacdbbwjc8t357h6hwsrh', + create_at: 1502389307432, + update_at: 1502389307432, + delete_at: 0, + creator_id: 'x6sdh1ok1tyd9f4dgq4ybw839a', + name: 'thanks', + }, + ], + files: [ + { + id: 'f1oxe5rtepfs7n3zifb4sso7po', + user_id: '89ertha8xpfsumpucqppy5knao', + post_id: 'a7ebyw883trm884p1qcgt8yw4a', + create_at: 1608270920357, + update_at: 1608270920357, + delete_at: 0, + name: '4qtwrg.jpg', + extension: 'jpg', + size: 89208, + mime_type: 'image/jpeg', + width: 500, + height: 656, + has_preview_image: true, + mini_preview: + '/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABAAEAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AN/T/iZp+pX15FpUmnwLbXtpJpyy2sQLw8CcBXA+bksCDnHGOaf4W+P3xIshbQ6loB8RrbK11f3FpbBFW3ZwiFGHB2kr25BIOeCPPbX4S3407T7rTdDfxFNIpDyRaw9lsB4OECHGR15yO4GK6fRPhR4sGmSnxAs8NgchNOjvDPsjz8qSHA37cDk5JPPFdlOpTdPlcVt/Ku1lrvr17b67EPnjrH8/626H/9k=', + }, + ], + }, + }, + { + id: '8fcnk3p1jt8mmkaprgajoxz115a', + create_at: 1596104683748, + update_at: 1596104683748, + edit_at: 0, + delete_at: 0, + is_pinned: false, + user_id: 'hy5sq51sebfh58ktrce5ijtcwyy', + channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', + root_id: '8swgtrrdiff89jnsiwiip3y1eoe', + parent_id: '', + original_id: '', + message: 'a added to the channel by j.', + type: 'system_add_to_channel', + props: { + addedUserId: 'z89qsntet7bimd3xddfu7u9ncdaxc', + addedUsername: 'a', + userId: 'hy5sdfdfq51sebfh58ktrce5ijtcwy', + username: 'j', + }, + hashtags: '', + pending_post_id: '', + reply_count: 0, + last_reply_at: 0, + participants: null, + metadata: {}, + }, + { + id: '3y3w3a6gkbg73bnj3xund9o5ic', + create_at: 1596277483749, + update_at: 1596277483749, + edit_at: 0, + delete_at: 0, + is_pinned: false, + user_id: '44ud4m9tqwby3mphzzdwm7h31sr', + channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', + root_id: '8swgtrrdiff89jnsiwiip3y1eoe', + parent_id: 'ps81iqbwesfby8jayz7owg4yypo', + original_id: '', + message: 'Great work M!', + type: '', + props: {}, + hashtags: '', + pending_post_id: '', + reply_count: 4, + last_reply_at: 0, + participants: null, + metadata: {}, + }, + ]; + + 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 spyOnHandlePostsInThread = jest.spyOn(DataOperator as any, 'handlePostsInThread'); + const spyOnHandlePostsInChannel = jest.spyOn(DataOperator as any, 'handlePostsInChannel'); + + await createConnection(true); + + // handlePosts will in turn call handlePostsInThread + await DataOperator.handlePosts({ + orders: [ + '8swgtrrdiff89jnsiwiip3y1eoe', + '8fcnk3p1jt8mmkaprgajoxz115a', + '3y3w3a6gkbg73bnj3xund9o5ic', + ], + values: posts, + previousPostId: '', + }); + + expect(spyOnHandleReactions).toHaveBeenCalledTimes(1); + expect(spyOnHandleReactions).toHaveBeenCalledWith({ + reactions: [ + { + user_id: 'njic1w1k5inefp848jwk6oukio', + post_id: 'a7ebyw883trm884p1qcgt8yw4a', + emoji_name: 'clap', + create_at: 1608252965442, + update_at: 1608252965442, + delete_at: 0, + }, + ], + prepareRowsOnly: true, + }); + + expect(spyOnHandleFiles).toHaveBeenCalledTimes(1); + expect(spyOnHandleFiles).toHaveBeenCalledWith({ + files: [ + { + id: 'f1oxe5rtepfs7n3zifb4sso7po', + user_id: '89ertha8xpfsumpucqppy5knao', + post_id: 'a7ebyw883trm884p1qcgt8yw4a', + create_at: 1608270920357, + update_at: 1608270920357, + delete_at: 0, + name: '4qtwrg.jpg', + extension: 'jpg', + size: 89208, + mime_type: 'image/jpeg', + width: 500, + height: 656, + has_preview_image: true, + mini_preview: + '/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABAAEAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AN/T/iZp+pX15FpUmnwLbXtpJpyy2sQLw8CcBXA+bksCDnHGOaf4W+P3xIshbQ6loB8RrbK11f3FpbBFW3ZwiFGHB2kr25BIOeCPPbX4S3407T7rTdDfxFNIpDyRaw9lsB4OECHGR15yO4GK6fRPhR4sGmSnxAs8NgchNOjvDPsjz8qSHA37cDk5JPPFdlOpTdPlcVt/Ku1lrvr17b67EPnjrH8/626H/9k=', + }, + ], + prepareRowsOnly: true, + }); + + expect(spyOnHandlePostMetadata).toHaveBeenCalledTimes(1); + expect(spyOnHandlePostMetadata).toHaveBeenCalledWith({ + embeds: [ + { + embed: [ + { + type: 'opengraph', + url: 'https://github.com/mickmister/mattermost-plugin-default-theme', + data: { + type: 'object', + url: 'https://github.com/mickmister/mattermost-plugin-default-theme', + title: 'mickmister/mattermost-plugin-default-theme', + description: 'Contribute to mickmister/mattermost-plugin-default-theme development by creating an account on GitHub.', + determiner: '', + site_name: 'GitHub', + locale: '', + locales_alternate: null, + images: [ + { + url: '', + secure_url: 'https://community-release.mattermost.com/api/v4/image?url=https%3A%2F%2Favatars1.githubusercontent.com%2Fu%2F6913320%3Fs%3D400%26v%3D4', + type: '', + width: 0, + height: 0, + }, + ], + audios: null, + videos: null, + }, + }, + ], + postId: '8swgtrrdiff89jnsiwiip3y1eoe', + }, + ], + images: [ + { + images: { + 'https://community-release.mattermost.com/api/v4/image?url=https%3A%2F%2Favatars1.githubusercontent.com%2Fu%2F6913320%3Fs%3D400%26v%3D4': { + width: 400, + height: 400, + format: 'png', + frame_count: 0, + }, + }, + postId: '8swgtrrdiff89jnsiwiip3y1eoe', + }, + ], + prepareRowsOnly: true, + }); + + expect(spyOnHandleIsolatedEntity).toHaveBeenCalledTimes(1); + expect(spyOnHandleIsolatedEntity).toHaveBeenCalledWith({ + tableName: 'CustomEmoji', + values: [ + { + id: 'dgwyadacdbbwjc8t357h6hwsrh', + create_at: 1502389307432, + update_at: 1502389307432, + delete_at: 0, + creator_id: 'x6sdh1ok1tyd9f4dgq4ybw839a', + name: 'thanks', + }, + ], + }); + + expect(spyOnHandlePostsInThread).toHaveBeenCalledTimes(1); + 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/data_operator/index.ts b/app/database/admin/data_operator/index.ts index 1e3e7243c7..d64ccce41b 100644 --- a/app/database/admin/data_operator/index.ts +++ b/app/database/admin/data_operator/index.ts @@ -1,183 +1,5 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. - -import {MM_TABLES} from '@constants/database'; -import { - BatchOperations, - DBInstance, - HandleBaseData, - HandleIsolatedEntityData, - RecordValue, -} from '@typings/database/database'; - -import DatabaseManager from '../database_manager'; - -import { - operateAppRecord, - operateCustomEmojiRecord, - operateGlobalRecord, - operateRoleRecord, - operateServersRecord, - operateSystemRecord, - operateTermsOfServiceRecord, -} from './operators'; - -export enum OperationType { - 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' -} - -class DataOperator { - private defaultDatabase: DBInstance; - private serverDatabase: DBInstance; - - /** - * handleIsolatedEntityData: Operator that handles Create/Update operations on the isolated entities as - * described by the IsolatedTables type - * @param {HandleIsolatedEntityData} entityData - * @param {OperationType} entityData.optType - * @param {IsolatedEntities} entityData.tableName - * @param {Records} entityData.values - * @returns {Promise} - */ - handleIsolatedEntityData = async ({optType, tableName, values}: HandleIsolatedEntityData): Promise => { - let recordOperator; - - 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.handleBaseData({optType, values, tableName, recordOperator}); - } - }; - - /** - * batchOperations: Accepts an instance of Database (either Default or Server) and an array of - * prepareCreate/prepareUpdate values and executes the actions on the database. - * @param {BatchOperations} operation - * @param {Database} operation.db - * @param {Array} operation.models - * @returns {Promise} - */ - private batchOperations = async ({db, models}: BatchOperations) => { - if (models.length > 0) { - await db.action(async () => { - await db.batch(...models); - }); - } - }; - - /** - * handleBaseData: Handles the Create/Update operations on an entity. - * @param {HandleBaseData} opsBase - * @param {OperationType} opsBase.optType - * @param {string} opsBase.tableName - * @param {Records} opsBase.values - * @param {(recordOperator: DataFactory) => void} opsBase.recordOperator - * @returns {Promise} - */ - private handleBaseData = async ({optType, tableName, values, recordOperator}: HandleBaseData) => { - const db = await this.getDatabase(tableName); - if (!db) { - return; - } - - let results; - const config = {db, optType, tableName}; - - if (Array.isArray(values) && values.length) { - const recordPromises = await values.map(async (value) => { - const record = await recordOperator({...config, value}); - return record; - }); - - results = await Promise.all(recordPromises); - } else { - results = await recordOperator({...config, value: values as RecordValue}); - } - - if (results) { - await this.batchOperations({db, models: Array.isArray(results) ? results : Array(results)}); - } - }; - - /** - * 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): Promise => { - const isInDefaultDB = Object.values(MM_TABLES.DEFAULT).some((tbName) => { - return tableName === tbName; - }); - - if (isInDefaultDB) { - return this.defaultDatabase || this.getDefaultDatabase(); - } - - return this.serverDatabase || this.getServerDatabase(); - }; - - /** - * getDefaultDatabase: Returns the default database - * @returns {Promise} - */ - private getDefaultDatabase = async () => { - this.defaultDatabase = await DatabaseManager.getDefaultDatabase(); - return this.defaultDatabase; - }; - - /** - * getServerDatabase: Returns the current active server database (multi-server support) - * @returns {Promise} - */ - private getServerDatabase = async () => { - this.serverDatabase = await DatabaseManager.getActiveServerDatabase(); - return this.serverDatabase; - }; -} +import DataOperator from '@database/admin/data_operator/handlers'; export default new DataOperator(); diff --git a/app/database/admin/data_operator/operators.ts b/app/database/admin/data_operator/operators.ts deleted file mode 100644 index 67b55e6e2a..0000000000 --- a/app/database/admin/data_operator/operators.ts +++ /dev/null @@ -1,210 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {MM_TABLES} from '@constants/database'; -import {Q} from '@nozbe/watermelondb'; -import Model from '@nozbe/watermelondb/Model'; - -import App from '@typings/database/app'; -import CustomEmoji from '@typings/database/custom_emoji'; -import { - DataFactory, - RawApp, - RawCustomEmoji, - RawGlobal, - RawRole, - RawServers, - RawSystem, - RawTermsOfService, -} from '@typings/database/database'; -import Global from '@typings/database/global'; -import Role from '@typings/database/role'; -import Servers from '@typings/database/servers'; -import System from '@typings/database/system'; -import TermsOfService from '@typings/database/terms_of_service'; - -import {OperationType} from './index'; - -const {APP, GLOBAL, SERVERS} = MM_TABLES.DEFAULT; -const {CUSTOM_EMOJI, ROLE, SYSTEM, TERMS_OF_SERVICE} = MM_TABLES.SERVER; - -/** - * operateAppRecord: Prepares record of entity 'App' from the DEFAULT database for update or create actions. - * @param {DataFactory} operator - * @param {Database} operator.db - * @param {OperationType} operator.optType - * @param {RecordValue} operator.value - * @returns {Promise} - */ -export const operateAppRecord = async ({db, optType, value}: DataFactory) => { - const record = value as RawApp; - - const generator = (app: App) => { - app._raw.id = record?.id ?? app.id; - app.buildNumber = record?.buildNumber ?? ''; - app.createdAt = record?.createdAt ?? 0; - app.versionNumber = record?.versionNumber ?? ''; - }; - - return operateBaseRecord({db, optType, tableName: APP, value, generator}); -}; - -/** - * operateGlobalRecord: Prepares record of entity 'Global' from the DEFAULT database for update or create actions. - * @param {DataFactory} operator - * @param {Database} operator.db - * @param {OperationType} operator.optType - * @param {RecordValue} operator.value - * @returns {Promise} - */ -export const operateGlobalRecord = async ({db, optType, value}: DataFactory) => { - const record = value as RawGlobal; - - const generator = (global: Global) => { - global._raw.id = record?.id ?? global.id; - global.name = record?.name ?? ''; - global.value = record?.value ?? 0; - }; - - return operateBaseRecord({db, optType, tableName: GLOBAL, value, generator}); -}; - -/** - * operateServersRecord: Prepares record of entity 'Servers' from the DEFAULT database for update or create actions. - * @param {DataFactory} operator - * @param {Database} operator.db - * @param {OperationType} operator.optType - * @param {RecordValue} operator.value - * @returns {Promise} - */ -export const operateServersRecord = async ({db, optType, value}: DataFactory) => { - const record = value as RawServers; - - const generator = (servers: Servers) => { - servers._raw.id = record?.id ?? servers.id; - servers.dbPath = record?.dbPath ?? ''; - servers.displayName = record?.displayName ?? 0; - servers.mentionCount = record?.mentionCount ?? 0; - servers.unreadCount = record?.unreadCount ?? 0; - servers.url = record?.url ?? 0; - }; - - return operateBaseRecord({db, optType, tableName: SERVERS, value, generator}); -}; - -/** - * operateCustomEmojiRecord: Prepares record of entity 'CustomEmoji' from the SERVER database for update or create actions. - * @param {DataFactory} operator - * @param {Database} operator.db - * @param {OperationType} operator.optType - * @param {RecordValue} operator.value - * @returns {Promise} - */ -export const operateCustomEmojiRecord = async ({db, optType, value}: DataFactory) => { - const record = value as RawCustomEmoji; - - const generator = (emoji: CustomEmoji) => { - emoji._raw.id = record?.id ?? emoji.id; - emoji.name = record?.name ?? ''; - }; - - return operateBaseRecord({db, optType, tableName: CUSTOM_EMOJI, value, generator}); -}; - -/** - * operateRoleRecord: Prepares record of entity 'Role' from the SERVER database for update or create actions. - * @param {DataFactory} operator - * @param {Database} operator.db - * @param {OperationType} operator.optType - * @param {RecordValue} operator.value - * @returns {Promise} - */ -export const operateRoleRecord = async ({db, optType, value}: DataFactory) => { - const record = value as RawRole; - - const generator = (role: Role) => { - role._raw.id = record?.id ?? role.id; - role.name = record?.name ?? ''; - role.permissions = record?.permissions ?? []; - }; - - return operateBaseRecord({db, optType, tableName: ROLE, value, generator}); -}; - -/** - * operateSystemRecord: Prepares record of entity 'System' from the SERVER database for update or create actions. - * @param {DataFactory} operator - * @param {Database} operator.db - * @param {OperationType} operator.optType - * @param {RecordValue} operator.value - * @returns {Promise} - */ -export const operateSystemRecord = async ({db, optType, value}: DataFactory) => { - const record = value as RawSystem; - - const generator = (system: System) => { - system._raw.id = record?.id ?? system.id; - system.name = record?.name ?? ''; - system.value = record?.value ?? ''; - }; - - return operateBaseRecord({db, optType, tableName: SYSTEM, value, generator}); -}; - -/** - * operateTermsOfServiceRecord: Prepares record of entity 'TermsOfService' from the SERVER database for update or create actions. - * @param {DataFactory} operator - * @param {Database} operator.db - * @param {OperationType} operator.optType - * @param {RecordValue} operator.value - * @returns {Promise} - */ -export const operateTermsOfServiceRecord = async ({db, optType, value}: DataFactory) => { - const record = value as RawTermsOfService; - - const generator = (tos: TermsOfService) => { - tos._raw.id = record?.id ?? tos.id; - tos.acceptedAt = record?.acceptedAt ?? 0; - }; - - return operateBaseRecord({db, optType, tableName: TERMS_OF_SERVICE, value, generator}); -}; - -/** - * 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. - * - * In our case, we check to see if we have an existing 'id' and if so, we'll update the record with the data. - * For an UPDATE operation, we fetch the existing record using the 'id' value and then we do the update operation; - * if no record is found for that 'id', we'll create it a new record. - * - * @param {DataFactory} operatorBase - * @param {Database} operatorBase.db - * @param {OperationType} operatorBase.optType - * @param {string} operatorBase.tableName - * @param {any} operatorBase.value - * @param {((model: Model) => void)} operatorBase.generator - * @returns {Promise} - */ -const operateBaseRecord = async ({db, optType, tableName, value, generator}: DataFactory) => { - // We query first to see if we have a record on that entity with the current value.id - const appRecord = await db.collections.get(tableName!).query(Q.where('id', value.id)).fetch() as Model[]; - const isPresent = appRecord.length > 0; - - if ((isPresent && optType === OperationType.CREATE) || (isPresent && optType === 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 record = appRecord[0]; - return record.prepareUpdate(() => generator!(record)); - } - - if ((!isPresent && optType === OperationType.UPDATE) || (optType === OperationType.CREATE)) { - // Two possible scenarios - // 1. We don't have a record yet to update; so we create it - // 2. This is just a normal create operation - return db.collections.get(tableName!).prepareCreate(generator); - } - - return null; -}; diff --git a/app/database/admin/data_operator/operators/index.ts b/app/database/admin/data_operator/operators/index.ts new file mode 100644 index 0000000000..89ea3b497f --- /dev/null +++ b/app/database/admin/data_operator/operators/index.ts @@ -0,0 +1,507 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q} from '@nozbe/watermelondb'; +import Model from '@nozbe/watermelondb/Model'; + +import {MM_TABLES} from '@constants/database'; +import App from '@typings/database/app'; +import CustomEmoji from '@typings/database/custom_emoji'; +import { + BaseOperator, + IdenticalRecord, + Operator, + RawApp, + RawCustomEmoji, + RawDraft, + RawFile, + RawGlobal, + RawPost, + RawPostMetadata, + RawPostsInChannel, + RawPostsInThread, + RawReaction, + RawRole, + RawServers, + RawSystem, + RawTermsOfService, +} from '@typings/database/database'; +import Draft from '@typings/database/draft'; +import File from '@typings/database/file'; +import Global from '@typings/database/global'; +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 Role from '@typings/database/role'; +import Servers from '@typings/database/servers'; +import System from '@typings/database/system'; +import TermsOfService from '@typings/database/terms_of_service'; + +const {APP, GLOBAL, SERVERS} = MM_TABLES.DEFAULT; +const { + CUSTOM_EMOJI, + DRAFT, + FILE, + POST, + POST_METADATA, + POSTS_IN_CHANNEL, + POSTS_IN_THREAD, + REACTION, + ROLE, + SYSTEM, + TERMS_OF_SERVICE, +} = MM_TABLES.SERVER; + +/** + * operateAppRecord: Prepares record of entity 'App' from the DEFAULT database for update or create actions. + * @param {Operator} operator + * @param {Database} operator.database + * @param {RecordValue} operator.value + * @returns {Promise} + */ +export const operateAppRecord = async ({database, value}: Operator) => { + const record = value as RawApp; + + const generator = (app: App) => { + app._raw.id = record?.id ?? app.id; + app.buildNumber = record?.buildNumber; + app.createdAt = record?.createdAt; + app.versionNumber = record?.versionNumber; + }; + + return operateBaseRecord({ + database, + tableName: APP, + value, + generator, + }); +}; + +/** + * operateGlobalRecord: Prepares record of entity 'Global' from the DEFAULT database for update or create actions. + * @param {Operator} operator + * @param {Database} operator.database + * @param {RecordValue} operator.value + * @returns {Promise} + */ +export const operateGlobalRecord = async ({database, value}: Operator) => { + const record = value as RawGlobal; + + const generator = (global: Global) => { + global._raw.id = record?.id ?? global.id; + global.name = record?.name; + global.value = record?.value; + }; + + return operateBaseRecord({ + database, + tableName: GLOBAL, + value, + generator, + }); +}; + +/** + * operateServersRecord: Prepares record of entity 'Servers' from the DEFAULT database for update or create actions. + * @param {Operator} operator + * @param {Database} operator.database + * @param {RecordValue} operator.value + * @returns {Promise} + */ +export const operateServersRecord = async ({database, value}: Operator) => { + const record = value as RawServers; + + 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; + }; + + return operateBaseRecord({ + database, + tableName: SERVERS, + value, + generator, + }); +}; + +/** + * operateCustomEmojiRecord: Prepares record of entity 'CustomEmoji' from the SERVER database for update or create actions. + * @param {Operator} operator + * @param {Database} operator.database + * @param {RecordValue} operator.value + * @returns {Promise} + */ +export const operateCustomEmojiRecord = async ({database, value}: Operator) => { + const record = value as RawCustomEmoji; + const generator = (emoji: CustomEmoji) => { + emoji._raw.id = record?.id ?? emoji.id; + emoji.name = record.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({ + database, + tableName: CUSTOM_EMOJI, + value, + generator, + }); +}; + +/** + * operateRoleRecord: Prepares record of entity 'Role' from the SERVER database for update or create actions. + * @param {Operator} operator + * @param {Database} operator.database + * @param {RecordValue} operator.value + * @returns {Promise} + */ +export const operateRoleRecord = async ({database, value}: Operator) => { + const record = value as RawRole; + + const generator = (role: Role) => { + role._raw.id = record?.id ?? role.id; + role.name = record?.name; + role.permissions = record?.permissions; + }; + + return operateBaseRecord({ + database, + tableName: ROLE, + value, + generator, + }); +}; + +/** + * operateSystemRecord: Prepares record of entity 'System' from the SERVER database for update or create actions. + * @param {Operator} operator + * @param {Database} operator.database + * @param {RecordValue} operator.value + * @returns {Promise} + */ +export const operateSystemRecord = async ({database, value}: Operator) => { + const record = value as RawSystem; + + const generator = (system: System) => { + system._raw.id = record?.id ?? system.id; + system.name = record?.name; + system.value = record?.value; + }; + + return operateBaseRecord({ + database, + tableName: SYSTEM, + value, + generator, + }); +}; + +/** + * operateTermsOfServiceRecord: Prepares record of entity 'TermsOfService' from the SERVER database for update or create actions. + * @param {Operator} operator + * @param {Database} operator.database + * @param {RecordValue} operator.value + * @returns {Promise} + */ +export const operateTermsOfServiceRecord = async ({database, value}: Operator) => { + const record = value as RawTermsOfService; + + const generator = (tos: TermsOfService) => { + tos._raw.id = record?.id ?? tos.id; + tos.acceptedAt = record?.acceptedAt; + }; + + return operateBaseRecord({ + database, + tableName: TERMS_OF_SERVICE, + value, + generator, + }); +}; + +/** + * operatePostRecord: Prepares record of entity 'Post' from the SERVER database for update or create actions. + * @param {Operator} operator + * @param {Database} operator.database + * @param {RecordValue} operator.value + * @returns {Promise} + */ +export const operatePostRecord = async ({database, value}: Operator) => { + const record = value as RawPost; + + 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 ?? {}; + }; + + return operateBaseRecord({ + database, + tableName: POST, + value, + generator, + }); +}; + +/** + * operatePostInThreadRecord: Prepares record of entity 'POSTS_IN_THREAD' from the SERVER database for update or create actions. + * @param {Operator} operator + * @param {Database} operator.database + * @param {RecordValue} operator.value + * @returns {Promise} + */ +export const operatePostInThreadRecord = async ({database, value}: Operator) => { + const record = value as RawPostsInThread; + + const generator = (postsInThread: PostsInThread) => { + postsInThread._raw.id = postsInThread.id; + postsInThread.postId = record.post_id; + postsInThread.earliest = record.earliest; + postsInThread.latest = record.latest!; + }; + + return operateBaseRecord({ + database, + tableName: POSTS_IN_THREAD, + value, + generator, + }); +}; + +/** + * operateReactionRecord: Prepares record of entity 'REACTION' from the SERVER database for update or create actions. + * @param {Operator} operator + * @param {Database} operator.database + * @param {RecordValue} operator.value + * @returns {Promise} + */ +export const operateReactionRecord = async ({database, value}: Operator) => { + const record = value as RawReaction; + + 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; + }; + + return operateBaseRecord({ + database, + tableName: REACTION, + value, + generator, + }); +}; + +/** + * operateFileRecord: Prepares record of entity 'FILE' from the SERVER database for update or create actions. + * @param {Operator} operator + * @param {Database} operator.database + * @param {RecordValue} operator.value + * @returns {Promise} + */ +export const operateFileRecord = async ({database, value}: Operator) => { + const record = value as RawFile; + + 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 ?? ''; + }; + + return operateBaseRecord({ + database, + tableName: FILE, + value, + generator, + }); +}; + +/** + * operatePostMetadataRecord: Prepares record of entity 'POST_METADATA' from the SERVER database for update or create actions. + * @param {Operator} operator + * @param {Database} operator.database + * @param {RecordValue} operator.value + * @returns {Promise} + */ +export const operatePostMetadataRecord = async ({database, value}: Operator) => { + const record = value as RawPostMetadata; + + const generator = (postMeta: PostMetadata) => { + postMeta._raw.id = postMeta.id; + postMeta.data = record.data; + postMeta.postId = record.postId; + postMeta.type = record.type; + }; + + return operateBaseRecord({ + database, + tableName: POST_METADATA, + value, + generator, + }); +}; + +/** + * operateDraftRecord: Prepares record of entity 'DRAFT' from the SERVER database for update or create actions. + * @param {Operator} operator + * @param {Database} operator.database + * @param {RecordValue} operator.value + * @returns {Promise} + */ +export const operateDraftRecord = async ({database, value}: Operator) => { + const record = value as RawDraft; + const emptyFileInfo: FileInfo[] = []; + + 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; + }; + + return operateBaseRecord({ + database, + tableName: DRAFT, + value, + generator, + }); +}; + +/** + * operatePostsInChannelRecord: Prepares record of entity 'POSTS_IN_CHANNEL' from the SERVER database for update or create actions. + * @param {Operator} operator + * @param {Database} operator.database + * @param {RecordValue} operator.value + * @returns {Promise} + */ +export const operatePostsInChannelRecord = async ({database, value}: Operator) => { + const record = value 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; + }; + + return operateBaseRecord({ + database, + tableName: POSTS_IN_CHANNEL, + value, + generator, + }); +}; + +/** + * 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. + * + * 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 {Database} operatorBase.database + * @param {string} operatorBase.tableName + * @param {RecordValue} operatorBase.value + * @param {((model: Model) => void)} operatorBase.generator + * @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 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 + return record.prepareUpdate(() => generator!(record)); + } + + // Two possible scenarios + // 1. We don't have a record yet to update; so we create it + // 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 new file mode 100644 index 0000000000..5e6c94c588 --- /dev/null +++ b/app/database/admin/data_operator/operators/test.ts @@ -0,0 +1,544 @@ +// 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 { + operateAppRecord, + operateCustomEmojiRecord, + operateDraftRecord, + operateFileRecord, + operateGlobalRecord, + operatePostInThreadRecord, + operatePostMetadataRecord, + operatePostRecord, + operatePostsInChannelRecord, + operateReactionRecord, + operateRoleRecord, + operateServersRecord, + operateSystemRecord, + operateTermsOfServiceRecord, +} from './index'; + +jest.mock('@database/admin/database_manager'); + +const {APP} = MM_TABLES.DEFAULT; + +describe('*** DataOperator: Operators tests ***', () => { + const createConnection = async (setActive = false) => { + const dbName = 'server_schema_connection'; + const serverUrl = 'https://appv2.mattermost.com'; + const database = await DatabaseManager.createDatabaseConnection({ + shouldAddToDefaultDatabase: true, + configs: { + actionsEnabled: true, + dbName, + dbType: DatabaseType.SERVER, + serverUrl, + }, + }); + + if (setActive) { + await DatabaseManager.setActiveServerDatabase({ + displayName: dbName, + serverUrl, + }); + } + + return database; + }; + + it('=> operateAppRecord: should return an array of type App', async () => { + expect.assertions(3); + + const database = await DatabaseManager.getDefaultDatabase(); + expect(database).toBeTruthy(); + + const preparedRecords = await operateAppRecord({ + database: database!, + value: { + buildNumber: 'build-7', + createdAt: 1, + id: 'id-18', + versionNumber: 'v-1', + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toMatch('App'); + }); + + it('=> operateGlobalRecord: should return an array of type Global', async () => { + expect.assertions(3); + + const database = await DatabaseManager.getDefaultDatabase(); + expect(database).toBeTruthy(); + + const preparedRecords = await operateGlobalRecord({ + database: database!, + value: {id: 'g-1', name: 'g-n1', value: 'g-v1'}, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toMatch('Global'); + }); + + it('=> operateServersRecord: should return an array of type Servers', async () => { + expect.assertions(3); + + const database = await DatabaseManager.getDefaultDatabase(); + expect(database).toBeTruthy(); + + const preparedRecords = await operateServersRecord({ + database: database!, + value: { + dbPath: 'mm-server', + displayName: 's-displayName', + id: 's-1', + mentionCount: 1, + unreadCount: 0, + url: 'https://community.mattermost.com', + }, + }); + + expect(preparedRecords).toBeTruthy(); + 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); + + const database = await createConnection(); + expect(database).toBeTruthy(); + + const preparedRecords = await operateRoleRecord({ + database: database!, + value: {id: 'role-1', name: 'role-name-1', permissions: []}, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toMatch('Role'); + }); + + it('=> operateSystemRecord: should return an array of type System', async () => { + expect.assertions(3); + + const database = await createConnection(); + expect(database).toBeTruthy(); + + const preparedRecords = await operateSystemRecord({ + database: database!, + value: {id: 'system-1', name: 'system-name-1', value: 'system'}, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toMatch('System'); + }); + + it('=> operateTermsOfServiceRecord: should return an array of type TermsOfService', async () => { + expect.assertions(3); + + const database = await createConnection(); + expect(database).toBeTruthy(); + + const preparedRecords = await operateTermsOfServiceRecord({ + database: database!, + value: {id: 'system-1', acceptedAt: 1}, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toMatch( + 'TermsOfService', + ); + }); + + 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); + + const database = await createConnection(); + expect(database).toBeTruthy(); + + const preparedRecords = await operatePostRecord({ + 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: {}, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toMatch('Post'); + }); + + it('=> operatePostInThreadRecord: should return an array of type PostsInThread', async () => { + expect.assertions(3); + + const database = await createConnection(); + expect(database).toBeTruthy(); + + const preparedRecords = await operatePostInThreadRecord({ + database: database!, + value: { + id: 'ps81iqbddesfby8jayz7owg4yypoo', + post_id: '8swgtrrdiff89jnsiwiip3y1eoe', + earliest: 1596032651748, + latest: 1597032651748, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toMatch('PostsInThread'); + }); + + it('=> operateReactionRecord: should return an array of type Reaction', async () => { + expect.assertions(3); + + const database = await createConnection(); + expect(database).toBeTruthy(); + + const preparedRecords = await operateReactionRecord({ + database: database!, + value: { + id: 'ps81iqbddesfby8jayz7owg4yypoo', + user_id: 'q3mzxua9zjfczqakxdkowc6u6yy', + post_id: 'ps81iqbddesfby8jayz7owg4yypoo', + emoji_name: 'thumbsup', + create_at: 1596032651748, + update_at: 1608253011321, + delete_at: 0, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toMatch('Reaction'); + }); + + it('=> operateFileRecord: should return an array of type File', async () => { + expect.assertions(3); + + const database = await createConnection(); + expect(database).toBeTruthy(); + + const preparedRecords = await operateFileRecord({ + database: database!, + value: { + post_id: 'ps81iqbddesfby8jayz7owg4yypoo', + name: 'test_file', + extension: '.jpg', + size: 1000, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toMatch('File'); + }); + + it('=> operatePostMetadataRecord: should return an array of type PostMetadata', async () => { + expect.assertions(3); + + const database = await createConnection(); + expect(database).toBeTruthy(); + + const preparedRecords = await operatePostMetadataRecord({ + database: database!, + value: { + id: 'ps81i4yypoo', + data: {}, + postId: 'ps81iqbddesfby8jayz7owg4yypoo', + type: 'opengraph', + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toMatch('PostMetadata'); + }); + + it('=> operateDraftRecord: should return an array of type Draft', async () => { + expect.assertions(3); + + const database = await createConnection(); + expect(database).toBeTruthy(); + + const preparedRecords = await operateDraftRecord({ + database: database!, + value: { + id: 'ps81i4yypoo', + root_id: 'ps81iqbddesfby8jayz7owg4yypoo', + message: 'draft message', + channel_id: 'channel_idp23232e', + files: [], + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toMatch('Draft'); + }); + + it('=> operatePostsInChannelRecord: should return an array of type PostsInChannel', async () => { + expect.assertions(3); + + const database = await createConnection(); + expect(database).toBeTruthy(); + + const preparedRecords = await operatePostsInChannelRecord({ + database: database!, + value: { + id: 'ps81i4yypoo', + channel_id: 'channel_idp23232e', + earliest: 1608253011321, + latest: 1609253011321, + }, + }); + + expect(preparedRecords).toBeTruthy(); + expect(preparedRecords!.collection.modelClass.name).toMatch('PostsInChannel'); + }); +}); diff --git a/app/database/admin/data_operator/test.ts b/app/database/admin/data_operator/test.ts deleted file mode 100644 index 59ef8321ff..0000000000 --- a/app/database/admin/data_operator/test.ts +++ /dev/null @@ -1,481 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {MM_TABLES} from '@constants/database'; -import {Q} from '@nozbe/watermelondb'; -import App from '@typings/database/app'; - -import DatabaseManager, {DatabaseType} from '../database_manager'; -import DataOperator, {IsolatedEntities, OperationType} from './index'; -import { - operateAppRecord, - operateCustomEmojiRecord, - operateGlobalRecord, - operateRoleRecord, - operateServersRecord, - operateSystemRecord, - operateTermsOfServiceRecord, -} from './operators'; - -jest.mock('../database_manager'); - -const {APP} = MM_TABLES.DEFAULT; - -describe('*** Data Operator tests ***', () => { - it('=> should return an array of type App for operateAppRecord', async () => { - expect.assertions(3); - - const db = await DatabaseManager.getDefaultDatabase(); - expect(db).toBeTruthy(); - - const preparedRecords = await operateAppRecord({ - db: db!, - optType: OperationType.CREATE, - value: {buildNumber: 'build-7', createdAt: 1, id: 'id-18', versionNumber: 'v-1'}, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toMatch('App'); - }); - - it('=> should return an array of type Global for operateGlobalRecord', async () => { - expect.assertions(3); - - const db = await DatabaseManager.getDefaultDatabase(); - expect(db).toBeTruthy(); - - const preparedRecords = await operateGlobalRecord({ - db: db!, - optType: OperationType.CREATE, - value: {id: 'g-1', name: 'g-n1', value: 'g-v1'}, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toMatch('Global'); - }); - - it('=> should return an array of type Servers for operateServersRecord', async () => { - expect.assertions(3); - - const db = await DatabaseManager.getDefaultDatabase(); - expect(db).toBeTruthy(); - - const preparedRecords = await operateServersRecord({ - db: db!, - optType: OperationType.CREATE, - value: { - dbPath: 'mm-server', - displayName: 's-displayName', - id: 's-1', - mentionCount: 1, - unreadCount: 0, - url: 'https://community.mattermost.com', - }, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toMatch('Servers'); - }); - - it('=> should return an array of type CustomEmoji for operateCustomEmojiRecord', async () => { - expect.assertions(3); - - const db = await DatabaseManager.createDatabaseConnection({ - shouldAddToDefaultDatabase: true, - databaseConnection: { - actionsEnabled: true, - dbName: 'community mattermost', - dbType: DatabaseType.SERVER, - serverUrl: 'https://appv2.mattermost.com', - }, - }); - expect(db).toBeTruthy(); - - const preparedRecords = await operateCustomEmojiRecord({ - db: db!, - optType: OperationType.CREATE, - value: {id: 'emo-1', name: 'emoji'}, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toMatch('CustomEmoji'); - }); - - it('=> should return an array of type Role for operateRoleRecord', async () => { - expect.assertions(3); - - const db = await DatabaseManager.createDatabaseConnection({ - shouldAddToDefaultDatabase: true, - databaseConnection: { - actionsEnabled: true, - dbName: 'community mattermost', - dbType: DatabaseType.SERVER, - serverUrl: 'https://appv2.mattermost.com', - }, - }); - expect(db).toBeTruthy(); - - const preparedRecords = await operateRoleRecord({ - db: db!, - optType: OperationType.CREATE, - value: {id: 'role-1', name: 'role-name-1', permissions: []}, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toMatch('Role'); - }); - - it('=> should return an array of type System for operateSystemRecord', async () => { - expect.assertions(3); - - const db = await DatabaseManager.createDatabaseConnection({ - shouldAddToDefaultDatabase: true, - databaseConnection: { - actionsEnabled: true, - dbName: 'community mattermost', - dbType: DatabaseType.SERVER, - serverUrl: 'https://appv2.mattermost.com', - }, - }); - expect(db).toBeTruthy(); - - const preparedRecords = await operateSystemRecord({ - db: db!, - optType: OperationType.CREATE, - value: {id: 'system-1', name: 'system-name-1', value: 'system'}, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toMatch('System'); - }); - - it('=> should return an array of type TermsOfService for operateTermsOfServiceRecord', async () => { - expect.assertions(3); - - const db = await DatabaseManager.createDatabaseConnection({ - shouldAddToDefaultDatabase: true, - databaseConnection: { - actionsEnabled: true, - dbName: 'community mattermost', - dbType: DatabaseType.SERVER, - serverUrl: 'https://appv2.mattermost.com', - }, - }); - expect(db).toBeTruthy(); - - const preparedRecords = await operateTermsOfServiceRecord({ - db: db!, - optType: OperationType.CREATE, - value: {id: 'system-1', acceptedAt: 1}, - }); - - expect(preparedRecords).toBeTruthy(); - expect(preparedRecords!.collection.modelClass.name).toMatch('TermsOfService'); - }); - - 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.handleIsolatedEntityData({ - optType: OperationType.CREATE, - 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 defaultDB = await DatabaseManager.getDefaultDatabase(); - expect(defaultDB).toBeTruthy(); - - const records = await defaultDB!.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.handleIsolatedEntityData({ - optType: OperationType.CREATE, - 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.handleIsolatedEntityData({ - optType: OperationType.UPDATE, - 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.handleIsolatedEntityData({ - optType: OperationType.UPDATE, - 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(); - - // id-10 and id-11 exist but yet the optType is CREATE. The operator should then prepareUpdate the records instead of prepareCreate - await DataOperator.handleIsolatedEntityData({ - optType: OperationType.CREATE, - 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.handleIsolatedEntityData({ - optType: OperationType.UPDATE, - 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('=> should use operateAppRecord for APP entity in handleBaseData', async () => { - expect.assertions(2); - - const defaultDB = await DatabaseManager.getDefaultDatabase(); - expect(defaultDB).toBeTruthy(); - - const spyOnHandleBase = jest.spyOn(DataOperator as any, 'handleBaseData'); - - const data = { - optType: OperationType.CREATE, - 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'}, - ], - }; - - await DataOperator.handleIsolatedEntityData(data); - - expect(spyOnHandleBase).toHaveBeenCalledWith({...data, recordOperator: operateAppRecord}); - }); - - it('=> should use operateGlobalRecord for GLOBAL entity in handleBaseData', async () => { - expect.assertions(2); - - const defaultDB = await DatabaseManager.getDefaultDatabase(); - expect(defaultDB).toBeTruthy(); - - const spyOnHandleBase = jest.spyOn(DataOperator as any, 'handleBaseData'); - - const data = { - optType: OperationType.CREATE, - tableName: IsolatedEntities.GLOBAL, - values: {id: 'global-1-id', name: 'global-1-name', value: 'global-1-value'}, - }; - - await DataOperator.handleIsolatedEntityData(data); - - expect(spyOnHandleBase).toHaveBeenCalledWith({...data, recordOperator: operateGlobalRecord}); - }); - - it('=> should use operateServersRecord for SERVERS entity in handleBaseData', async () => { - expect.assertions(2); - - const defaultDB = await DatabaseManager.getDefaultDatabase(); - expect(defaultDB).toBeTruthy(); - - const spyOnHandleBase = jest.spyOn(DataOperator as any, 'handleBaseData'); - - const data = { - optType: OperationType.CREATE, - tableName: IsolatedEntities.SERVERS, - values: { - dbPath: 'server.db', - displayName: 'community', - id: 'server-id-1', - mentionCount: 0, - unreadCount: 0, - url: 'https://community.mattermost.com', - }, - }; - - await DataOperator.handleIsolatedEntityData(data); - - expect(spyOnHandleBase).toHaveBeenCalledWith({...data, recordOperator: operateServersRecord}); - }); - - it('=> should use operateCustomEmojiRecord for CUSTOM_EMOJI entity in handleBaseData', async () => { - expect.assertions(2); - - const defaultDB = await DatabaseManager.getDefaultDatabase(); - expect(defaultDB).toBeTruthy(); - - const spyOnHandleBase = jest.spyOn(DataOperator as any, 'handleBaseData'); - - const data = { - optType: OperationType.CREATE, - tableName: IsolatedEntities.CUSTOM_EMOJI, - values: { - id: 'custom-emoji-id-1', - name: 'custom-emoji-1', - }, - }; - - await DataOperator.handleIsolatedEntityData(data); - - expect(spyOnHandleBase).toHaveBeenCalledWith({...data, recordOperator: operateCustomEmojiRecord}); - }); - - it('=> should use operateRoleRecord for ROLE entity in handleBaseData', async () => { - expect.assertions(2); - - const defaultDB = await DatabaseManager.getDefaultDatabase(); - expect(defaultDB).toBeTruthy(); - - const spyOnHandleBase = jest.spyOn(DataOperator as any, 'handleBaseData'); - - const data = { - optType: OperationType.CREATE, - tableName: IsolatedEntities.ROLE, - values: { - id: 'custom-emoji-id-1', - name: 'custom-emoji-1', - permissions: ['custom-emoji-1'], - }, - }; - - await DataOperator.handleIsolatedEntityData(data); - - expect(spyOnHandleBase).toHaveBeenCalledWith({...data, recordOperator: operateRoleRecord}); - }); - - it('=> should use operateSystemRecord for SYSTEM entity in handleBaseData', async () => { - expect.assertions(2); - - const defaultDB = await DatabaseManager.getDefaultDatabase(); - expect(defaultDB).toBeTruthy(); - - const spyOnHandleBase = jest.spyOn(DataOperator as any, 'handleBaseData'); - - const data = { - optType: OperationType.CREATE, - tableName: IsolatedEntities.SYSTEM, - values: {id: 'system-id-1', name: 'system-1', value: 'system-1'}, - }; - - await DataOperator.handleIsolatedEntityData(data); - - expect(spyOnHandleBase).toHaveBeenCalledWith({...data, recordOperator: operateSystemRecord}); - }); - - it('=> should use operateTermsOfServiceRecord for TERMS_OF_SERVICE entity in handleBaseData', async () => { - expect.assertions(2); - - const defaultDB = await DatabaseManager.getDefaultDatabase(); - expect(defaultDB).toBeTruthy(); - - const spyOnHandleBase = jest.spyOn(DataOperator as any, 'handleBaseData'); - - const data = { - optType: OperationType.CREATE, - tableName: IsolatedEntities.TERMS_OF_SERVICE, - values: {id: 'tos-1', acceptedAt: 1}, - }; - - await DataOperator.handleIsolatedEntityData(data); - - expect(spyOnHandleBase).toHaveBeenCalledWith({...data, recordOperator: operateTermsOfServiceRecord}); - }); - - it('=> should not call handleBaseData if tableName is invalid', async () => { - expect.assertions(2); - - const defaultDB = await DatabaseManager.getDefaultDatabase(); - expect(defaultDB).toBeTruthy(); - - const spyOnHandleBase = jest.spyOn(DataOperator as any, 'handleBaseData'); - - const data = { - optType: OperationType.CREATE, - tableName: 'INVALID_TABLE_NAME', - values: {id: 'tos-1', acceptedAt: 1}, - }; - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - await DataOperator.handleIsolatedEntityData(data); - - expect(spyOnHandleBase).toHaveBeenCalledTimes(0); - }); -}); diff --git a/app/database/admin/data_operator/utils/index.ts b/app/database/admin/data_operator/utils/index.ts new file mode 100644 index 0000000000..f5a8231225 --- /dev/null +++ b/app/database/admin/data_operator/utils/index.ts @@ -0,0 +1,123 @@ +// 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 {ChainPosts, SanitizePosts, SanitizeReactions, RawPost, RawReaction} from '@typings/database/database'; +import Reaction from '@typings/database/reaction'; + +const {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 {RawPost[]} sanitizePosts.posts + * @param {string[]} sanitizePosts.orders + */ +export const sanitizePosts = ({posts, orders}: SanitizePosts) => { + const orderedPosts:RawPost[] = []; + const unOrderedPosts:RawPost[] = []; + + posts.forEach((post) => { + if (post?.id && orders.includes(post.id)) { + orderedPosts.push(post); + } else { + unOrderedPosts.push(post); + } + }); + + return { + orderedPosts, + 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 {string[]} chainPosts.orders + * @param {RawPost[]} chainPosts.rawPosts + * @param {string} chainPosts.previousPostId + * @returns {RawPost[]} + */ +export const createPostsChain = ({orders, rawPosts, previousPostId = ''}: ChainPosts) => { + const posts: RawPost[] = []; + rawPosts.forEach((post) => { + const postId = post.id; + const orderIndex = orders.findIndex((order) => { + return order === postId; + }); + + if (orderIndex === -1) { + // 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}); + } else { + posts.push({...post, prev_post_id: orders[orderIndex - 1]}); + } + }); + + return posts; +}; + +/** + * 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 {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) => { + const reactions = (await database.collections. + get(REACTION). + query(Q.where('post_id', post_id)). + fetch()) as Reaction[]; + + // similarObjects: Contains objects that are in both the RawReaction array and in the Reaction entity + const similarObjects: Reaction[] = []; + + const createReactions: RawReaction[] = []; + + const emojiSet = new Set(); + + for (let i = 0; i < rawReactions.length; i++) { + const rawReaction = rawReactions[i] as RawReaction; + + // Do we have a similar value of rawReaction in the REACTION table? + const idxPresent = reactions.findIndex((value) => { + return ( + value.userId === rawReaction.user_id && + value.emojiName === rawReaction.emoji_name + ); + }); + + if (idxPresent === -1) { + // So, we don't have a similar Reaction object. That one is new...so we'll create it + createReactions.push(rawReaction); + + // If that reaction is new, that implies that the emoji might also be new + emojiSet.add(rawReaction.emoji_name); + } else { + // we have a similar object in both reactions and rawReactions; we'll pop it out from both arrays + similarObjects.push(reactions[idxPresent]); + } + } + + // finding out elements to delete using array subtract + const deleteReactions = reactions. + filter((reaction) => !similarObjects.includes(reaction)). + map((outCast) => outCast.prepareDestroyPermanently()); + + const createEmojis = Array.from(emojiSet).map((emoji) => { + return {name: emoji}; + }); + + return {createReactions, createEmojis, deleteReactions}; +}; diff --git a/app/database/admin/data_operator/utils/mock.ts b/app/database/admin/data_operator/utils/mock.ts new file mode 100644 index 0000000000..71fd795c0a --- /dev/null +++ b/app/database/admin/data_operator/utils/mock.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const mockedPosts = { + order: [ + '8swgtrrdiff89jnsiwiip3y1eoe', + '8fcnk3p1jt8mmkaprgajoxz115a', + '3y3w3a6gkbg73bnj3xund9o5ic', + '4btbnmticjgw7ewd3qopmpiwqw', + ], + posts: { + '8swgtrrdiff89jnsiwiip3y1eoe': { + 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: "I'll second these kudos! Thanks m!", + type: '', + props: {}, + hashtags: '', + pending_post_id: '', + reply_count: 4, + last_reply_at: 0, + participants: null, + metadata: {}, + }, + '8fcnk3p1jt8mmkaprgajoxz115a': { + id: '8fcnk3p1jt8mmkaprgajoxz115a', + create_at: 1601557961124, + update_at: 1601557961124, + edit_at: 0, + delete_at: 0, + is_pinned: false, + user_id: 'hy5sq51sebfh58ktrce5ijtcwyy', + channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', + root_id: '', + parent_id: '', + original_id: '', + message: 'a added to the channel by j.', + type: 'system_add_to_channel', + props: { + addedUserId: 'z89qsntet7bimd3xddfu7u9ncdaxc', + addedUsername: 'a', + userId: 'hy5sdfdfq51sebfh58ktrce5ijtcwy', + username: 'j', + }, + hashtags: '', + pending_post_id: '', + reply_count: 0, + last_reply_at: 0, + participants: null, + metadata: {}, + }, + '3y3w3a6gkbg73bnj3xund9o5ic': { + id: '3y3w3a6gkbg73bnj3xund9o5ic', + create_at: 1596213796212, + update_at: 1596213796212, + edit_at: 0, + delete_at: 0, + is_pinned: false, + user_id: '44ud4m9tqwby3mphzzdwm7h31sr', + channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', + root_id: 'ps81iqbewesfby8jayz7owg4yypo', + parent_id: 'ps81iqbwesfby8jayz7owg4yypo', + original_id: '', + message: 'Great work M!', + type: '', + props: {}, + hashtags: '', + pending_post_id: '', + reply_count: 4, + last_reply_at: 0, + participants: null, + metadata: {}, + }, + '4btbnmticjgw7ewd3qopmpiwqw': { + id: '4btbnmticjgw7ewd3qopmpiwqw', + create_at: 1603221654312, + update_at: 1603221654312, + edit_at: 0, + delete_at: 0, + is_pinned: false, + user_id: 'opihgdf9nby385mnxj7a5jpfsy3e', + channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', + root_id: '', + parent_id: '', + original_id: '', + message: 'eannel.', + type: 'system_join_channel', + props: { + username: 'dok', + }, + hashtags: '', + pending_post_id: '', + reply_count: 0, + last_reply_at: 0, + participants: null, + metadata: {}, + }, + '4r9jmr7eqt8dxq3f9woypzurry': { + id: '4r9jmr7eqt8dxq3f9woypzurry', + create_at: 1608252986811, + update_at: 1608323574215, + edit_at: 0, + delete_at: 0, + is_pinned: false, + user_id: '1zkzkhh357b4bdfejephjz5u8daw', + channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', + root_id: 'a7ebyw883sdftrm884p1qcgt8yw4a', + parent_id: 'a7ebyw8q83trm884p1qcgt8yw4a', + original_id: '', + message: 'Oh off fixed', + type: '', + props: {}, + hashtags: '', + pending_post_id: '', + has_reactions: true, + reply_count: 7, + last_reply_at: 0, + participants: null, + metadata: { + reactions: [ + { + user_id: 'weeoa9janfdmbgp3wrgcbofxpo', + post_id: '4r9jmr7eqt8dxq3f9woypzurry', + emoji_name: 'thumbsup', + create_at: 1608253011321, + update_at: 1608253011321, + delete_at: 0, + }, + { + user_id: 'dbjk4taox3fn5mxnua7fc5zo6c', + post_id: '4r9jmr7eqt8dxq3f9woypzurry', + emoji_name: 'thumbsup', + create_at: 1608253070704, + update_at: 1608253070704, + delete_at: 0, + }, + ], + }, + }, + '7mwm4hb7g3bx8e1yumpi5zgwtw': { + id: '7mwm4hb7g3bx8e1yumpi5zgwtw', + create_at: 1608289643899, + update_at: 1608289727232, + edit_at: 1608289727232, + delete_at: 0, + is_pinned: false, + user_id: '1zkzkhhk357b4bejephjz5u8daw', + channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', + root_id: 'a7ebyw8f83trm884p1qcgt8yw4a', + parent_id: 'a7ebyw883trm884p1qcgt8yw4a', + original_id: '', + message: 'There are many ', + type: '', + props: {}, + hashtags: '', + pending_post_id: '', + reply_count: 7, + last_reply_at: 0, + participants: null, + metadata: { + emojis: [ + { + id: 'by3ddj8act7g1icikwehpksi1ycd', + create_at: 1580913641769, + update_at: 1580913641769, + delete_at: 0, + creator_id: '4cdprpki7ri81mbx8efixdcsb8jo', + name: 'boom2', + }, + ], + }, + }, + }, +}; + +export const mockedReactions = [ + { + user_id: 'beqkgo4wzbn98kjzjgc1p5n91o', + post_id: '8ww8kb1dbpf59fu4d5xhu5nf5w', + emoji_name: 'tada', + create_at: 1601558322701, + update_at: 1601558322701, + delete_at: 0, + }, + { + user_id: 'z89qsntet7bimd3xu7u9ncdaxc', + post_id: '8ww8kb1dbpf59fu4d5xhu5nf5w', + emoji_name: 'thanks', + create_at: 1601558379209, + update_at: 1601558379209, + delete_at: 0, + }, + { + user_id: 'z89qsntet7bimd3xu7u9ncdaxc', + post_id: '8ww8kb1dbpf59fu4d5xhu5nf5w', + emoji_name: 'thumbsup', + create_at: 1601558413496, + update_at: 1601558413496, + delete_at: 0, + }, +]; diff --git a/app/database/admin/data_operator/utils/test.ts b/app/database/admin/data_operator/utils/test.ts new file mode 100644 index 0000000000..8639fac24b --- /dev/null +++ b/app/database/admin/data_operator/utils/test.ts @@ -0,0 +1,116 @@ +// 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 {DatabaseType} from '@typings/database/enums'; + +import {createPostsChain, sanitizePosts, sanitizeReactions} from './index'; +import {mockedPosts, mockedReactions} from './mock'; + +jest.mock('../../database_manager'); + +describe('DataOperator: Utils tests', () => { + it('=> sanitizePosts: should filter between ordered and unordered posts', () => { + const {orderedPosts, unOrderedPosts} = sanitizePosts({ + posts: Object.values(mockedPosts.posts), + orders: mockedPosts.order, + }); + expect(orderedPosts.length).toBe(4); + expect(unOrderedPosts.length).toBe(2); + }); + + it('=> createPostsChain: should link posts amongst each other based on order array', () => { + const previousPostId = 'prev_xxyuoxmehne'; + const chainedOfPosts = createPostsChain({ + orders: mockedPosts.order, + rawPosts: Object.values(mockedPosts.posts), + previousPostId, + }); + + // eslint-disable-next-line max-nested-callbacks + const post1 = chainedOfPosts.find((post) => { + return post.id === '8swgtrrdiff89jnsiwiip3y1eoe'; + }); + expect(post1).toBeTruthy(); + expect(post1!.prev_post_id).toBe(previousPostId); + + // eslint-disable-next-line max-nested-callbacks + const post2 = chainedOfPosts.find((post) => { + return post.id === '8fcnk3p1jt8mmkaprgajoxz115a'; + }); + 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'; + }); + expect(post3).toBeTruthy(); + expect(post3!.prev_post_id).toBe('8fcnk3p1jt8mmkaprgajoxz115a'); + + // eslint-disable-next-line max-nested-callbacks + const post4 = chainedOfPosts.find((post) => { + return post.id === '4btbnmticjgw7ewd3qopmpiwqw'; + }); + expect(post4).toBeTruthy(); + expect(post4!.prev_post_id).toBe('3y3w3a6gkbg73bnj3xund9o5ic'); + }); + + it('=> sanitizeReactions: should triage between reactions that needs creation/deletion and emojis to be created', async () => { + const dbName = 'server_schema_connection'; + const serverUrl = 'https://appv2.mattermost.com'; + const database = await DatabaseManager.createDatabaseConnection({ + shouldAddToDefaultDatabase: true, + configs: { + actionsEnabled: true, + dbName, + dbType: DatabaseType.SERVER, + serverUrl, + }, + }); + await DatabaseManager.setActiveServerDatabase({ + displayName: dbName, + serverUrl, + }); + + // we commit one Reaction to our database + const prepareRecords = await DataOperator.handleReactions({ + reactions: [ + { + user_id: 'beqkgo4wzbn98kjzjgc1p5n91o', + post_id: '8ww8kb1dbpf59fu4d5xhu5nf5w', + emoji_name: 'tada_will_be_removed', + create_at: 1601558322701, + update_at: 1601558322701, + delete_at: 0, + }, + ], + prepareRowsOnly: true, + }); + + // Jest in not using the same database instance amongst the Singletons; hence, we are creating the reaction record here + // eslint-disable-next-line max-nested-callbacks + await database!.action(async () => { + await database!.batch(...prepareRecords); + }); + + const { + createReactions, + createEmojis, + deleteReactions, + } = await sanitizeReactions({ + database: database!, + post_id: '8ww8kb1dbpf59fu4d5xhu5nf5w', + rawReactions: mockedReactions, + }); + + // The reaction with emoji_name 'tada_will_be_removed' will be in the deleteReactions array. This implies that the user who reacted on that post later removed the reaction. + expect(deleteReactions.length).toBe(1); + expect(deleteReactions[0].emojiName).toBe('tada_will_be_removed'); + + expect(createReactions.length).toBe(3); + + expect(createEmojis.length).toBe(3); + }); +}); diff --git a/app/database/admin/data_operator/wrapper/index.ts b/app/database/admin/data_operator/wrapper/index.ts new file mode 100644 index 0000000000..4b566e10af --- /dev/null +++ b/app/database/admin/data_operator/wrapper/index.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +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 + const connections = await DatabaseManager.retrieveDatabaseInstances([ + serverUrl, + ]); + + if (connections?.length) { + // finds the connection that corresponds to the serverUrl value + const index = connections.findIndex((connection) => { + return connection.url === serverUrl; + }); + + if (!connections?.[index]?.dbInstance) { + throw new DatabaseConnectionException( + `An instance of a database connection was found but we could not create a connection out of it for url: ${serverUrl}`, + ); + } + + const connection = connections[index].dbInstance as Database; + + return new DataOperator(connection); + } + + throw new DatabaseConnectionException( + `No database has been registered with this url: ${serverUrl}`, + ); +}; diff --git a/app/database/admin/data_operator/wrapper/test.ts b/app/database/admin/data_operator/wrapper/test.ts new file mode 100644 index 0000000000..80ea672654 --- /dev/null +++ b/app/database/admin/data_operator/wrapper/test.ts @@ -0,0 +1,353 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import DatabaseManager from '@database/admin/database_manager'; +import {createDataOperator} from '@database/admin/data_operator/wrapper'; +import DatabaseConnectionException from '@database/admin/exceptions/database_connection_exception'; +import {DatabaseType} from '@typings/database/enums'; + +jest.mock('@database/admin/database_manager'); + +describe('*** DataOperator Wrapper ***', () => { + it('=> wrapper should return an instance of DataOperator ', async () => { + expect.assertions(1); + + const serverUrl = 'https://wrapper.mattermost.com'; + + // first we create the connection and save it into default database + await DatabaseManager.createDatabaseConnection({ + configs: { + actionsEnabled: true, + dbName: 'community mattermost', + dbType: DatabaseType.SERVER, + serverUrl, + }, + shouldAddToDefaultDatabase: true, + }); + + const dataOperator = await createDataOperator(serverUrl); + + expect(dataOperator).toBeTruthy(); + }); + + it('=> wrapper should throw an error due to invalid server url', async () => { + expect.assertions(2); + + const serverUrl = 'https://wrapper.mattermost.com'; + + // first we create the connection and save it into default database + await DatabaseManager.createDatabaseConnection({ + configs: { + actionsEnabled: true, + dbName: 'test_database', + dbType: DatabaseType.SERVER, + serverUrl, + }, + shouldAddToDefaultDatabase: true, + }); + + await expect(createDataOperator('https://error.com')).rejects.toThrow( + 'No database has been registered with this url: https://error.com', + ); + + await expect(createDataOperator('https://error.com')).rejects.toThrow(DatabaseConnectionException); + }); + + it('=> wrapper to handlePosts [OTHER DATABASE]: should write to Post and its sub-child entities', async () => { + expect.assertions(12); + + const posts = [ + { + id: '8swgtrrdiff89jnsiwiip3y1eoe', + create_at: 1596032651747, + update_at: 1596032651747, + edit_at: 0, + delete_at: 0, + is_pinned: false, + user_id: 'q3mzxua9zjfczqakxdkowc6u6yy', + channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', + root_id: '', + parent_id: 'ps81iqbddesfby8jayz7owg4yypoo', + original_id: '', + message: "I'll second these kudos! Thanks m!", + type: '', + props: {}, + hashtags: '', + pending_post_id: '', + reply_count: 4, + last_reply_at: 0, + participants: null, + metadata: { + images: { + 'https://community-release.mattermost.com/api/v4/image?url=https%3A%2F%2Favatars1.githubusercontent.com%2Fu%2F6913320%3Fs%3D400%26v%3D4': { + width: 400, + height: 400, + format: 'png', + frame_count: 0, + }, + }, + reactions: [ + { + user_id: 'njic1w1k5inefp848jwk6oukio', + post_id: 'a7ebyw883trm884p1qcgt8yw4a', + emoji_name: 'clap', + create_at: 1608252965442, + update_at: 1608252965442, + delete_at: 0, + }, + ], + embeds: [ + { + type: 'opengraph', + url: + 'https://github.com/mickmister/mattermost-plugin-default-theme', + data: { + type: 'object', + url: + 'https://github.com/mickmister/mattermost-plugin-default-theme', + title: 'mickmister/mattermost-plugin-default-theme', + description: + 'Contribute to mickmister/mattermost-plugin-default-theme development by creating an account on GitHub.', + determiner: '', + site_name: 'GitHub', + locale: '', + locales_alternate: null, + images: [ + { + url: '', + secure_url: + 'https://community-release.mattermost.com/api/v4/image?url=https%3A%2F%2Favatars1.githubusercontent.com%2Fu%2F6913320%3Fs%3D400%26v%3D4', + type: '', + width: 0, + height: 0, + }, + ], + audios: null, + videos: null, + }, + }, + ], + emojis: [ + { + id: 'dgwyadacdbbwjc8t357h6hwsrh', + create_at: 1502389307432, + update_at: 1502389307432, + delete_at: 0, + creator_id: 'x6sdh1ok1tyd9f4dgq4ybw839a', + name: 'thanks', + }, + ], + files: [ + { + id: 'f1oxe5rtepfs7n3zifb4sso7po', + user_id: '89ertha8xpfsumpucqppy5knao', + post_id: 'a7ebyw883trm884p1qcgt8yw4a', + create_at: 1608270920357, + update_at: 1608270920357, + delete_at: 0, + name: '4qtwrg.jpg', + extension: 'jpg', + size: 89208, + mime_type: 'image/jpeg', + width: 500, + height: 656, + has_preview_image: true, + mini_preview: + '/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABAAEAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AN/T/iZp+pX15FpUmnwLbXtpJpyy2sQLw8CcBXA+bksCDnHGOaf4W+P3xIshbQ6loB8RrbK11f3FpbBFW3ZwiFGHB2kr25BIOeCPPbX4S3407T7rTdDfxFNIpDyRaw9lsB4OECHGR15yO4GK6fRPhR4sGmSnxAs8NgchNOjvDPsjz8qSHA37cDk5JPPFdlOpTdPlcVt/Ku1lrvr17b67EPnjrH8/626H/9k=', + }, + ], + }, + }, + { + id: '8fcnk3p1jt8mmkaprgajoxz115a', + create_at: 1596104683748, + update_at: 1596104683748, + edit_at: 0, + delete_at: 0, + is_pinned: false, + user_id: 'hy5sq51sebfh58ktrce5ijtcwyy', + channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', + root_id: '8swgtrrdiff89jnsiwiip3y1eoe', + parent_id: '', + original_id: '', + message: 'a added to the channel by j.', + type: 'system_add_to_channel', + props: { + addedUserId: 'z89qsntet7bimd3xddfu7u9ncdaxc', + addedUsername: 'a', + userId: 'hy5sdfdfq51sebfh58ktrce5ijtcwy', + username: 'j', + }, + hashtags: '', + pending_post_id: '', + reply_count: 0, + last_reply_at: 0, + participants: null, + metadata: {}, + }, + { + id: '3y3w3a6gkbg73bnj3xund9o5ic', + create_at: 1596277483749, + update_at: 1596277483749, + edit_at: 0, + delete_at: 0, + is_pinned: false, + user_id: '44ud4m9tqwby3mphzzdwm7h31sr', + channel_id: 'xxoq1p6bqg7dkxb3kj1mcjoungw', + root_id: '8swgtrrdiff89jnsiwiip3y1eoe', + parent_id: 'ps81iqbwesfby8jayz7owg4yypo', + original_id: '', + message: 'Great work M!', + type: '', + props: {}, + hashtags: '', + pending_post_id: '', + reply_count: 4, + last_reply_at: 0, + participants: null, + metadata: {}, + }, + ]; + + // create connection to other server in default db + await DatabaseManager.createDatabaseConnection({ + shouldAddToDefaultDatabase: true, + configs: { + actionsEnabled: true, + dbName: 'other_server', + dbType: DatabaseType.SERVER, + 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 spyOnHandlePostsInThread = jest.spyOn(dataOperator as any, 'handlePostsInThread'); + const spyOnHandlePostsInChannel = jest.spyOn(dataOperator as any, 'handlePostsInChannel'); + + // handlePosts will in turn call handlePostsInThread + await dataOperator.handlePosts({ + orders: [ + '8swgtrrdiff89jnsiwiip3y1eoe', + '8fcnk3p1jt8mmkaprgajoxz115a', + '3y3w3a6gkbg73bnj3xund9o5ic', + ], + values: posts, + previousPostId: '', + }); + + expect(spyOnHandleReactions).toHaveBeenCalledTimes(1); + expect(spyOnHandleReactions).toHaveBeenCalledWith({ + reactions: [ + { + user_id: 'njic1w1k5inefp848jwk6oukio', + post_id: 'a7ebyw883trm884p1qcgt8yw4a', + emoji_name: 'clap', + create_at: 1608252965442, + update_at: 1608252965442, + delete_at: 0, + }, + ], + prepareRowsOnly: true, + }); + + expect(spyOnHandleFiles).toHaveBeenCalledTimes(1); + expect(spyOnHandleFiles).toHaveBeenCalledWith({ + files: [ + { + id: 'f1oxe5rtepfs7n3zifb4sso7po', + user_id: '89ertha8xpfsumpucqppy5knao', + post_id: 'a7ebyw883trm884p1qcgt8yw4a', + create_at: 1608270920357, + update_at: 1608270920357, + delete_at: 0, + name: '4qtwrg.jpg', + extension: 'jpg', + size: 89208, + mime_type: 'image/jpeg', + width: 500, + height: 656, + has_preview_image: true, + mini_preview: + '/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABAAEAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/AN/T/iZp+pX15FpUmnwLbXtpJpyy2sQLw8CcBXA+bksCDnHGOaf4W+P3xIshbQ6loB8RrbK11f3FpbBFW3ZwiFGHB2kr25BIOeCPPbX4S3407T7rTdDfxFNIpDyRaw9lsB4OECHGR15yO4GK6fRPhR4sGmSnxAs8NgchNOjvDPsjz8qSHA37cDk5JPPFdlOpTdPlcVt/Ku1lrvr17b67EPnjrH8/626H/9k=', + }, + ], + prepareRowsOnly: true, + }); + + expect(spyOnHandlePostMetadata).toHaveBeenCalledTimes(1); + expect(spyOnHandlePostMetadata).toHaveBeenCalledWith({ + embeds: [ + { + embed: [ + { + type: 'opengraph', + url: 'https://github.com/mickmister/mattermost-plugin-default-theme', + data: { + type: 'object', + url: 'https://github.com/mickmister/mattermost-plugin-default-theme', + title: 'mickmister/mattermost-plugin-default-theme', + description: 'Contribute to mickmister/mattermost-plugin-default-theme development by creating an account on GitHub.', + determiner: '', + site_name: 'GitHub', + locale: '', + locales_alternate: null, + images: [ + { + url: '', + secure_url: 'https://community-release.mattermost.com/api/v4/image?url=https%3A%2F%2Favatars1.githubusercontent.com%2Fu%2F6913320%3Fs%3D400%26v%3D4', + type: '', + width: 0, + height: 0, + }, + ], + audios: null, + videos: null, + }, + }, + ], + postId: '8swgtrrdiff89jnsiwiip3y1eoe', + }, + ], + images: [ + { + images: { + 'https://community-release.mattermost.com/api/v4/image?url=https%3A%2F%2Favatars1.githubusercontent.com%2Fu%2F6913320%3Fs%3D400%26v%3D4': { + width: 400, + height: 400, + format: 'png', + frame_count: 0, + }, + }, + postId: '8swgtrrdiff89jnsiwiip3y1eoe', + }, + ], + prepareRowsOnly: true, + }); + + expect(spyOnHandleIsolatedEntity).toHaveBeenCalledTimes(1); + expect(spyOnHandleIsolatedEntity).toHaveBeenCalledWith({ + tableName: 'CustomEmoji', + values: [ + { + id: 'dgwyadacdbbwjc8t357h6hwsrh', + create_at: 1502389307432, + update_at: 1502389307432, + delete_at: 0, + creator_id: 'x6sdh1ok1tyd9f4dgq4ybw839a', + name: 'thanks', + }, + ], + }); + + expect(spyOnHandlePostsInThread).toHaveBeenCalledTimes(1); + 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 9327a8dad6..55a933de17 100644 --- a/app/database/admin/database_manager/__mocks__/index.ts +++ b/app/database/admin/database_manager/__mocks__/index.ts @@ -1,16 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Database, Model, Q} from '@nozbe/watermelondb'; -import {Class} from '@nozbe/watermelondb/utils/common'; +import {Database, Q} from '@nozbe/watermelondb'; import LokiJSAdapter from '@nozbe/watermelondb/adapters/lokijs'; import {MM_TABLES} from '@constants/database'; -import IServers from '@typings/database/servers'; -import type {DBInstance, DefaultNewServer, MMDatabaseConnection} from '@typings/database/database'; - -import DefaultMigration from '../../../default/migration'; -import {App, Global, Servers} from '../../../default/models'; +import DefaultMigration from '@database/default/migration'; +import {App, Global, Servers} from '@database/default/models'; +import {defaultSchema} from '@database/default/schema'; +import ServerMigration from '@database/server/migration'; import { Channel, ChannelInfo, @@ -40,33 +38,22 @@ import { TeamSearchHistory, TermsOfService, User, -} from '../../../server/models'; -import {defaultSchema} from '../../../default/schema'; -import ServerMigration from '../../../server/migration'; -import {serverSchema} from '../../../server/schema'; +} 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 {DatabaseType} from '@typings/database/enums'; +import IServers from '@typings/database/servers'; const {SERVERS} = MM_TABLES.DEFAULT; -type Models = Class[] - -// The elements needed to create a new connection -type DatabaseConnection = { - databaseConnection: MMDatabaseConnection, shouldAddToDefaultDatabase - : boolean -} - -// The elements required to switch to another active server database -type ActiveServerDatabase = { displayName: string, serverUrl: string } - -// The only two types of databases in the app -export enum DatabaseType { - DEFAULT, - SERVER -} +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +logger.silence(); class DatabaseManager { - private activeDatabase: DBInstance; - private defaultDatabase: DBInstance; + private activeDatabase: DatabaseInstance; + private defaultDatabase: DatabaseInstance; private readonly defaultModels: Models; private readonly iOSAppGroupDatabase: string | null; private readonly androidFilesDirectory: string | null; @@ -74,10 +61,36 @@ class DatabaseManager { 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.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; @@ -86,21 +99,21 @@ class DatabaseManager { /** * 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 {DatabaseConfigs} databaseConnection * @param {boolean} shouldAddToDefaultDatabase * - * @returns {Promise} + * @returns {Promise} */ createDatabaseConnection = async ({ - databaseConnection, + configs, shouldAddToDefaultDatabase = true, - }: DatabaseConnection): Promise => { + }: DatabaseConnection): Promise => { const { actionsEnabled = true, dbName = 'default', dbType = DatabaseType.DEFAULT, serverUrl = undefined, - } = databaseConnection; + } = configs; try { const databaseName = dbType === DatabaseType.DEFAULT ? 'default' : dbName; @@ -118,7 +131,11 @@ class DatabaseManager { // Registers the new server connection into the DEFAULT database if (serverUrl && shouldAddToDefaultDatabase) { - await this.addServerToDefaultDatabase({databaseFilePath: databaseName, displayName: dbName, serverUrl}); + await this.addServerToDefaultDatabase({ + databaseFilePath: databaseName, + displayName: dbName, + serverUrl, + }); } return new Database({adapter, actionsEnabled, modelClasses}); } catch (e) { @@ -139,7 +156,7 @@ class DatabaseManager { const isServerPresent = await this.isServerPresent(serverUrl); this.activeDatabase = await this.createDatabaseConnection({ - databaseConnection: { + configs: { actionsEnabled: true, dbName: displayName, dbType: DatabaseType.SERVER, @@ -160,14 +177,14 @@ class DatabaseManager { 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 {DBInstance} + * @returns {DatabaseInstance} */ - getActiveServerDatabase = (): DBInstance => { + getActiveServerDatabase = (): DatabaseInstance => { return this.activeDatabase; }; @@ -175,7 +192,7 @@ class DatabaseManager { * getDefaultDatabase : Returns the default database. * @returns {Database} default database */ - getDefaultDatabase = async (): Promise => { + getDefaultDatabase = async (): Promise => { if (!this.defaultDatabase) { await this.setDefaultDatabase(); } @@ -187,9 +204,11 @@ class DatabaseManager { * and return them to the caller. * * @param {string[]} serverUrls - * @returns {Promise<{url: string, dbInstance: DBInstance}[] | null>} + * @returns {Promise<{url: string, dbInstance: DatabaseInstance}[] | null>} */ - retrieveDatabaseInstances = async (serverUrls?: string[]): Promise<{ url: string, dbInstance: DBInstance }[] | 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(); @@ -203,16 +222,15 @@ class DatabaseManager { if (servers.length) { const databasePromises = servers.map(async (server: IServers) => { const {displayName, url} = server; - const databaseConnection = { - actionsEnabled: true, - dbName: displayName, - dbType: DatabaseType.SERVER, - serverUrl: url, - }; // Since we are retrieving existing URL ( and so database connections ) from the 'DEFAULT' database, shouldAddToDefaultDatabase is set to false const dbInstance = await this.createDatabaseConnection({ - databaseConnection, + configs: { + actionsEnabled: true, + dbName: displayName, + dbType: DatabaseType.SERVER, + serverUrl: url, + }, shouldAddToDefaultDatabase: false, }); @@ -239,7 +257,10 @@ class DatabaseManager { let server: IServers; if (defaultDB) { - const serversRecords = await defaultDB.collections.get(SERVERS).query(Q.where('url', serverUrl)).fetch() as IServers[]; + const serversRecords = (await defaultDB.collections. + get(SERVERS). + query(Q.where('url', serverUrl)). + fetch()) as IServers[]; server = serversRecords?.[0] ?? undefined; if (server) { @@ -266,17 +287,17 @@ class DatabaseManager { 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[]; + const allServers = defaultDatabase && ((await defaultDatabase.collections.get(MM_TABLES.DEFAULT.SERVERS).query().fetch()) as IServers[]); return allServers; }; /** * setDefaultDatabase : Sets the default database. - * @returns {Promise} + * @returns {Promise} */ - private setDefaultDatabase = async (): Promise => { + private setDefaultDatabase = async (): Promise => { this.defaultDatabase = await this.createDatabaseConnection({ - databaseConnection: {dbName: 'default'}, + configs: {dbName: 'default'}, shouldAddToDefaultDatabase: false, }); return this.defaultDatabase; @@ -289,11 +310,7 @@ class DatabaseManager { * @param {string} serverUrl * @returns {Promise} */ - private addServerToDefaultDatabase = async ({ - databaseFilePath, - displayName, - serverUrl, - }: DefaultNewServer) => { + private addServerToDefaultDatabase = async ({databaseFilePath, displayName, serverUrl}: DefaultNewServer) => { try { const defaultDatabase = await this.getDefaultDatabase(); const isServerPresent = await this.isServerPresent(serverUrl); diff --git a/app/database/admin/database_manager/index.ts b/app/database/admin/database_manager/index.ts index e1aa7108dc..85e1a76635 100644 --- a/app/database/admin/database_manager/index.ts +++ b/app/database/admin/database_manager/index.ts @@ -1,26 +1,17 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite'; import {Database, Q} from '@nozbe/watermelondb'; +import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite'; +import logger from '@nozbe/watermelondb/utils/common/logger'; import {DeviceEventEmitter, Platform} from 'react-native'; import {FileSystem} from 'react-native-unimodules'; import {MIGRATION_EVENTS, MM_TABLES} from '@constants/database'; -import IServers from '@typings/database/servers'; -import type { - ActiveServerDatabase, - DBInstance, - DatabaseConnection, - DefaultNewServer, - MigrationEvents, - Models, -} from '@typings/database/database'; -import {deleteIOSDatabase, getIOSAppGroupDetails} from '@utils/mattermost_managed'; - -import DefaultMigration from '../../default/migration'; -import {App, Global, Servers} from '../../default/models'; -import {defaultSchema} from '../../default/schema'; +import DefaultMigration from '@database/default/migration'; +import {App, Global, Servers} from '@database/default/models'; +import {defaultSchema} from '@database/default/schema'; +import ServerMigration from '@database/server/migration'; import { Channel, ChannelInfo, @@ -50,21 +41,32 @@ import { TeamSearchHistory, TermsOfService, User, -} from '../../server/models'; -import ServerMigration from '../../server/migration'; -import {serverSchema} from '../../server/schema'; +} from '@database/server/models'; +import {serverSchema} from '@database/server/schema'; +import type { + ActiveServerDatabase, + DatabaseConnection, + DatabaseInstance, + DefaultNewServer, + MigrationEvents, + Models, +} from '@typings/database/database'; +import {DatabaseType} from '@typings/database/enums'; +import IServers from '@typings/database/servers'; +import {deleteIOSDatabase, getIOSAppGroupDetails} from '@utils/mattermost_managed'; const {SERVERS} = MM_TABLES.DEFAULT; -// The only two types of databases in the app -export enum DatabaseType { - DEFAULT, - SERVER +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: DBInstance; - private defaultDatabase: DBInstance; + private activeDatabase: DatabaseInstance; + private defaultDatabase: DatabaseInstance; private readonly defaultModels: Models; private readonly iOSAppGroupDatabase: string | null; private readonly androidFilesDirectory: string | null; @@ -72,10 +74,36 @@ class DatabaseManager { 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.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; @@ -87,18 +115,18 @@ class DatabaseManager { * @param {MMDatabaseConnection} databaseConnection * @param {boolean} shouldAddToDefaultDatabase * - * @returns {Promise} + * @returns {Promise} */ createDatabaseConnection = async ({ - databaseConnection, + configs, shouldAddToDefaultDatabase = true, - }: DatabaseConnection): Promise => { + }: DatabaseConnection): Promise => { const { actionsEnabled = true, dbName = 'default', dbType = DatabaseType.DEFAULT, serverUrl = undefined, - } = databaseConnection; + } = configs; try { const databaseName = dbType === DatabaseType.DEFAULT ? 'default' : dbName; @@ -138,7 +166,7 @@ class DatabaseManager { const isServerPresent = await this.isServerPresent(serverUrl); this.activeDatabase = await this.createDatabaseConnection({ - databaseConnection: { + configs: { actionsEnabled: true, dbName: displayName, dbType: DatabaseType.SERVER, @@ -161,14 +189,14 @@ class DatabaseManager { }); 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 {DBInstance} + * @returns {DatabaseInstance} */ - getActiveServerDatabase = (): DBInstance => { + getActiveServerDatabase = (): DatabaseInstance => { return this.activeDatabase; }; @@ -176,7 +204,7 @@ class DatabaseManager { * getDefaultDatabase : Returns the default database. * @returns {Database} default database */ - getDefaultDatabase = async (): Promise => { + getDefaultDatabase = async (): Promise => { if (!this.defaultDatabase) { await this.setDefaultDatabase(); } @@ -188,9 +216,11 @@ class DatabaseManager { * and return them to the caller. * * @param {string[]} serverUrls - * @returns {Promise<{url: string, dbInstance: DBInstance}[] | null>} + * @returns {Promise<{url: string, dbInstance: DatabaseInstance}[] | null>} */ - retrieveDatabaseInstances = async (serverUrls?: string[]): Promise<{ url: string, dbInstance: DBInstance }[] | 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(); @@ -204,16 +234,15 @@ class DatabaseManager { if (servers.length) { const databasePromises = servers.map(async (server: IServers) => { const {displayName, url} = server; - const databaseConnection = { - actionsEnabled: true, - dbName: displayName, - dbType: DatabaseType.SERVER, - serverUrl: url, - }; // Since we are retrieving existing URL ( and so database connections ) from the 'DEFAULT' database, shouldAddToDefaultDatabase is set to false const dbInstance = await this.createDatabaseConnection({ - databaseConnection, + configs: { + actionsEnabled: true, + dbName: displayName, + dbType: DatabaseType.SERVER, + serverUrl: url, + }, shouldAddToDefaultDatabase: false, }); @@ -240,7 +269,10 @@ class DatabaseManager { let server: IServers; if (defaultDB) { - const serversRecords = await defaultDB.collections.get(SERVERS).query(Q.where('url', serverUrl)).fetch() as IServers[]; + const serversRecords = (await defaultDB.collections. + get(SERVERS). + query(Q.where('url', serverUrl)). + fetch()) as IServers[]; server = serversRecords?.[0] ?? undefined; if (server) { @@ -306,17 +338,19 @@ class DatabaseManager { 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[]; + const allServers = + defaultDatabase && + ((await defaultDatabase.collections.get(MM_TABLES.DEFAULT.SERVERS).query().fetch()) as IServers[]); return allServers; }; /** * setDefaultDatabase : Sets the default database. - * @returns {Promise} + * @returns {Promise} */ - private setDefaultDatabase = async (): Promise => { + private setDefaultDatabase = async (): Promise => { this.defaultDatabase = await this.createDatabaseConnection({ - databaseConnection: {dbName: 'default'}, + configs: {dbName: 'default'}, shouldAddToDefaultDatabase: false, }); return this.defaultDatabase; @@ -329,11 +363,7 @@ class DatabaseManager { * @param {string} serverUrl * @returns {Promise} */ - private addServerToDefaultDatabase = async ({ - databaseFilePath, - displayName, - serverUrl, - }: DefaultNewServer) => { + private addServerToDefaultDatabase = async ({databaseFilePath, displayName, serverUrl}: DefaultNewServer) => { try { const defaultDatabase = await this.getDefaultDatabase(); const isServerPresent = await this.isServerPresent(serverUrl); @@ -393,4 +423,10 @@ class DatabaseManager { }; } +if (!__DEV__) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + logger.silence(); +} + export default new DatabaseManager(); diff --git a/app/database/admin/database_manager/test.ts b/app/database/admin/database_manager/test.ts index 22f4345e2b..b5903a9c50 100644 --- a/app/database/admin/database_manager/test.ts +++ b/app/database/admin/database_manager/test.ts @@ -4,11 +4,11 @@ import {Database} from '@nozbe/watermelondb'; import {MM_TABLES} from '@constants/database'; - -import {DBInstance} from '@typings/database/database'; +import {DatabaseInstance} from '@typings/database/database'; +import {DatabaseType} from '@typings/database/enums'; import IServers from '@typings/database/servers'; -import DatabaseManager, {DatabaseType} from './index'; +import DatabaseManager from './index'; jest.mock('./index'); @@ -36,7 +36,7 @@ describe('*** Database Manager tests ***', () => { const connection1 = await DatabaseManager.createDatabaseConnection({ shouldAddToDefaultDatabase: true, - databaseConnection: { + configs: { actionsEnabled: true, dbName: 'community mattermost', dbType: DatabaseType.SERVER, @@ -50,7 +50,7 @@ describe('*** Database Manager tests ***', () => { it('=> should switch between active server connections', async () => { expect.assertions(7); - let activeServer: DBInstance; + let activeServer: DatabaseInstance; let adapter; activeServer = await DatabaseManager.getActiveServerDatabase(); @@ -139,7 +139,7 @@ describe('*** Database Manager tests ***', () => { const serverUrl = 'https://appv3.mattermost.com'; await DatabaseManager.createDatabaseConnection({ shouldAddToDefaultDatabase: true, - databaseConnection: { + configs: { actionsEnabled: true, dbName: 'community mattermost', dbType: DatabaseType.SERVER, @@ -149,7 +149,7 @@ describe('*** Database Manager tests ***', () => { await DatabaseManager.createDatabaseConnection({ shouldAddToDefaultDatabase: true, - databaseConnection: { + configs: { actionsEnabled: true, dbName: 'duplicate server', dbType: DatabaseType.SERVER, diff --git a/app/database/admin/database_manager/test_manual.ts b/app/database/admin/database_manager/test_manual.ts index ae2b5cf583..defce95e56 100644 --- a/app/database/admin/database_manager/test_manual.ts +++ b/app/database/admin/database_manager/test_manual.ts @@ -3,9 +3,10 @@ import {Platform} from 'react-native'; +import {DatabaseType} from '@typings/database/enums'; import {getIOSAppGroupDetails} from '@utils/mattermost_managed'; -import DBManager, {DatabaseType} from './index'; +import DBManager from './index'; export default async () => { // Test: It should return the iOS App-Group shared directory @@ -24,7 +25,7 @@ export default async () => { const testNewServerConnection = async () => { await DBManager.createDatabaseConnection({ shouldAddToDefaultDatabase: true, - databaseConnection: { + configs: { actionsEnabled: true, dbName: 'community mattermost', dbType: DatabaseType.SERVER, diff --git a/app/database/admin/exceptions/database_connection_exception.ts b/app/database/admin/exceptions/database_connection_exception.ts new file mode 100644 index 0000000000..aab0b82ee2 --- /dev/null +++ b/app/database/admin/exceptions/database_connection_exception.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/** + * DatabaseConnectionException: This error can be thrown whenever an issue arises with the Database + */ +class DatabaseConnectionException extends Error { + tableName?: string; + constructor(message: string, tableName?: string) { + super(message); + this.tableName = tableName; + this.name = 'DatabaseConnectionException'; + } +} +export default DatabaseConnectionException; diff --git a/app/database/admin/exceptions/database_operator_exception.ts b/app/database/admin/exceptions/database_operator_exception.ts new file mode 100644 index 0000000000..308d501996 --- /dev/null +++ b/app/database/admin/exceptions/database_operator_exception.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// 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. + */ +class DatabaseOperatorException extends Error { + error : Error | undefined; + constructor(message: string, error?: Error) { + super(message); + this.name = 'DatabaseOperatorException'; + this.error = error; + } +} +export default DatabaseOperatorException; diff --git a/app/database/server/models/post.ts b/app/database/server/models/post.ts index 08953ddaa3..6392af0adb 100644 --- a/app/database/server/models/post.ts +++ b/app/database/server/models/post.ts @@ -57,6 +57,9 @@ export default class Post extends Model { /** delete_at : The timestamp to when this post was last archived/deleted */ @field('delete_at') deleteAt!: number; + /** update_at : The timestamp to when this post was last updated on the server */ + @field('update_at') updateAt!: number; + /** edit_at : The timestamp to when this post was last edited */ @field('edit_at') editAt!: number; @@ -79,19 +82,19 @@ export default class Post extends Model { @field('root_id') rootId!: string; /** type : Type of props (e.g. system message) */ - @field('type') type!: string; + @field('type') type!: PostType; /** user_id : The foreign key of the User who authored this post. */ @field('user_id') userId!: string; /** props : Additional attributes for this props */ - @json('props', (rawJson) => rawJson) props!: string; + @json('props', (rawJson) => rawJson) props!: object; // A draft can be associated with this post for as long as this post is a parent post - @lazy draft = this.collections.get(DRAFT).query(Q.on(POST, 'id', this.id)) as Query + @lazy draft = this.collections.get(DRAFT).query(Q.on(POST, 'id', this.id)) as Query; /** postsInThread: The thread to which this post is associated */ - @lazy postsInThread = this.collections.get(POSTS_IN_THREAD).query(Q.on(POST, 'id', this.id)) as Query + @lazy postsInThread = this.collections.get(POSTS_IN_THREAD).query(Q.on(POST, 'id', this.id)) as Query; /** files: All the files associated with this Post */ @children(FILE) files!: File[]; diff --git a/app/database/server/models/post_metadata.ts b/app/database/server/models/post_metadata.ts index 1ddf605896..15e8014cc0 100644 --- a/app/database/server/models/post_metadata.ts +++ b/app/database/server/models/post_metadata.ts @@ -6,6 +6,7 @@ import {field, immutableRelation, json} from '@nozbe/watermelondb/decorators'; import Model, {Associations} from '@nozbe/watermelondb/Model'; import {MM_TABLES} from '@constants/database'; +import {PostMetadataData, PostMetadataType} from '@typings/database/database'; import Post from '@typings/database/post'; const {POST, POST_METADATA} = MM_TABLES.SERVER; @@ -28,10 +29,10 @@ export default class PostMetadata extends Model { @field('post_id') postId!: string; /** type : The type will work in tandem with the value present in the field 'data'. One 'type' for each kind of 'data' */ - @field('type') type!: PostType; + @field('type') type!: PostMetadataType; - /** data : Different types of data ranging from arrays, emojis, files to images and reactions. */ - @json('data', (rawJson) => rawJson) data!: PostMetadataTypes; + /** data : Different types of data ranging from embeds to images. */ + @json('data', (rawJson) => rawJson) data!: PostMetadataData; /** post: The record representing the POST parent. */ @immutableRelation(POST, 'post_id') post!: Relation; diff --git a/app/database/server/schema/table_schemas/post.ts b/app/database/server/schema/table_schemas/post.ts index ac969b5f9c..ed655beb87 100644 --- a/app/database/server/schema/table_schemas/post.ts +++ b/app/database/server/schema/table_schemas/post.ts @@ -13,6 +13,7 @@ export default tableSchema({ {name: 'channel_id', type: 'string', isIndexed: true}, {name: 'create_at', type: 'number'}, {name: 'delete_at', type: 'number'}, + {name: 'update_at', type: 'number'}, {name: 'edit_at', type: 'number'}, {name: 'is_pinned', type: 'boolean'}, {name: 'message', type: 'string'}, diff --git a/app/database/server/schema/test.ts b/app/database/server/schema/test.ts index 77a5cbf23d..96a3e01979 100644 --- a/app/database/server/schema/test.ts +++ b/app/database/server/schema/test.ts @@ -219,6 +219,7 @@ describe('*** Test schema for SERVER database ***', () => { channel_id: {name: 'channel_id', type: 'string', isIndexed: true}, create_at: {name: 'create_at', type: 'number'}, delete_at: {name: 'delete_at', type: 'number'}, + update_at: {name: 'update_at', type: 'number'}, edit_at: {name: 'edit_at', type: 'number'}, is_pinned: {name: 'is_pinned', type: 'boolean'}, message: {name: 'message', type: 'string'}, @@ -234,6 +235,7 @@ describe('*** Test schema for SERVER database ***', () => { {name: 'channel_id', type: 'string', isIndexed: true}, {name: 'create_at', type: 'number'}, {name: 'delete_at', type: 'number'}, + {name: 'update_at', type: 'number'}, {name: 'edit_at', type: 'number'}, {name: 'is_pinned', type: 'boolean'}, {name: 'message', type: 'string'}, diff --git a/app/i18n/index.ts b/app/i18n/index.ts index b08699b443..125ed5a866 100644 --- a/app/i18n/index.ts +++ b/app/i18n/index.ts @@ -13,69 +13,69 @@ function loadTranslation(locale: string) { let translations; let momentData; switch (locale) { - case 'de': - translations = require('@assets/i18n/de.json'); - momentData = require('moment/locale/de'); - break; - case 'es': - translations = require('@assets/i18n/es.json'); - momentData = require('moment/locale/es'); - break; - case 'fr': - translations = require('@assets/i18n/fr.json'); - momentData = require('moment/locale/fr'); - break; - case 'it': - translations = require('@assets/i18n/it.json'); - momentData = require('moment/locale/it'); - break; - case 'ja': - translations = require('@assets/i18n/ja.json'); - momentData = require('moment/locale/ja'); - break; - case 'ko': - translations = require('@assets/i18n/ko.json'); - momentData = require('moment/locale/ko'); - break; - case 'nl': - translations = require('@assets/i18n/nl.json'); - momentData = require('moment/locale/nl'); - break; - case 'pl': - translations = require('@assets/i18n/pl.json'); - momentData = require('moment/locale/pl'); - break; - case 'pt-BR': - translations = require('@assets/i18n/pt-BR.json'); - momentData = require('moment/locale/pt-br'); - break; - case 'ro': - translations = require('@assets/i18n/ro.json'); - momentData = require('moment/locale/ro'); - break; - case 'ru': - translations = require('@assets/i18n/ru.json'); - momentData = require('moment/locale/ru'); - break; - case 'tr': - translations = require('@assets/i18n/tr.json'); - momentData = require('moment/locale/tr'); - break; - case 'uk': - translations = require('@assets/i18n/uk.json'); - momentData = require('moment/locale/uk'); - break; - case 'zh-CN': - translations = require('@assets/i18n/zh-CN.json'); - momentData = require('moment/locale/zh-cn'); - break; - case 'zh-TW': - translations = require('@assets/i18n/zh-TW.json'); - momentData = require('moment/locale/zh-tw'); - break; - default: - translations = en; - break; + case 'de': + translations = require('@assets/i18n/de.json'); + momentData = require('moment/locale/de'); + break; + case 'es': + translations = require('@assets/i18n/es.json'); + momentData = require('moment/locale/es'); + break; + case 'fr': + translations = require('@assets/i18n/fr.json'); + momentData = require('moment/locale/fr'); + break; + case 'it': + translations = require('@assets/i18n/it.json'); + momentData = require('moment/locale/it'); + break; + case 'ja': + translations = require('@assets/i18n/ja.json'); + momentData = require('moment/locale/ja'); + break; + case 'ko': + translations = require('@assets/i18n/ko.json'); + momentData = require('moment/locale/ko'); + break; + case 'nl': + translations = require('@assets/i18n/nl.json'); + momentData = require('moment/locale/nl'); + break; + case 'pl': + translations = require('@assets/i18n/pl.json'); + momentData = require('moment/locale/pl'); + break; + case 'pt-BR': + translations = require('@assets/i18n/pt-BR.json'); + momentData = require('moment/locale/pt-br'); + break; + case 'ro': + translations = require('@assets/i18n/ro.json'); + momentData = require('moment/locale/ro'); + break; + case 'ru': + translations = require('@assets/i18n/ru.json'); + momentData = require('moment/locale/ru'); + break; + case 'tr': + translations = require('@assets/i18n/tr.json'); + momentData = require('moment/locale/tr'); + break; + case 'uk': + translations = require('@assets/i18n/uk.json'); + momentData = require('moment/locale/uk'); + break; + case 'zh-CN': + translations = require('@assets/i18n/zh-CN.json'); + momentData = require('moment/locale/zh-cn'); + break; + case 'zh-TW': + translations = require('@assets/i18n/zh-TW.json'); + momentData = require('moment/locale/zh-tw'); + break; + default: + translations = en; + break; } if (momentData) { diff --git a/app/init/global_event_handler.ts b/app/init/global_event_handler.ts index aec979d66f..d80c65c07c 100644 --- a/app/init/global_event_handler.ts +++ b/app/init/global_event_handler.ts @@ -81,38 +81,38 @@ class GlobalEventHandler { // TODO: Only execute this if there are no more servers switch (Platform.OS) { - case 'ios': { - const mainPath = FileSystem.documentDirectory?.split('/').slice(0, -1).join('/'); - const libraryDir = `${mainPath}/Library`; - const cookiesDir = `${libraryDir}/Cookies`; - const cookies = await FileSystem.getInfoAsync(cookiesDir); - const webkitDir = `${libraryDir}/WebKit`; - const webkit = await FileSystem.getInfoAsync(webkitDir); + case 'ios': { + const mainPath = FileSystem.documentDirectory?.split('/').slice(0, -1).join('/'); + const libraryDir = `${mainPath}/Library`; + const cookiesDir = `${libraryDir}/Cookies`; + const cookies = await FileSystem.getInfoAsync(cookiesDir); + const webkitDir = `${libraryDir}/WebKit`; + const webkit = await FileSystem.getInfoAsync(webkitDir); - if (cookies.exists) { - FileSystem.deleteAsync(cookiesDir); + if (cookies.exists) { + FileSystem.deleteAsync(cookiesDir); + } + + if (webkit.exists) { + FileSystem.deleteAsync(webkitDir); + } + break; } - if (webkit.exists) { - FileSystem.deleteAsync(webkitDir); - } - break; - } + case 'android': { + const cacheDir = FileSystem.cacheDirectory; + const mainPath = cacheDir?.split('/').slice(0, -1).join('/'); + const cookies = await FileSystem.getInfoAsync(`${mainPath}/app_webview/Cookies`); + const cookiesJ = await FileSystem.getInfoAsync(`${mainPath}/app_webview/Cookies-journal`); + if (cookies.exists) { + FileSystem.deleteAsync(`${mainPath}/app_webview/Cookies`); + } - case 'android': { - const cacheDir = FileSystem.cacheDirectory; - const mainPath = cacheDir?.split('/').slice(0, -1).join('/'); - const cookies = await FileSystem.getInfoAsync(`${mainPath}/app_webview/Cookies`); - const cookiesJ = await FileSystem.getInfoAsync(`${mainPath}/app_webview/Cookies-journal`); - if (cookies.exists) { - FileSystem.deleteAsync(`${mainPath}/app_webview/Cookies`); + if (cookiesJ.exists) { + FileSystem.deleteAsync(`${mainPath}/app_webview/Cookies-journal`); + } + break; } - - if (cookiesJ.exists) { - FileSystem.deleteAsync(`${mainPath}/app_webview/Cookies-journal`); - } - break; - } } }; diff --git a/app/init/push_notifications.ts b/app/init/push_notifications.ts index e06c5e3292..055c17d38d 100644 --- a/app/init/push_notifications.ts +++ b/app/init/push_notifications.ts @@ -111,33 +111,33 @@ class PushNotifications { if (payload) { switch (payload.type) { - case NOTIFICATION_TYPE.CLEAR: + case NOTIFICATION_TYPE.CLEAR: // Mark the channel as read - break; - case NOTIFICATION_TYPE.MESSAGE: + break; + case NOTIFICATION_TYPE.MESSAGE: // fetch the posts for the channel - if (foreground) { + if (foreground) { // Show the in-app notification - } else if (userInteraction && !payload.userInfo?.local) { + } else if (userInteraction && !payload.userInfo?.local) { // Swith to the server / team / channel that matches the notification - const componentId = EphemeralStore.getNavigationTopComponentId(); - if (componentId) { + const componentId = EphemeralStore.getNavigationTopComponentId(); + if (componentId) { // Emit events to close the sidebars - await dismissAllModals(); - await popToRoot(); + await dismissAllModals(); + await popToRoot(); + } } - } - break; - case NOTIFICATION_TYPE.SESSION: + break; + case NOTIFICATION_TYPE.SESSION: // eslint-disable-next-line no-console - console.log('Session expired notification'); + console.log('Session expired notification'); - // Logout the user from the server that matches the notification + // Logout the user from the server that matches the notification - break; + break; } } }; diff --git a/app/mattermost.ts b/app/mattermost.ts index 4b9e7272ae..93af8b9322 100644 --- a/app/mattermost.ts +++ b/app/mattermost.ts @@ -77,13 +77,13 @@ export function componentDidAppearListener({componentId}: ComponentDidAppearEven EphemeralStore.addNavigationComponentId(componentId); switch (componentId) { - case 'MainSidebar': - DeviceEventEmitter.emit(NavigationConstants.MAIN_SIDEBAR_DID_OPEN, this.handleSidebarDidOpen); - DeviceEventEmitter.emit(NavigationConstants.BLUR_POST_DRAFT); - break; - case 'SettingsSidebar': - DeviceEventEmitter.emit(NavigationConstants.BLUR_POST_DRAFT); - break; + case 'MainSidebar': + DeviceEventEmitter.emit(NavigationConstants.MAIN_SIDEBAR_DID_OPEN, this.handleSidebarDidOpen); + DeviceEventEmitter.emit(NavigationConstants.BLUR_POST_DRAFT); + break; + case 'SettingsSidebar': + DeviceEventEmitter.emit(NavigationConstants.BLUR_POST_DRAFT); + break; } } diff --git a/app/notifications/index.ios.ts b/app/notifications/index.ios.ts index e4afb501df..0f42c3bedd 100644 --- a/app/notifications/index.ios.ts +++ b/app/notifications/index.ios.ts @@ -1,11 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -/* eslint-disable @typescript-eslint/no-empty-function */ -/* eslint-disable @typescript-eslint/no-unused-vars */ - import {Notifications} from 'react-native-notifications'; +/* eslint-disable */ export default { getDeliveredNotifications: Notifications.ios.getDeliveredNotifications, getPreferences: async () => null, diff --git a/app/screens/index.ts b/app/screens/index.ts index 1714d8d267..986fd593f9 100644 --- a/app/screens/index.ts +++ b/app/screens/index.ts @@ -11,7 +11,7 @@ import {gestureHandlerRootHOC} from 'react-native-gesture-handler'; import {Screens} from '@constants'; // TODO: Remove this and uncomment screens as they get added -/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable */ const withGestures = (screen: React.ComponentType, styles: StyleProp) => { if (Platform.OS === 'android') { diff --git a/babel.config.js b/babel.config.js index 154070e940..873d8dcfb8 100644 --- a/babel.config.js +++ b/babel.config.js @@ -23,6 +23,7 @@ module.exports = { '@assets': './dist/assets', '@components': './app/components', '@constants': './app/constants', + '@database': './app/database', '@i18n': './app/i18n', '@init': './app/init', '@notifications': './app/notifications', diff --git a/package-lock.json b/package-lock.json index 71b3e309ab..e6ace3ed96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6319,6 +6319,13 @@ "rambdax": "2.15.0", "rxjs": "^6.5.3", "sql-escape-string": "^1.1.0" + }, + "dependencies": { + "lokijs": { + "version": "npm:@nozbe/lokijs@1.5.10-wmelon3", + "resolved": "https://registry.npmjs.org/@nozbe/lokijs/-/lokijs-1.5.10-wmelon3.tgz", + "integrity": "sha512-yfuj/SzYiVVn0e3OP8vjcbekumUR62Df90deG8uH7+5nqJqTLe4HkEzlmwJfss9UE0K8PsTQLACFOUq/2aAJ2A==" + } } }, "@nozbe/with-observables": { @@ -6748,11 +6755,29 @@ "eslint-visitor-keys": "^1.1.0" } }, + "eslint-config-prettier": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.15.0.tgz", + "integrity": "sha512-a1+kOYLR8wMGustcgAjdydMsQ2A/2ipRPwRKUmfYaSxc9ZPcrku080Ctl6zrZzZNs/U82MjSv+qKREkoq3bJaw==", + "dev": true, + "requires": { + "get-stdin": "^6.0.0" + } + }, "eslint-plugin-jest": { "version": "22.4.1", "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-22.4.1.tgz", "integrity": "sha512-gcLfn6P2PrFAVx3AobaOzlIEevpAEf9chTpFZz7bYfc7pz8XRv7vuKTIE4hxPKZSha6XWKKplDQ0x9Pq8xX2mg==", "dev": true + }, + "eslint-plugin-prettier": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.2.tgz", + "integrity": "sha512-GlolCC9y3XZfv3RQfwGew7NnuFDKsfI4lbvRK+PIIo23SFH+LemGs4cKwzAaRa+Mdb+lQO/STaIayno8T5sJJA==", + "dev": true, + "requires": { + "prettier-linter-helpers": "^1.0.0" + } } } }, @@ -14894,15 +14919,6 @@ } } }, - "eslint-config-prettier": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.11.0.tgz", - "integrity": "sha512-oB8cpLWSAjOVFEJhhyMZh6NOEOtBVziaqdDQ86+qhDHFbZXoRTM7pNSvFRfW/W/L/LrQ38C99J5CGuRBBzBsdA==", - "dev": true, - "requires": { - "get-stdin": "^6.0.0" - } - }, "eslint-plugin-eslint-comments": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-3.2.0.tgz", @@ -15010,15 +15026,6 @@ "from": "github:mattermost/eslint-plugin-mattermost#070ce792d105482ffb2b27cfc0b7e78b3d20acee", "dev": true }, - "eslint-plugin-prettier": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.2.tgz", - "integrity": "sha512-GlolCC9y3XZfv3RQfwGew7NnuFDKsfI4lbvRK+PIIo23SFH+LemGs4cKwzAaRa+Mdb+lQO/STaIayno8T5sJJA==", - "dev": true, - "requires": { - "prettier-linter-helpers": "^1.0.0" - } - }, "eslint-plugin-react": { "version": "7.21.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.21.5.tgz", @@ -23097,11 +23104,6 @@ } } }, - "lokijs": { - "version": "npm:@nozbe/lokijs@1.5.10-wmelon3", - "resolved": "https://registry.npmjs.org/@nozbe/lokijs/-/lokijs-1.5.10-wmelon3.tgz", - "integrity": "sha512-yfuj/SzYiVVn0e3OP8vjcbekumUR62Df90deG8uH7+5nqJqTLe4HkEzlmwJfss9UE0K8PsTQLACFOUq/2aAJ2A==" - }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -25436,9 +25438,9 @@ "dev": true }, "prettier": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.0.5.tgz", - "integrity": "sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.2.1.tgz", + "integrity": "sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==", "dev": true }, "prettier-linter-helpers": { diff --git a/tsconfig.json b/tsconfig.json index 8266c1fb73..d8baf695de 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,6 +37,7 @@ "@components/*": ["app/components/*"], "@constants": ["app/constants/index"], "@constants/*": ["app/constants/*"], + "@database/*": ["app/database/*"], "@i18n": ["app/i18n/index"], "@init/*": ["app/init/*"], "@notifications": ["app/notifications/index"], diff --git a/types/database/database.d.ts b/types/database/database.d.ts index 8c39bfb768..abf69e1ba8 100644 --- a/types/database/database.d.ts +++ b/types/database/database.d.ts @@ -6,113 +6,336 @@ import Model from '@nozbe/watermelondb/Model'; import {Migration} from '@nozbe/watermelondb/Schema/migrations'; import {Class} from '@nozbe/watermelondb/utils/common'; -import {IsolatedEntities, OperationType} from '../../app/database/admin/data_operator'; -import {DatabaseType} from '../../app/database/admin/database_manager'; +import {DatabaseType, IsolatedEntities} from './enums'; export type MigrationEvents = { - onSuccess: () => void, - onStarted: () => void, - onFailure: (error: string) => void, -} + onSuccess: () => void; + onStarted: () => void; + onFailure: (error: string) => void; +}; export type MMAdaptorOptions = { - dbPath: string, - schema: AppSchema, - migrationSteps?: Migration [], - migrationEvents?: MigrationEvents -} + dbPath: string; + schema: AppSchema; + migrationSteps?: Migration[]; + migrationEvents?: MigrationEvents; +}; -export type MMDatabaseConnection = { - actionsEnabled?: boolean, - dbName: string, - dbType?: DatabaseType.DEFAULT | DatabaseType.SERVER, - serverUrl?: string, -} +export type DatabaseConfigs = { + actionsEnabled?: boolean; + dbName: string; + dbType?: DatabaseType.DEFAULT | DatabaseType.SERVER; + serverUrl?: string; +}; export type DefaultNewServer = { - databaseFilePath: string, - displayName: string, - serverUrl: string -} + databaseFilePath: string; + displayName: string; + serverUrl: string; +}; // A database connection is of type 'Database'; unless it fails to be initialize and in which case it becomes 'undefined' -export type DBInstance = Database | undefined +export type DatabaseInstance = Database | undefined; export type RawApp = { - buildNumber: string, - createdAt: number, - id: string, - versionNumber: string, -} + buildNumber: string; + createdAt: number; + id: string; + versionNumber: string; +}; export type RawGlobal = { - id: string, - name: string, - value: string, -} + id: string; + name: string; + value: string; +}; export type RawServers = { - dbPath: string, - displayName: string, - id: string, - mentionCount: number, - unreadCount: number, - url: string -} + dbPath: string; + displayName: string; + id: string; + mentionCount: number; + unreadCount: number; + url: string; +}; export type RawCustomEmoji = { - id: string, - name: string -} + id?: string; + name: string; + create_at?: number; + update_at?: number; + delete_at?: number; + creator_id?: string; +}; export type RawRole = { - id: string, - name: string, - permissions: [] -} + id: string; + name: string; + permissions: []; +}; export type RawSystem = { - id: string, - name: string, - value: string -} + id: string; + name: string; + value: string; +}; export type RawTermsOfService = { - id: string, - acceptedAt: number + id: string; + acceptedAt: number; +}; + +export type RawDraft = { + id?: string; + channel_id: string; + files?: FileInfo[]; + message?: string; + root_id?: string; +}; + +export type RawEmbed = { data: {}; type: string; url: string }; + +export type RawPostMetadata = { + data: any; + type: string; + postId: string; + id?: string; +}; + +interface PostMetadataTypes { + embeds: PostEmbed; + images: Dictionary; } -export type RecordValue = RawApp | RawGlobal | RawServers | RawCustomEmoji | RawRole | RawSystem | RawTermsOfService +export type RawFile = { + create_at: number; + delete_at: number; + extension: string; + has_preview_image?: boolean; + height: number; + id?: string; + localPath?: string; + mime_type?: string; + mini_preview?: string; // thumbnail + name: string; + post_id: string; + size: number; + update_at: number; + user_id: string; + width?: number; +}; -export type DataFactory = { - db: Database, - generator?: (model: Model) => void, - optType?: OperationType, - tableName?: string, - value: RecordValue, +export type RawReaction = { + id?: string; + create_at: number; + delete_at: number; + emoji_name: string; + post_id: string; + update_at: number; + user_id: string; +}; + +export type RawPostsInChannel = { + id?: string; + channel_id: string; + earliest: number; + latest: number; +}; + +interface PostEmbed { + type: PostEmbedType; + url: string; + data: Record; } -export type Records = RecordValue | RecordValue[] - -export type HandleBaseData = { - optType: OperationType, - tableName: string, - values: Records, - recordOperator: (recordOperator: DataFactory) => void +interface PostImage { + height: number; + width: number; + format?: string; + frame_count?: number; } -export type BatchOperations = { db: Database, models: Model[] } +interface PostImageMetadata extends PostImage { + url: string; +} -export type HandleIsolatedEntityData = { optType: OperationType, tableName: IsolatedEntities, values: Records } +export type PostMetadataData = Record | PostImageMetadata; -export type Models = Class[] +export type PostMetadataType = 'images' | 'embeds'; + +// The RawPost describes the shape of the object received from a getPosts request +export type RawPost = { + channel_id: string; + create_at: number; + delete_at: number; + edit_at: number; + file_ids?: string[]; + filenames?: string[]; + hashtags: string; + id: string; + is_pinned?: boolean; + last_reply_at?: number; + message: string; + original_id: string; + parent_id: string; + participants?: null; + pending_post_id: string; + prev_post_id?: string; // taken from getPosts API call; outside of post object + props: object; + reply_count?: number; + root_id: string; + type: string; + update_at: number; + user_id: string; + metadata?: { + embeds?: RawEmbed[]; + emojis?: RawCustomEmoji[]; + files?: RawFile[]; + images?: Dictionary; + reactions?: RawReaction[]; + }; +}; + +export type RawChannelMembers = { + channel_id: string; + explicit_roles: string; + last_update_at: number; + last_viewed_at: number; + mention_count: number; + msg_count: number; + notify_props: NotifyProps; + roles: string; + scheme_admin: boolean; + scheme_guest: boolean; + scheme_user: boolean; + user_id: string; +}; + +export type ChannelType = 'D' | 'O' | 'G' | 'P'; + +export type RawChannel = { + create_at: number; + creator_id: string; + delete_at: number; + display_name: string; + extra_update_at: number; + group_constrained: boolean | null; + header: string; + id: string; + last_post_at: number; + name: string; + props: null; + purpose: string; + scheme_id: null; + shared: null; + team_id: string; + total_msg_count: number; + type: ChannelType; + update_at: number; +}; + +export type RawPostsInThread = { + id?: string; + earliest: number; + latest?: number; + post_id: string; +}; + +export type RecordValue = + | RawApp + | RawCustomEmoji + | RawDraft + | RawFile + | RawGlobal + | RawPost + | RawPostMetadata + | RawPostsInChannel + | RawPostsInThread + | RawReaction + | RawRole + | RawServers + | RawSystem + | RawTermsOfService; + +export type Operator = { + database: Database; + value: RecordValue; +}; + +export type RecordOperator = (operator: Operator) => Promise; + +export type BaseOperator = Operator & { + generator: (model: Model) => void; + tableName: string; +}; + +export type ExecuteRecords = { + tableName: string; + values: RecordValue[]; + recordOperator: RecordOperator; +}; + +export type PrepareRecords = ExecuteRecords & { database: Database }; + +export type BatchOperations = { database: Database; models: Model[] }; + +export type HandleIsolatedEntityData = { + tableName: IsolatedEntities; + values: RecordValue[]; +}; + +export type Models = Class[]; // The elements needed to create a new connection export type DatabaseConnection = { - databaseConnection: MMDatabaseConnection, - shouldAddToDefaultDatabase: boolean -} + configs: DatabaseConfigs; + shouldAddToDefaultDatabase: boolean; +}; // The elements required to switch to another active server database -export type ActiveServerDatabase = { displayName: string, serverUrl: string } +export type ActiveServerDatabase = { displayName: string; serverUrl: string }; +export type HandleReactions = { + reactions: RawReaction[]; + prepareRowsOnly: boolean; +}; + +export type HandleFiles = { + files: RawFile[]; + prepareRowsOnly: boolean; +}; + +export type HandlePostMetadata = { + embeds?: { embed: RawEmbed[]; postId: string }[]; + images?: { images: Dictionary; postId: string }[]; + prepareRowsOnly: boolean; +}; + +export type HandlePosts = { + orders: string[]; + values: RawPost[]; + previousPostId?: string; +}; + +export type SanitizeReactions = { + database: Database; + post_id: string; + rawReactions: RawReaction[]; +}; + +export type ChainPosts = { + orders: string[]; + rawPosts: RawPost[]; + previousPostId: string; +}; + +export type SanitizePosts = { + posts: RawPost[]; + orders: string[]; +}; + +export type IdenticalRecord = { + existingRecord: Model; + newValue: RecordValue; + tableName: string; +}; diff --git a/types/database/enums.ts b/types/database/enums.ts new file mode 100644 index 0000000000..012d345ec0 --- /dev/null +++ b/types/database/enums.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export enum OperationType { + 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', +} + +// The only two types of databases in the app +export enum DatabaseType { + DEFAULT, + SERVER, +} diff --git a/types/database/index.d.ts b/types/database/index.d.ts index 46d158e346..e65a1a99f3 100644 --- a/types/database/index.d.ts +++ b/types/database/index.d.ts @@ -6,39 +6,23 @@ interface NotifyProps { desktop: string; desktop_sound: true; email: true; - first_name: true + first_name: true; mention_keys: string; push: string; } interface UserProps { - [userPropsName: string]: any + [userPropsName: string]: any; } interface Timezone { - automaticTimezone: string - manualTimezone: string, - useAutomaticTimezone: true, -} - -interface PostEmbed { - type: PostEmbedType; - url: string; - data: Record; -} - -interface CustomEmoji { - id: string; - create_at: number; - update_at: number; - delete_at: number; - creator_id: string; - name: string; - category: 'custom'; + automaticTimezone: string; + manualTimezone: string; + useAutomaticTimezone: true; } interface FileInfo { - id: string; + id?: string; user_id: string; post_id: string; create_at: number; @@ -59,40 +43,18 @@ interface FileInfo { type PostEmbedType = 'image' | 'message_attachment' | 'opengraph'; -interface PostImage { - height: number; - width: number; - format?: string; - frame_count?: number; -} - -interface Reaction { - user_id: string; - post_id: string; - emoji_name: string; - create_at: number; -} - -interface PostMetadataTypes { - embeds: Array; - emojis: Array; - files: Array; - images: Dictionary; - reactions: Array; -} - -type PostType = 'system_add_remove' | - 'system_add_to_channel' | - 'system_add_to_team' | - 'system_channel_deleted' | - 'system_channel_restored' | - 'system_displayname_change' | - 'system_convert_channel' | - 'system_ephemeral' | - 'system_header_change' | - 'system_join_channel' | - 'system_join_leave' | - 'system_leave_channel' | - 'system_purpose_change' | - 'system_remove_from_channel'; - +type PostType = + | 'system_add_remove' + | 'system_add_to_channel' + | 'system_add_to_team' + | 'system_channel_deleted' + | 'system_channel_restored' + | 'system_displayname_change' + | 'system_convert_channel' + | 'system_ephemeral' + | 'system_header_change' + | 'system_join_channel' + | 'system_join_leave' + | 'system_leave_channel' + | 'system_purpose_change' + | 'system_remove_from_channel'; diff --git a/types/database/post.d.ts b/types/database/post.d.ts index 466d67e107..738c1b652a 100644 --- a/types/database/post.d.ts +++ b/types/database/post.d.ts @@ -31,6 +31,9 @@ export default class Post extends Model { /** delete_at : The timestamp to when this post was last archived/deleted */ deleteAt: number; + /** update_at : The timestamp to when this post was last updated on the server */ + updateAt!: number; + /** edit_at : The timestamp to when this post was last edited */ editAt: number; @@ -59,7 +62,7 @@ export default class Post extends Model { userId: string; /** props : Additional attributes for this props */ - props: string; + props: object; /** drafts : Every drafts associated with this Post */ drafts: Draft; diff --git a/types/database/post_metadata.d.ts b/types/database/post_metadata.d.ts index f69dd2fc22..116a53e800 100644 --- a/types/database/post_metadata.d.ts +++ b/types/database/post_metadata.d.ts @@ -3,6 +3,7 @@ import {Relation} from '@nozbe/watermelondb'; import Model, {Associations} from '@nozbe/watermelondb/Model'; +import {PostMetadataTypes} from '@typings/database/database'; import Post from '@typings/database/post'; @@ -23,7 +24,7 @@ export default class PostMetadata extends Model { type: string; /** data : Different types of data ranging from arrays, emojis, files to images and reactions. */ - data: string; + data: PostMetadataTypes; /** post: The record representing the POST parent. */ post: Relation;