mongocat
Version:
✨ Mongocat 😺 is easy to use, configuration based Denormalization mongoose plugin for read heavy applications. Mongocat reduces write complexity too.
731 lines (622 loc) • 22.3 kB
text/typescript
/* eslint-disable no-prototype-builtins */
import {
model,
Types,
MongooseQueryMiddleware,
Schema,
UpdateQuery,
} from 'mongoose';
import { IPolymorphicConsumer } from '../types';
import { flattenObj } from '../utils/flatten';
import { ObjectBuilder } from '../utils/filter';
import { unflattenObj } from '../utils/unflatten';
import { denormalizeEmitter, updateQueryMethods } from '..';
/**
*
* @param options = {
key: '_id',
localKey: 'modelId',
fromRef: 'modelType',
toPath: 'content',
withTimestamp: true,
}
* @returns
*/
export const polymorphicConsumer = (ops: IPolymorphicConsumer) => {
const options = ops || {};
options.toPath = options.toPath || 'content';
return (schema: Schema) => {
const {
fixedProvider,
availableProviders,
localKey = 'modelId',
foreignKey = '_id',
fromRefPath = 'modelType',
toPath,
morphSchemaMap,
withTimestamp,
denormWhen,
modelSwitcher,
clean,
strict,
exceptionOnFailure,
as,
inArray = false,
polymorphic = true,
toRef,
// old
morphKey = 'modelId',
fromRef = 'modelType',
} = options;
if (!toPath || !localKey || !foreignKey)
return new Error(
`Configuration invalid: toPath:${toPath} , localKey:${localKey} , foreignKey:${foreignKey}`
);
if (clean) {
// Don't use without knowing properly
schema.methods.toJSON = function() {
const objectEntity = this.toObject();
delete objectEntity[localKey];
delete objectEntity[fromRefPath];
return objectEntity;
};
}
if (withTimestamp) {
schema.add({
denormalizedAt: { type: Date },
});
}
// eslint-disable-next-line no-prototype-builtins
if (!schema.paths.hasOwnProperty(toPath)) {
if (!morphSchemaMap) {
const msg = `Schema configuration invalid: toPath:${toPath} , localKey:${localKey} , foreignKey:${foreignKey}. either defiine static sub schema or dynamic morphSchemaMap`;
console.log(msg);
return new Error(msg);
}
schema.add({
[toPath]: Schema.Types.Mixed,
});
}
const allProviders =
(fixedProvider && [fixedProvider]) ||
(availableProviders && availableProviders) ||
(morphSchemaMap && Object.keys(morphSchemaMap)) ||
(options.modelSwitcher && Object.values(options.modelSwitcher)) ||
[];
/***
* Event emit after provider publishing
*/
allProviders.map((provider: string): string => {
denormalizeEmitter.on(
`denormalize/publish/${provider}`,
async (data: any) => {
// Step-1: Get Model instance
const { toPath, inArray = false, toRef } = options;
// const ProviderModel = model(fromRef);
const ConsumerModel = model(toRef);
console.log({ options, data });
// 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: string[] =
(morphSchemaMap && Object.keys(morphSchemaMap[provider].paths)) ||
Object.keys(schema.paths[toPath]?.schema?.paths) ||
[];
console.log({ schemaFeilds, flattenPublishedData });
const filteredFlattenedData = ObjectBuilder.filterObject(
flattenPublishedData,
(_, k) => schemaFeilds.includes(k),
value => !(value instanceof Types.ObjectId) && Object(value)
);
// Step-4: Update ConsumerModel
const filterKey = `${toPath}.${foreignKey}`;
const filterQuery = {
[filterKey]: data[foreignKey],
};
const updateFlattenData: Record<string, any> = Object.entries(
filteredFlattenedData
).reduce((acc, [k, value]) => {
const consumerUpdateKeyPath = inArray
? `${toPath}.$.${k}`
: `${toPath}.${k}`;
return {
...acc,
[consumerUpdateKeyPath]: value,
};
}, {});
console.log({
filterKey,
filterQuery,
updateFlattenData,
filteredFlattenedData,
});
await ConsumerModel.updateMany(filterQuery, updateFlattenData, {
new: true,
});
console.info(
'\x1b[47m\x1b[46m%s\x1b[0m',
`✨ Successfully denormalized via hook polymorphicConsumer event emitted ✨`,
'\x1b[1m\x1b[4m',
`toRef: ${toRef}, from provider: ${provider}, toPath: ${toPath}, inObject 🐦`
);
}
);
return `allProviders registered`;
});
/***
* Event emit after provider publishing
*/
/***
* Change Stream sync
*/
function watchPolymorphicProvider(provider: string) {
const ConsumerModel = model(toRef);
const ProviderModel = model(provider);
let cursor: any;
try {
cursor = ProviderModel.watch();
} catch (error) {
cursor = null;
throw new Error(`Change Stream not supported`);
}
// ChangeStreamDocument<any>
cursor.on('change', async (change: any) => {
console.log(
`provider: ${provider} is calling for consumer:${options.toRef} on ${toPath} field`
);
if (change.operationType === 'update') {
const updatedData = change.updateDescription?.updatedFields || {};
const matchedKeys =
(schema.paths
.hasOwnProperty(toPath)
.hasOwnProperty('schema')
.hasOwnProperty('paths') &&
Object.keys(schema.paths[toPath]?.schema?.paths)) ||
(morphSchemaMap && Object.keys(morphSchemaMap[provider].paths)) ||
[];
const updateDenormedData: Record<string, any> = {};
const flattenProvider = flattenObj(updatedData);
matchedKeys.forEach((key: string) => {
updateDenormedData[key] = flattenProvider[key];
});
as &&
Object.entries(as).forEach(([key, value]) => {
if (flattenProvider[value]) {
updateDenormedData[key as string] = flattenProvider[value];
}
});
const providerId = change.documentKey?._id;
if (!providerId) return;
try {
const filterQuery = {
[localKey]: providerId,
[toPath]: {
$exists: true,
},
...(!fixedProvider && { [fromRefPath]: provider }),
};
const updateBody = flattenObj({ [toPath]: updateDenormedData });
console.log({ filterQuery, updateBody });
await ConsumerModel.updateMany(filterQuery, updateBody);
} catch (error) {
console.log(
`Failed to denormalize error by changeStream, Model: ${ConsumerModel} Field: ${toPath}`
);
await cursor.close();
}
}
});
cursor.on('error', async (error: any) => {
console.log(error.message || `Change Stream not supported`);
await cursor.close();
});
}
if (options.changeStreamEnabled && allProviders.length) {
process.nextTick(() => {
console.log({
[toRef]: toPath,
allProviders,
});
allProviders.map(watchPolymorphicProvider);
});
}
/***
* Change Stream sync
*/
// eslint-disable-next-line @typescript-eslint/ban-types
schema.pre(['save'], async function(next: Function) {
// Step-1: get modelType value
// eslint-disable-next-line @typescript-eslint/no-this-alias
const doc: any = this;
const ModelType: string =
fixedProvider || doc[fromRef] || doc[fromRefPath];
const LocalValue = doc[morphKey] || doc[localKey];
if (!toPath || !LocalValue || !ModelType) return;
if (fixedProvider && availableProviders) {
return next(
new Error(
`Conflict in config: fixedProvider & availableProviders both shouldn't avilable field: ${toPath}`
)
);
}
if (
!fixedProvider &&
availableProviders &&
!availableProviders.includes(ModelType)
) {
return next(
new Error(
`NOT AVILABLE in list: availableProviders: ${availableProviders}, but got: ${ModelType} field: ${toPath}, plugin: polymorphicConsumer , method: save`
)
);
}
if (availableProviders && morphSchemaMap) {
return next(
new Error(
`Conflict in config: morphSchemaMap & availableProviders both shouldn't avilable on path: ${toPath}`
)
);
}
if (denormWhen && doc[denormWhen['field']]) {
if (denormWhen['is'] && denormWhen['is'] !== doc[denormWhen['field']])
return;
if (denormWhen['ne'] && denormWhen['ne'] === doc[denormWhen['field']])
return;
if (
denormWhen['in'] &&
!denormWhen['in'].includes(doc[denormWhen['field']])
)
return;
if (
denormWhen['nin'] &&
denormWhen['nin'].includes(doc[denormWhen['field']])
)
return;
}
const shouldDenormalize =
LocalValue &&
ModelType &&
(this.isNew ||
this.isModified(localKey) ||
this.isModified(fromRefPath));
console.log({
shouldDenormalize,
localKey,
LocalValue,
ModelType,
fixedProvider,
toPath,
toRef,
});
if (shouldDenormalize) {
let ProviderModel: any;
if (modelSwitcher && modelSwitcher[ModelType]) {
ProviderModel = model(modelSwitcher[ModelType]);
} else {
if (morphSchemaMap && !morphSchemaMap[ModelType]) {
return next(
new Error(
`${ModelType} is not available on morphSchemaMap's schema.Please define schema for this modelType`
)
);
} else if (
availableProviders &&
!availableProviders.includes(ModelType)
) {
return next(
new Error(
`${ModelType} is not available on morphSchemaMap's schema.Please define schema for this modelType`
)
);
}
ProviderModel = model(ModelType);
}
/**
* Step-2: get Provider model instance & value ProviderModel({_id: modelId }).
* here [ProviderModel]({ [foreignKey]: localValue })
*/
const provider: Record<
string,
any
> | null = await ProviderModel.findOne(
{ [foreignKey]: LocalValue },
{ createdAt: 0, updatedAt: 0 }
);
if (!provider) {
if (strict || exceptionOnFailure) {
return next(
new Error(
`Denormalization failed for foreignKey:${foreignKey}, value: ${LocalValue} field: ${toPath}, plugin: polymorphicConsumer , method: save`
)
);
} else {
return next();
}
}
let updateDenormedData: Record<string, any> = {};
if (morphSchemaMap) {
/**
morphSchemaMap: {
Artist: new Schema({
_id: { type: Schema.Types.ObjectId },
name: String,
slug: String,
avatar: String,
}),
Genre: new Schema({
_id: { type: Schema.Types.ObjectId },
title: String,
slug: String
}),
},
*/
const morphSchemaKeys = Object.keys(morphSchemaMap[ModelType].paths);
const processor: Record<string, any> = {};
const flattenProvider = flattenObj(provider.toObject());
morphSchemaKeys.forEach((key: any) => {
processor[key] = flattenProvider[key];
});
as &&
Object.entries(as).forEach(([key, value]) => {
if (flattenProvider[value]) {
processor[key as string] = flattenProvider[value];
}
});
updateDenormedData = unflattenObj(processor);
} else {
updateDenormedData = provider.toObject();
}
if (as) {
const flattenProvider = flattenObj(provider.toObject());
// doc[toPath] = flattenProvider || { [foreignKey]: LocalValue };
let aliasMappedObject: Record<string, any> = {};
Object.entries(as).forEach(([key, value]) => {
if (flattenProvider[value]) {
aliasMappedObject[key as string] = flattenProvider[value];
}
});
aliasMappedObject = unflattenObj({
...flattenProvider,
...aliasMappedObject,
});
this[toPath] = aliasMappedObject;
if (withTimestamp) {
this['denormalizedAt'] = new Date().toISOString();
}
return next();
}
// Step-3: set denormmalized value on polymorphicConsumer
doc[toPath] = updateDenormedData;
if (withTimestamp) {
doc['denormalizedAt'] = new Date().toISOString();
}
return next();
}
return next();
});
/**
* onUpdate polymorphicConsumer
*/
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'] || false;
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'];
console.log(`PLUGIN: polymorphicConsumer , op: ${self.op} doc `, doc);
const ModelType: string =
fixedProvider || doc[fromRef] || doc[fromRefPath];
const LocalValue = doc[morphKey] || doc[localKey];
if (!toPath || !LocalValue || !ModelType) return;
if (fixedProvider && availableProviders) {
return next(
new Error(
`Conflict in config: fixedProvider & availableProviders both shouldn't avilable field: ${toPath}`
)
);
}
if (
!fixedProvider &&
availableProviders &&
!availableProviders.includes(ModelType)
) {
return next(
new Error(
`NOT AVILABLE in list: availableProviders: ${availableProviders}, but got: ${ModelType} field: ${toPath}, plugin: polymorphicConsumer , method: save`
)
);
}
if (availableProviders && morphSchemaMap) {
return next(
new Error(
`Conflict in config: morphSchemaMap & availableProviders both shouldn't avilable on path: ${toPath}`
)
);
}
if (denormWhen && doc[denormWhen['field']]) {
if (denormWhen['is'] && denormWhen['is'] !== doc[denormWhen['field']])
return;
if (denormWhen['ne'] && denormWhen['ne'] === doc[denormWhen['field']])
return;
if (
denormWhen['in'] &&
!denormWhen['in'].includes(doc[denormWhen['field']])
)
return;
if (
denormWhen['nin'] &&
denormWhen['nin'].includes(doc[denormWhen['field']])
)
return;
}
if (!ModelType) {
// return next(new Error(`${fromRef || fromRefPath} is required`));
return;
}
if (
!inArray &&
polymorphic &&
shouldDenormalize &&
!!doc &&
(!!LocalValue || !!ModelType)
) {
let ProviderModel: any;
if (modelSwitcher && modelSwitcher[ModelType]) {
ProviderModel = model(modelSwitcher[ModelType]);
} else {
if (morphSchemaMap && !morphSchemaMap[ModelType]) {
return next(
new Error(
`${ModelType} is not available on morphSchemaMap's schema.Please define schema for this modelType`
)
);
} else if (
availableProviders &&
!availableProviders.includes(ModelType)
) {
return next(
new Error(
`${ModelType} is not available on morphSchemaMap's schema.Please define schema for this modelType`
)
);
}
ProviderModel = model(ModelType);
}
/**
* Step-2: get Provider model instance & value ProviderModel({_id: modelId }).
* here [ProviderModel]({ [foreignKey]: localValue })
*/
const provider: Record<
string,
any
> | null = await ProviderModel.findOne(
{ [foreignKey]: LocalValue },
{ createdAt: 0, updatedAt: 0 }
);
if (!provider) {
if (strict || exceptionOnFailure) {
return next(
new Error(
`Denormalization failed for foreignKey:${foreignKey}, value: ${LocalValue} field: ${toPath}, plugin: polymorphicConsumer , method: ${self.op}`
)
);
} else {
return next();
}
}
let updateDenormedData: Record<string, any> = {};
if (morphSchemaMap) {
/**
morphSchemaMap: {
Artist: new Schema({
_id: { type: Schema.Types.ObjectId },
name: String,
slug: String,
avatar: String,
}),
Genre: new Schema({
_id: { type: Schema.Types.ObjectId },
title: String,
slug: String
}),
},
*/
const morphSchemaKeys = Object.keys(
morphSchemaMap[ModelType].paths
);
const processor: Record<string, any> = {};
const flattenProvider = flattenObj(provider.toObject());
morphSchemaKeys.forEach((key: any) => {
processor[key] = flattenProvider[key];
});
as &&
Object.entries(as).forEach(([key, value]) => {
if (flattenProvider[value]) {
processor[key as string] = flattenProvider[value];
}
});
updateDenormedData = unflattenObj(processor);
} else {
updateDenormedData = provider.toObject();
}
if (as) {
const flattenProvider = flattenObj(provider.toObject());
// doc[toPath] = flattenProvider || { [foreignKey]: LocalValue };
let aliasMappedObject: Record<string, any> = {};
Object.entries(as).forEach(([key, value]) => {
if (flattenProvider[value]) {
aliasMappedObject[key as string] = flattenProvider[value];
}
});
aliasMappedObject = unflattenObj({
...flattenProvider,
...aliasMappedObject,
});
doc[toPath] = aliasMappedObject;
if (withTimestamp) {
doc['denormalizedAt'] = new Date().toISOString();
}
return next();
}
// Step-3: set denormmalized value on polymorphicConsumer
doc[toPath] = updateDenormedData;
if (withTimestamp) {
doc['denormalizedAt'] = new Date().toISOString();
}
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) {
// const self = this as any;
// // const doc = self.getUpdate() as UpdateQuery<any>;
// // const updateField = updateParams.$set || {};
// const Provider =
// fixedProvider ||
// (options.fromRef && doc[options.fromRef]) ||
// (options.fromRefPath && doc[options.fromRefPath]) ||
// 'Not recognised';
// const Consumer = self.model.modelName;
// try {
// console.log({
// hook: 'publish consumer',
// $denormalize: !!self['$denormalize'],
// toPath,
// publishConsumer: Consumer,
// publishedProvider: Provider,
// });
// return next();
// } catch (error: any) {
// return next(
// new Error(
// `method: ${self.op}, plugin: polymorphicConsumer,toPath: ${toPath}, consumer: ${Consumer}, provider: ${Provider} failed`,
// ),
// );
// }
// },
// );
// onUpdateConsumer
};
};