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 a505bd5e11.

* 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 <nahumhbl@gmail.com>

* 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 <joseph.baylon@mattermost.com>

* Update app/database/admin/data_operator/operators.ts

Co-authored-by: Joseph Baylon <joseph.baylon@mattermost.com>

* Update app/database/admin/data_operator/operators.ts

Co-authored-by: Joseph Baylon <joseph.baylon@mattermost.com>

* 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 <migbot@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>

* 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 <nahumhbl@gmail.com>
Co-authored-by: Avinash Lingaloo <>
Co-authored-by: Joseph Baylon <joseph.baylon@mattermost.com>
Co-authored-by: Miguel Alatzar <migbot@users.noreply.github.com>
This commit is contained in:
Avinash Lingaloo
2021-03-26 19:23:32 +04:00
committed by GitHub
parent 62e1003f10
commit 040bf22264
37 changed files with 4009 additions and 1285 deletions

View File

@@ -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": [
{

View File

@@ -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<void>}
*/
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<void>}
*/
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<void>}
*/
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<PostImage>; 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<File[] | any[]>}
*/
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<PostImage>, postId: string}[] | undefined} handlePostMetadata.images
* @param {boolean} handlePostMetadata.prepareRowsOnly
* @returns {Promise<any[] | PostMetadata[]>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<Model[]>}
*/
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<void>}
*/
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<Database>}
*/
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<Database>}
*/
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<Database>}
*/
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;

View File

@@ -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));
});
});

View File

@@ -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<void>}
*/
handleIsolatedEntityData = async ({optType, tableName, values}: HandleIsolatedEntityData): Promise<void> => {
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<void>}
*/
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<void>}
*/
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<DBInstance>}
*/
private getDatabase = async (tableName: string): Promise<DBInstance> => {
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<DBInstance>}
*/
private getDefaultDatabase = async () => {
this.defaultDatabase = await DatabaseManager.getDefaultDatabase();
return this.defaultDatabase;
};
/**
* getServerDatabase: Returns the current active server database (multi-server support)
* @returns {Promise<DBInstance>}
*/
private getServerDatabase = async () => {
this.serverDatabase = await DatabaseManager.getActiveServerDatabase();
return this.serverDatabase;
};
}
import DataOperator from '@database/admin/data_operator/handlers';
export default new DataOperator();

View File

@@ -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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<any>}
*/
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;
};

View File

@@ -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<Model>}
*/
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<Model>}
*/
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<Model>}
*/
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<Model>}
*/
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<Model>}
*/
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<Model>}
*/
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<Model>}
*/
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<Model>}
*/
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<Model>}
*/
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<Model>}
*/
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<Model>}
*/
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<Model>}
*/
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<Model>}
*/
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<Model>}
*/
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<Model>}
*/
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;
};

View File

@@ -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');
});
});

View File

@@ -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);
});
});

View File

@@ -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};
};

View File

@@ -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,
},
];

View File

@@ -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);
});
});

View File

@@ -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}`,
);
};

View File

@@ -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));
});
});

View File

@@ -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<Model>[]
// 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<DBInstance>}
* @returns {Promise<DatabaseInstance>}
*/
createDatabaseConnection = async ({
databaseConnection,
configs,
shouldAddToDefaultDatabase = true,
}: DatabaseConnection): Promise<DBInstance> => {
}: DatabaseConnection): Promise<DatabaseInstance> => {
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<DBInstance> => {
getDefaultDatabase = async (): Promise<DatabaseInstance> => {
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<DBInstance>}
* @returns {Promise<DatabaseInstance>}
*/
private setDefaultDatabase = async (): Promise<DBInstance> => {
private setDefaultDatabase = async (): Promise<DatabaseInstance> => {
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<void>}
*/
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);

View File

@@ -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<DBInstance>}
* @returns {Promise<DatabaseInstance>}
*/
createDatabaseConnection = async ({
databaseConnection,
configs,
shouldAddToDefaultDatabase = true,
}: DatabaseConnection): Promise<DBInstance> => {
}: DatabaseConnection): Promise<DatabaseInstance> => {
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<DBInstance> => {
getDefaultDatabase = async (): Promise<DatabaseInstance> => {
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<DBInstance>}
* @returns {Promise<DatabaseInstance>}
*/
private setDefaultDatabase = async (): Promise<DBInstance> => {
private setDefaultDatabase = async (): Promise<DatabaseInstance> => {
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<void>}
*/
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();

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<Draft>
@lazy draft = this.collections.get(DRAFT).query(Q.on(POST, 'id', this.id)) as Query<Draft>;
/** 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<PostInThread>
@lazy postsInThread = this.collections.get(POSTS_IN_THREAD).query(Q.on(POST, 'id', this.id)) as Query<PostInThread>;
/** files: All the files associated with this Post */
@children(FILE) files!: File[];

View File

@@ -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<Post>;

View File

@@ -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'},

View File

@@ -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'},

View File

@@ -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) {

View File

@@ -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;
}
}
};

View File

@@ -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;
}
}
};

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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<any>, styles: StyleProp<ViewStyle>) => {
if (Platform.OS === 'android') {

View File

@@ -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',

54
package-lock.json generated
View File

@@ -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": {

View File

@@ -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"],

View File

@@ -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<PostImage>;
}
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<string, any>;
}
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<string, any> | PostImageMetadata;
export type Models = Class<Model>[]
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<PostImage>;
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<Model | null>;
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<Model>[];
// 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<PostImage>; 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;
};

24
types/database/enums.ts Normal file
View File

@@ -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,
}

View File

@@ -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<string, any>;
}
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<PostEmbed>;
emojis: Array<CustomEmoji>;
files: Array<FileInfo>;
images: Dictionary<PostImage>;
reactions: Array<Reaction>;
}
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';

View File

@@ -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;

View File

@@ -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<Post>;