mongocat
Version:
✨ Mongocat 😺 is easy to use, configuration based Denormalization mongoose plugin for read heavy applications. Mongocat reduces write complexity too.
431 lines (347 loc) • 12.9 kB
text/typescript
/* eslint-disable no-prototype-builtins */
import {
model,
Schema,
Types,
MongooseQueryMiddleware,
UpdateQuery,
} from 'mongoose';
import { flattenObj } from '../utils/flatten';
import { ObjectBuilder } from '../utils/filter';
import { ArrayBuilder } from '../utils/array/unique';
import { IPolymorphicArrayConsumer } from '../types';
import { denormalizeEmitter, updateQueryMethods } from '..';
export const polymorphicArrayConsumer = (ops: IPolymorphicArrayConsumer) => {
const options = ops || {};
options.toPath = options.toPath || 'contents';
options.toPathNestedField = options.toPathNestedField || 'content';
options.strict = options.strict || false;
return (schema: Schema) => {
const {
toRef,
availableProviders,
localKey = 'modelId',
foreignKey = '_id',
fromRefPath = 'modelType',
toPath,
toPathNestedField,
fixedProvider,
morphSchemaMap,
polymorphic = true,
inArray = true,
} = options;
if (!toPath || !toPathNestedField || !localKey || !foreignKey)
return new Error(
`Configuration invalid: toPath:${toPath},toPathNestedField:${toPathNestedField}, localKey:${localKey} , foreignKey:${foreignKey}, inArray`
);
const hasStaticSubSchema =
schema.paths[toPath] &&
schema.paths[toPath]?.schema.paths[toPathNestedField];
if (!hasStaticSubSchema) {
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.paths[toPath].schema.add({
[toPathNestedField]: Schema.Types.Mixed,
});
}
const allProviders =
(fixedProvider && [fixedProvider]) ||
(availableProviders && availableProviders) ||
(morphSchemaMap && Object.keys(morphSchemaMap)) ||
[];
/***
* 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 = true, toRef } = options;
// const ProviderModel = model(fromRef);
const ConsumerModel = model(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: string[] =
(morphSchemaMap && Object.keys(morphSchemaMap[provider].paths)) ||
Object.keys(
schema.paths[toPath]?.schema.paths[toPathNestedField].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}.${localKey}`;
const filterQuery = {
[filterKey]: data[foreignKey],
};
// const toPath = `${toPath}.$.${localKey}`;
// const contentUpdateBody = flattenObj({ [toPath]: updateDenormedData });
const updateFlattenData: Record<string, any> = Object.entries(
filteredFlattenedData
).reduce((acc, [k, value]) => {
const consumerUpdateKeyPath = inArray
? `${toPath}.$.${toPathNestedField}.${k}`
: `${toPath}.${toPathNestedField}.${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 polymorphicArrayConsumer event emitted ✨`,
'\x1b[1m\x1b[4m',
`toRef: ${toRef}, from provider: ${provider}, toPath: ${toPath}, in polymorphic Array 🐦`
);
}
);
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(
`PolymorphicArrayConsumer: provider: ${provider} is calling for consumer:${options.toRef} on ${toPath} field`
);
if (change.operationType === 'update') {
const updatedData = change.updateDescription?.updatedFields || {};
const matchedKeys: string[] =
(morphSchemaMap && Object.keys(morphSchemaMap[provider].paths)) ||
Object.keys(
schema.paths[toPath]?.schema.paths[toPathNestedField].schema.paths
) ||
[];
const updateDenormedData: Record<string, any> = {};
const flattenProvider = flattenObj(updatedData);
matchedKeys.forEach((key: string) => {
updateDenormedData[key] = flattenProvider[key];
});
const providerId = change.documentKey?._id;
if (!providerId) return;
try {
const filterKey = `${toPath}.${localKey}`;
const filterQuery = {
[filterKey]: providerId,
};
const toUpdate = `${toPath}.$.${toPathNestedField}`;
const updateBody = flattenObj({ [toUpdate]: updateDenormedData });
console.log({ filterQuery, toUpdate, updateBody });
await ConsumerModel.updateMany(filterQuery, updateBody);
} catch (error) {
console.log(
`Failed to denormalize error by changeStream, Model: ${toRef} Field: ${toPath} Nested: ${toPathNestedField}`,
error.message
);
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({
on: 'PolymorphicArrayConsumer',
[toRef]: toPath,
allProviders,
});
allProviders.map(watchPolymorphicProvider);
});
}
/***
* Change Stream sync
*/
// 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 document: any = this;
const doc = document?.toObject();
// STEP-1: validate request
if (!doc || !polymorphic || !inArray) return;
// if (!doc[toPath] || !doc[toPath]?.length) {
// return next(new Error(`Conflict in toPath: field: ${toPath}`));
// }
if (fixedProvider && availableProviders) {
return next(
new Error(
`Conflict in config: fixedProvider & availableProviders both shouldn't avilable field: ${toPath}`
)
);
}
// STEP-2: get provider model
const ModelType: string = fixedProvider || doc[fromRefPath];
const shouldDenormalize =
ModelType &&
(this.isNew || this.isModified(toPath)) &&
inArray &&
polymorphic &&
!!doc &&
!!doc[toPath] &&
!!doc[toPath]?.length;
console.log({
shouldDenormalize,
localKey,
ModelType,
toPath,
toRef,
});
if (shouldDenormalize) {
if (!toPath || !ModelType) return;
const ProviderModel = model(ModelType);
// STEP-3: get unique provider keys
const providersKeys = doc[toPath];
const uniqueKeys = ArrayBuilder.uniqueKeys(providersKeys, localKey);
// Flat providerValues
const uniqueValues = ArrayBuilder.uniqueValues(providersKeys, localKey);
// STEP-4: get provided contents
const filterQuery = {
[foreignKey]: {
$in: uniqueKeys,
},
};
const providers = await ProviderModel.find(filterQuery);
console.log({ ProviderModel, uniqueKeys, uniqueValues });
const contents = doc[toPath]?.map(
(toPathNested: Record<string, any>) => {
const content = providers?.find(
(_pro: Record<string, any>) =>
_pro[foreignKey]?.toString() ===
toPathNested[localKey].toString()
);
toPathNested[toPathNestedField] = content;
return toPathNested;
}
);
// STEP-4: parse & set feilds to be saved
this[toPath] = contents;
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 || !fixedProvider) 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 || !doc[toPath]) 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'];
const ModelType: string = fixedProvider || doc[fromRefPath];
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 (shouldDenormalize) {
if (!toPath || !ModelType) return;
console.log({
shouldDenormalize,
localKey,
doc,
toPath,
});
const ProviderModel = model(ModelType);
// STEP-3: get unique provider keys
const providersKeys = doc[toPath];
const uniqueKeys = ArrayBuilder.uniqueKeys(providersKeys, localKey);
// STEP-4: get provided contents
const filterQuery = {
[foreignKey]: {
$in: uniqueKeys,
},
};
const providers = await ProviderModel.find(filterQuery);
console.log({ ProviderModel, uniqueKeys });
const contents = doc[toPath]?.map(
(toPathNested: Record<string, any>) => {
const content = providers?.find(
(_pro: Record<string, any>) =>
_pro[foreignKey]?.toString() ===
toPathNested[localKey].toString()
);
toPathNested[toPathNestedField] = content;
return toPathNested;
}
);
// STEP-4: parse & set feilds to be saved
doc[toPath] = contents;
return next();
}
return next();
}
);
};
};