mongocat
Version:
✨ Mongocat 😺 is easy to use, configuration based Denormalization mongoose plugin for read heavy applications. Mongocat reduces write complexity too.
542 lines (471 loc) • 18.2 kB
text/typescript
/* eslint-disable no-prototype-builtins */
import {
Types,
model,
MongooseQueryMiddleware,
Schema,
UpdateQuery,
} from 'mongoose';
import { IWatchConsumer } from '../types';
import { flattenObj } from '../utils/flatten';
import { ObjectBuilder } from '../utils/filter';
import { denormalizeEmitter, updateQueryMethods } from '..';
/**
* It's used on ConsumerModel
* @param options = {
key: '_id',
fromRef: 'Category', // make default: toPath = category
inArray: true // if it's like genres
}
* @returns
*/
export const watchConsumer = (ops: IWatchConsumer) => {
const options = ops || {};
options.toPath = options.toPath || options.fromRef.toLowerCase();
return (schema: Schema) => {
const {
toPath,
key = '_id',
fromRef,
exceptionOnFailure = false,
toRef,
inArray = false,
strict = false,
} = options;
if (!toPath) return;
/***
* Event emit after provider publishing
*/
denormalizeEmitter.on(
`denormalize/publish/${fromRef}`,
async (data: any) => {
// Step-1: Get Model instance
// const ProviderModel = model(options.fromRef);
const ConsumerModel = model(options.toRef);
// Step-2: Get updatable key from Provider
const _docData = data?._doc ? data?._doc : data;
const flattenPublishedData = _docData;
// Step-3: Get updatable key on consumer
const schemaFeilds = Object.keys(schema.paths);
console.log({ flattenPublishedData });
const filteredFlattenedData = ObjectBuilder.filterObject(
flattenPublishedData,
(_, k) => schemaFeilds.includes(k),
value => !(value instanceof Types.ObjectId) && Object(value)
);
//TODO: get denormable keys first like GQL
// Step-4: Update ConsumerModel
const filterKey = `${toPath}.${key}`;
const filterQuery = {
[filterKey]: data[key],
};
const updateFlattenData: Record<string, any> = Object.entries(
filteredFlattenedData
).reduce((acc, [k, value]) => {
const consumerUpdateKeyPath = inArray
? `${toPath}.$.${k}`
: `${toPath}.${k}`;
return {
...acc,
[consumerUpdateKeyPath]: value,
};
}, {});
await ConsumerModel.updateMany(filterQuery, updateFlattenData, {
new: true,
});
console.info(
'\x1b[47m\x1b[46m%s\x1b[0m',
`✨ Successfully denormalized via hook watchConsumer event emitted ✨`,
'\x1b[1m\x1b[4m',
`toRef: ${toRef}, from provider: ${fromRef}, toPath: ${toPath}, inArray: ${inArray} 🐦`
);
}
);
/***
* Event emit after provider publishing
*/
if (fromRef && options.changeStreamEnabled) {
console.log(
`CHANGE STREAM: ${options.toRef} > ${toPath} << provider: ${options.fromRef}`
);
process.nextTick(() => {
const ConsumerModel = model(options.toRef);
const ProviderModel = model(options.fromRef);
let cursor: any;
try {
//FIXME: test it using local unsupported DB
cursor = ProviderModel.watch();
} catch (error) {
cursor = null;
throw new Error(`Change Stream not supported`);
}
// ChangeStreamDocument<any>
if (cursor) {
cursor.on('change', async (change: any) => {
console.log(`${options.fromRef} is called for ${options.toRef}`);
if (change.operationType === 'update') {
const updatedData = change.updateDescription?.updatedFields || {};
if (options.inArray) {
const matchedKeys = Object.keys(
schema.paths[toPath]?.schema?.paths
);
const updateDenormedData: Record<string, any> = {};
const flattenProvider = flattenObj(updatedData);
matchedKeys.forEach((key: string) => {
updateDenormedData[key] = flattenProvider[key];
});
// const providerId: any = change.documentKey?._id as unknown as any;
const providerId = change.documentKey?._id as any;
if (!providerId) return;
try {
const contentQuery = {
[`${toPath}.${key}`]: providerId,
};
const contentUpdateBody = flattenObj({
[`${toPath}.$`]: updateDenormedData,
});
console.log(contentQuery, contentUpdateBody);
await ConsumerModel.updateMany(
contentQuery,
contentUpdateBody
);
console.info(
'\x1b[47m\x1b[46m%s\x1b[0m',
`✨ Successfully denormalized from changeStream ✨`,
`toRef: ${options.toRef}, from provider: ${options.fromRef}, toPath: ${options.toPath} 🐦`
);
// CALL AFTER UPDATE HOOK
} catch (error) {
console.log(
`Failed to denormalize error by changeStream on mongoose, Model:${options.toRef},Field: ${options.toPath}`,
error.message
);
await cursor.close();
}
} else if (!options.inArray) {
const matchedKeys = Object.keys(
schema.paths[toPath]?.schema?.paths
);
const updateDenormedData: Record<string, any> = {};
const flattenProvider = flattenObj(updatedData);
matchedKeys.forEach((key: string) => {
updateDenormedData[key] = flattenProvider[key];
});
// const providerId: any = change.documentKey?._id as unknown as any;
const providerId = change.documentKey?._id as any;
if (!providerId) return;
try {
const contentQuery = {
[`${toPath}.${key}`]: providerId,
};
const contentUpdateBody = flattenObj({
[toPath]: updateDenormedData,
});
console.log(contentQuery, contentUpdateBody);
await ConsumerModel.updateMany(
contentQuery,
contentUpdateBody
);
// CALL AFTER UPDATE HOOK
console.info(
'\x1b[47m\x1b[46m%s\x1b[0m',
`✨ Successfully denormalized from changeStream✨`,
`toRef: ${options.toRef}, from provider: ${options.fromRef}, toPath: ${options.toPath} 🐦`
);
} catch (error) {
console.log(
`Failed to denormalize error by changeStream on mongoose, Model:${options.toRef},Field: ${options.toPath}`,
error.message
);
await cursor.close();
}
}
}
});
cursor.on('error', async () => {
await cursor.close();
console.log(`Change Stream not supported`);
});
}
});
}
// eslint-disable-next-line @typescript-eslint/ban-types
schema.pre(['save'], async function(next: Function) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const doc: any = this;
// if (!this[toPath] || !this[relationship as string] || !doc[toPath][key]) return;
// next(new Error(`to path nai`));
if ((this.isNew || this.isModified(toPath)) && this[toPath]) {
const msg = {
plugin: 'watchConsumer',
method: 'pre save',
provider: fromRef,
consumer: toRef,
inArray: inArray ? 'array' : 'object',
doc,
isNew: this.isNew ? 'creating' : 'updating',
modification: `${toPath} ${
this.isModified(toPath) ? 'is ' : 'is not'
} modified`,
};
console.log(msg);
if (!inArray && this[toPath] && this[toPath][key]) {
// Step-2: get provided data;
const ProviderModel = model(fromRef);
const filterQuery = { [key]: this[toPath][key] };
const provider: Record<
string,
any
> | null = await ProviderModel.findOne(filterQuery, {
createdAt: 0,
updatedAt: 0,
});
if (!provider) {
if (strict || exceptionOnFailure) {
return next(
new Error(
`Denormalization failed for key: ${key}, value: ${this[toPath][key]}, plugin: watchConsumer, method: save, consumer: ${toPath}, provider: ${fromRef}`
)
);
} else {
return next();
}
}
// Step-3: set denormmalized value on field on watchConsumer
Object.assign(this[toPath], provider?.toObject());
return next();
} else if (inArray && this[toPath]) {
const groupArray = doc[toPath]
?.filter((data: any) => data[key])
?.map((value: any) => value[key]);
if (!this[toPath] || !groupArray || !groupArray.length) {
delete this[toPath]; // I even don't what will be it's effect
if (strict || exceptionOnFailure) {
return next(
new Error(
`Denormalization failed for feild: ${toPath}, array: ${groupArray}, plugin: watchConsumer, method: save, consumer: ${toRef}, provider: ${fromRef}`
)
);
} else {
return next();
}
}
console.log('plugin: watchConsumer , method: update on: inArray ');
const ProviderModel = model(fromRef);
/**
* FIXME: if change consumer sub schema then fetch from provider
* else DO NOTHING
*/
const providers: Record<
string,
any
> | null = await ProviderModel.find({
[key]: { $in: groupArray },
});
if (!providers.length) {
if (strict || exceptionOnFailure) {
return next(
new Error(
`Denormalization failed for feild: ${toPath}, array: ${groupArray}, plugin: watchConsumer, method: save, consumer: ${toRef}, provider: ${fromRef}`
)
);
} else {
return next();
}
}
/**
* Consumer Update
* { _id: 62960300da98cc1aba3e9ff2 } // doc['_id']
* { users: [
{
* _id: 62960300da98cc1aba3e9ff2,
* name: Samayun Chowdhury
* username: samayunchy
* },
* {
* _id: 62a9c113382dec8e79c02178,
* name: AzadChowdhury
* username: azad
* }
* ]}
*/
// Step-3: set denormmalized value on field on watchConsumer
Object.assign(this[toPath], providers);
return;
}
return next();
}
});
// eslint-disable-next-line @typescript-eslint/ban-types
// schema.post(['save'], async function (doc: Record<string, any>, next: Function) {
// console.log(`post hook on watchConsumer`, this.isModified(doc[toPath]));
// next();
// });
/**
* VALIDATE CONSUMER UPDATE FIELD
*/
schema.pre(
Object.keys(updateQueryMethods) as MongooseQueryMiddleware[],
async function(next: any) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self: any = this;
if (!self) return;
// const shouldDenormalize = !!self.options['denormalize']; // {denormalize: true}
const shouldDenormalize = !self.options['denormalize']
? self.options['denormalize']
: true;
self['$denormalize'] = shouldDenormalize;
if (!shouldDenormalize) return;
const updateParams = self.getUpdate() as UpdateQuery<any>;
const doc = updateParams;
if (!doc) return;
self['$denormalize'] = shouldDenormalize;
// const updateField = updateParams.$set || {};
const ignoredFields = ['createdAt', 'updatedAt', '__v'] as string[];
ignoredFields.forEach(key => {
delete updateParams[key];
});
delete doc['$set'];
delete doc['$setOnInsert'];
if (!inArray && shouldDenormalize && !!doc && !!doc[toPath]) {
const denormableFeildsPrimaryKey = `${toPath}.${key}`;
const denormableFeildsPrimaryValue = doc[denormableFeildsPrimaryKey]
? doc[denormableFeildsPrimaryKey]
: doc[toPath][key];
if (!!doc[toPath] && !!doc[toPath][key]) {
console.log({
plugin: 'watchConsumer',
method: 'pre findOneAndUpdate',
provider: fromRef,
consumer: toRef,
inArray,
toPath,
key,
doc,
});
const ProviderModel = model(fromRef);
const provider: Record<
string,
any
> | null = await ProviderModel.findOne({
[key]: denormableFeildsPrimaryValue,
});
if (!provider) {
if (strict || exceptionOnFailure) {
return next(
new Error(
`Denormalization failed for toPath:${toPath}, value:${denormableFeildsPrimaryValue} plugin: watchConsumer, method: findOneAndUpdate on: !inArray`
)
);
} else {
return next();
}
}
console.log(
'\x1b[47m\x1b[46m%s\x1b[0m',
`Denormalizing on feild: ${toPath} , queryKey: ${denormableFeildsPrimaryKey} bodyValue: ${denormableFeildsPrimaryValue}`
);
/**
* Consumer Update
* { user._id: 62960300da98cc1aba3e9ff2 }
* { user: {
* _id: 62960300da98cc1aba3e9ff2,
* name: Samayun Chowdhury
* username: samayunchy
* }}
*/
// await this.model.updateMany(
// { [denormableFeildsPrimaryKey]: denormableFeildsPrimaryValue },
// { [toPath as string]: provider?.toObject() },
// );
// Object.assign(doc[toPath], provider?.toObject());
doc[toPath] = provider?.toObject();
return next();
}
return next();
} else if (inArray && shouldDenormalize && !!doc && !!doc[toPath]) {
console.log({
plugin: 'watchConsumer',
method: 'pre findOneAndUpdate',
provider: fromRef,
consumer: toRef,
toPath: toPath,
});
console.log(
`plugin: watchConsumer, method: findOneAndUpdate TYPE: PRE HOOK on: inArray path: ${toPath} data: ${updateParams[toPath]}`
);
console.log({
updateParams,
'updateParams[toPath]': updateParams[toPath],
});
const updateFields = updateParams[toPath as string] || [];
if (!updateFields || !updateFields.length) {
return next();
}
const groupArray = updateFields
?.filter((data: any) => data[key])
?.map((value: any) => value[key]);
if (!groupArray.length) {
// delete doc[toPath];
return next(
new Error(
`Denormalization failed on watchConsumer inArray portion for ${key} array: ${groupArray}. WRONG KEY INPUT FROM FRONTEND`
)
);
}
const ProviderModel = model(fromRef);
const providers: Record<
string,
any
> | null = await ProviderModel.find({
[key]: { $in: groupArray },
});
if (!providers || !providers.length) {
if (strict || exceptionOnFailure) {
return next(
new Error(
`Denormalization failed for feild: ${toPath}, array: ${groupArray}, plugin: watchConsumer, method: findOneAndUpdate, provider: ${fromRef}`
)
);
} else {
return next();
}
}
// Object.assign(doc[toPath], providers);
doc[toPath] = providers;
return next();
}
return next();
}
);
/**
* onUpdateConsumer
*/
// schema.post(
// Object.keys(updateQueryMethods) as MongooseQueryMiddleware[],
// // eslint-disable-next-line @typescript-eslint/ban-types
// async function (doc: Record<string, any>, next: Function) {
// try {
// const self = this as any;
// // const updateParams = self.getUpdate() as UpdateQuery<any>;
// // const updateField = updateParams.$set || {};
// console.log({
// hook: 'afterFetchProvider',
// $denormalize: !!self['$denormalize'],
// toPath,
// publishConsumer: toRef,
// publishedProvider: fromRef,
// });
// return next();
// } catch (error: any) {
// const self = this as any;
// return next(
// new Error(
// `method: ${self.op}, plugin: watchConsumer,toPath: ${toPath}, consumer: ${toRef}, provider: ${fromRef} failed`,
// ),
// );
// }
// },
// );
};
};