mattermost-mobile/app/database/admin/data_operator/handlers/index.ts
Avinash Lingaloo 040bf22264
MM_33223 [v2] Database Operator - Post section (#5227)
* MM_30475 : ADDED default schema

* MM_30475 : ADDED todo for field 'value' of default/Global entity

* MM_30476 : Created schema for SERVER DB

* MM_30476 : Server model [ IN PROGRESS ]

* MM_30476 : Including types for group, groups_in_channel and role

* MM_30476 : ADDED models for Group

- @typings absolute path has been added to the tsconfig.json

* MM_30476 : ADDED typings to current models

* MM_30476 : ADDED typings to current models

* MM_30476 : ADDED models related to TEAM section of the ERD

* MM_30476 : ADDED models for User section of the ERD

* MM_30476 : ADDED models for POST section of the ERD

* MM_30476 : ADDED models for Channel section of the ERD

* MM_30475 : Updated typings and references to MM_TABLES

* MM_30476 : Verified all field names

* MM_30476 : Verified every table associations

* MM_30476 : Verified all relation fields

* MM_30476 : Updated primary id of the main models

We will override the wdb id at component level when we create a new records.  This involves the models : channel, group, post, team and user.

* MM_30476 : Including 1:1 relationship amongs some entities

* MM_30476 : ADDED Schema Managers

* The migration array will hold all the migration steps.

*  The initial app release (e.g. v2 )will have an empty array and subsequent releases  (e.g.  v2.1 ) will have the steps listed in that array.

* On initialization, the database will perform the migration to accomodate for new columns/tables creation and while it will conserve the mobile phone's data, it will also make it conform to this new schema.

* If a migration fails, the migration process will rollback any changes.  This migration will be thoroughly tested in development before pushing it live.

* Revert "MM_30476 : ADDED Schema Managers"

This reverts commit a505bd5e11124e8eb8f258ce8dbb8168a535f7ae.

* MM_30478 : Converted schema_manager into a function

* MM_30478 : Updated schema manager and included patch for wdb

* MM_30478:  Updated watermelondb patch package

* MM_30478 : Update function create_schema_manager to createSqliteAdaptorOptions

* MM_30476 : Update constant name to reflect directory name

* MM_30476 : Updated msgCount from my_channel model to message_count in server schema

* MM_30482 : Added tests for schema_manager

* MM_30482 : Database Manager [ IN PROGRESS ]

* MM_30478 : Returning an sqliteAdapter instead of an object

* MM_30476 : Apply suggestions from code review

Co-authored-by: Elias Nahum <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>
2021-03-26 19:23:32 +04:00

752 lines
25 KiB
TypeScript

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