From 1d69bd9beb5e09bfa611287003c424980ffcdb7d Mon Sep 17 00:00:00 2001 From: Lexus Drumgold Date: Sat, 22 May 2021 00:48:48 -0400 Subject: [PATCH] feat(mango): plugin --- .eslintrc.js | 11 +- README.md | 148 +++++- __tests__/__fixtures__/.gitkeep | 0 __tests__/__fixtures__/cars.fixture.ts | 59 +++ docs/examples/.gitkeep | 0 docs/examples/subscribers.ts | 56 +++ package.json | 22 +- src/config/logger.ts | 9 + src/dto/index.ts | 6 + src/dto/mango-options.dto.ts | 35 ++ src/index.ts | 2 + .../accumulator-operators.interface.ts | 5 +- .../aggregation-operators.interface.ts | 6 +- .../aggregation-stages.interface.ts | 5 +- src/interfaces/bucket-stage-auto.interface.ts | 4 +- src/interfaces/bucket-stage.interface.ts | 6 +- src/interfaces/index.ts | 2 + src/interfaces/mango-cache.interface.ts | 8 +- src/interfaces/mango-options.interface.ts | 17 +- .../mango-parser-options.interface.ts | 11 +- src/interfaces/mango-parser.interface.ts | 7 +- src/interfaces/mango.interface.ts | 59 +++ src/interfaces/mingo-options.interface.ts | 27 ++ .../query-criteria-options.interface.ts | 5 +- src/interfaces/query-operators.interface.ts | 6 +- src/mango.ts | 6 - .../__tests__/mango-parser.mixin.spec.ts | 23 +- src/mixins/mango-parser.mixin.ts | 18 +- src/plugins/__tests__/mango.plugin.spec.ts | 446 ++++++++++++++++++ src/plugins/index.ts | 6 + src/plugins/mango.plugin.ts | 388 +++++++++++++++ src/types/document.types.ts | 31 +- src/types/mango.types.ts | 8 +- src/types/mingo.types.ts | 37 +- tsconfig.prod.json | 2 +- yarn.lock | 61 +-- 36 files changed, 1399 insertions(+), 143 deletions(-) delete mode 100644 __tests__/__fixtures__/.gitkeep create mode 100644 __tests__/__fixtures__/cars.fixture.ts delete mode 100644 docs/examples/.gitkeep create mode 100644 docs/examples/subscribers.ts create mode 100644 src/config/logger.ts create mode 100644 src/dto/index.ts create mode 100644 src/dto/mango-options.dto.ts create mode 100644 src/interfaces/mango.interface.ts create mode 100644 src/interfaces/mingo-options.interface.ts delete mode 100644 src/mango.ts create mode 100644 src/plugins/__tests__/mango.plugin.spec.ts create mode 100644 src/plugins/index.ts create mode 100644 src/plugins/mango.plugin.ts diff --git a/.eslintrc.js b/.eslintrc.js index c901489..b47a75b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -110,18 +110,23 @@ module.exports = { 'bool', 'bson', 'commitlint', + 'dto', 'enums', 'enum', 'execa', 'formatter', 'mingo', + 'mparser', 'nullable', 'perf', 'postpublish', 'readonly', 'tgz', 'typeof', + 'uids', + 'uid', 'utf8', + 'vin', 'wip', 'zsh' ], @@ -168,7 +173,11 @@ module.exports = { } }, { - files: ['.eslintrc.*'], + files: [ + '.eslintrc.*', + '__tests__/__fixtures__/cars.fixture.ts', + 'docs/examples/subscribers.ts' + ], rules: { 'spellcheck/spell-checker': 0 } diff --git a/README.md b/README.md index e04cb5d..d8c77fb 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Mango is a MongoDB-like API for in-memory object collections. It combines the power of [mingo][1] and [qs-to-mongo][2] to allow: - running aggregation pipelines -- executing searches with query criteria and options +- performing searches (with query criteria **and** URL queries) - parsing and converting URL query objects and strings ## Installation @@ -39,8 +39,8 @@ power of [mingo][1] and [qs-to-mongo][2] to allow: ## Usage [Configuration](#configuration) -[🚧 Creating a New Mango Query Client](#🚧-creating-a-new-mango-query-client) -[🚧 Mango API](#🚧-mango-api) +[Creating a New Mango Plugin](#creating-a-new-mango-plugin) +[Mango Plugin API](#mango-plugin-api) ### Configuration @@ -52,11 +52,11 @@ power of [mingo][1] and [qs-to-mongo][2] to allow: #### Mingo The `Mango` class integrates with [mingo][1], a MongoDB query language for -in-memory objects, to support aggregation pipelines and querying. +in-memory objects, to support aggregation pipelines and executing searches. Operators loaded by Mango can be viewed in the [config](src/config/mingo.ts) -file. If additional operators are needed, you'll need to load them _before_ -[creating a new query client](#🚧-creating-a-new-mango-query-client). +file. If additional operators are needed, load them _before_ +[creating a new plugin](#creating-a-new-mango-plugin). #### TypeScript @@ -75,21 +75,145 @@ For shorter import paths, TypeScript users can add the following aliases: These aliases will be used in following code examples. -### 🚧 Creating a New Mango Query Client +### Creating a New Mango Plugin -**TODO:** Update documentation. +#### Documents -### 🚧 Mango API +A document is an object from an in-memory collection. Each document should have +a unique identifier (uid). -**TODO:** Update documentation. +By default, this value is assumed to map to the `_id` field of each document. +This can be changed via the [plugin settings](#plugin-settings). + +```typescript +import type { MangoParsedUrlQuery, MangoSearchParams } from '@mango/types' + +export interface ISubscriber { + email: string + first_name: string + last_name: string +} + +export type SubscriberUID = 'email' +export type SubscriberParams = MangoSearchParams +export type SubscriberQuery = MangoParsedUrlQuery +``` + +#### Plugin + +The Mango plugin accepts an options object thats gets passed down to the +[mingo][1] and [qs-to-mongo][2] modules. + +Via the options dto, you can: + +- set initial collection cache +- set uid field for each document +- set date fields and fields searchable by text + +```typescript +import { Mango } from '@mango' +import type { MangoOptionsDTO } from '@mango/dto' + +const options: MangoOptionsDTO = { + cache: { + collection: [ + { + email: 'nmaxstead0@arizona.edu', + first_name: 'Nate', + last_name: 'Maxstead' + }, + { + email: 'rbrisseau1@sohu.com', + first_name: 'Roland', + last_name: 'Brisseau' + }, + { + email: 'ksmidmoor2@sphinn.com', + first_name: 'Kippar', + last_name: 'Smidmoor' + }, + { + email: 'gdurnford3@360.cn', + first_name: 'Godfree', + last_name: 'Durnford' + }, + { + email: 'mfauguel4@webnode.com', + first_name: 'Madelle', + last_name: 'Fauguel' + } + ] + }, + mingo: { idKey: 'email' }, + parser: { + fullTextFields: ['first_name', 'last_name'] + } +} + +export const SubscribersMango = new Mango(options) +``` + +**Note**: All properties are optional. + +To learn more about [qs-to-mongo][3] options, see [Options][4] from the package +documentation. Note that the `objectIdFields` and `parameters` options are not +accepted by the Mango parser. + +### Mango Plugin API + +The Mango plugin allows users to run aggregation pipelines and execute searches +against in-memory object collections. Query documents using a URL query, or +search for them using a query criteria and options object. + +Documentation can be viewed [here](src/plugins/mango.plugin.ts). + +```typescript +/** + * `Mango` plugin interface. + * + * - https://github.com/kofrasa/mingo + * - https://github.com/fox1t/qs-to-mongo + * + * @template D - Document (collection object) + * @template U - Name of document uid field + * @template P - Search parameters (query criteria and options) + * @template Q - Parsed URL query object + */ +export interface IMango< + D extends PlainObject = PlainObject, + U extends keyof D = '_id', + P extends MangoSearchParams = MangoSearchParams, + Q extends MangoParsedUrlQuery = MangoParsedUrlQuery +> { + readonly cache: Readonly> + readonly logger: Debugger + readonly mingo: typeof mingo + readonly mparser: IMangoParser + readonly options: MangoOptions + + aggregate( + pipeline?: OneOrMany> + ): AggregationPipelineResult + find(params?: P): DocumentPartial[] + findByIds(uids?: NumberString[], params?: P): DocumentPartial[] + findOne(uid: NumberString, params?: P): DocumentPartial | null + findOneOrFail(uid: NumberString, params?: P): DocumentPartial + query(query?: Q | string): DocumentPartial[] + queryByIds(uids?: NumberString[], query?: Q | string): DocumentPartial[] + queryOne(uid: NumberString, query?: Q | string): DocumentPartial | null + queryOneOrFail(uid: NumberString, query?: Q | string): DocumentPartial + resetCache(collection?: D[]): MangoCache +} +``` ## Built With - [debug][3] - Debugging utility - [mingo][1] - MongoDB query language for in-memory objects -- [qs-to-mongo][2] - Parse and convert query parameters into MongoDB query - criteria and options +- [qs-to-mongo][2] - Parse and convert URL queries into MongoDB query criteria + and options [1]: https://github.com/kofrasa/mingo [2]: https://github.com/fox1t/qs-to-mongo [3]: https://github.com/visionmedia/debug +[4]: https://github.com/fox1t/qs-to-mongo#options diff --git a/__tests__/__fixtures__/.gitkeep b/__tests__/__fixtures__/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/__tests__/__fixtures__/cars.fixture.ts b/__tests__/__fixtures__/cars.fixture.ts new file mode 100644 index 0000000..741c7aa --- /dev/null +++ b/__tests__/__fixtures__/cars.fixture.ts @@ -0,0 +1,59 @@ +import type { MangoCache } from '@/interfaces' +import type { MangoParsedUrlQuery, MangoSearchParams } from '@/types' + +/** + * @file Global Test Fixture - Cars Collection + * @module tests/fixtures/cars + */ + +export interface ICar { + make: string + model: string + model_year: number + vin: string +} + +export type CarUID = 'vin' +export type CarParams = MangoSearchParams +export type CarQuery = MangoParsedUrlQuery + +export const CARS_IDKEY: CarUID = 'vin' + +export const CARS_MOCK_CACHE_EMPTY: MangoCache = { + collection: Object.freeze([]) +} + +export const CARS_MOCK_CACHE: MangoCache = { + collection: Object.freeze([ + { + make: 'Scion', + model: 'tC', + model_year: 2010, + vin: '3221085d-6f55-4d23-842a-aeb0e413fca8' + }, + { + make: 'Mitsubishi', + model: '3000GT', + model_year: 1999, + vin: '5b38c222-bf0c-4972-9810-d8cd7e399a56' + }, + { + make: 'Nissan', + model: 'Quest', + model_year: 1994, + vin: '6e177f82-055d-464d-b118-cf36b10fb77d' + }, + { + make: 'Chevrolet', + model: 'Aveo', + model_year: 2006, + vin: 'e3df6457-3901-4c25-90cd-6aaabf3cdcb8' + }, + { + make: 'Subaru', + model: 'Impreza', + model_year: 1994, + vin: 'eda31c5a-7b59-4250-b365-e66661930bc8' + } + ]) +} diff --git a/docs/examples/.gitkeep b/docs/examples/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docs/examples/subscribers.ts b/docs/examples/subscribers.ts new file mode 100644 index 0000000..4702894 --- /dev/null +++ b/docs/examples/subscribers.ts @@ -0,0 +1,56 @@ +import { Mango } from '@mango' +import type { MangoOptionsDTO } from '@mango/dto' +import type { MangoParsedUrlQuery, MangoSearchParams } from '@mango/types' + +/** + * @file Example - Subscribers Collection + * @module docs/examples/subscribers + */ + +export interface ISubscriber { + email: string + first_name: string + last_name: string +} + +export type SubscriberUID = 'email' +export type SubscriberParams = MangoSearchParams +export type SubscriberQuery = MangoParsedUrlQuery + +const options: MangoOptionsDTO = { + cache: { + collection: [ + { + email: 'nmaxstead0@arizona.edu', + first_name: 'Nate', + last_name: 'Maxstead' + }, + { + email: 'rbrisseau1@sohu.com', + first_name: 'Roland', + last_name: 'Brisseau' + }, + { + email: 'ksmidmoor2@sphinn.com', + first_name: 'Kippar', + last_name: 'Smidmoor' + }, + { + email: 'gdurnford3@360.cn', + first_name: 'Godfree', + last_name: 'Durnford' + }, + { + email: 'mfauguel4@webnode.com', + first_name: 'Madelle', + last_name: 'Fauguel' + } + ] + }, + mingo: { idKey: 'email' }, + parser: { + fullTextFields: ['first_name', 'last_name'] + } +} + +export const SubscribersMango = new Mango(options) diff --git a/package.json b/package.json index 9f3e26f..4980ad8 100644 --- a/package.json +++ b/package.json @@ -42,16 +42,16 @@ "dependencies": { "@flex-development/exceptions": "2.0.0", "@flex-development/tutils": "1.0.0", - "@types/debug": "latest", - "@types/lodash.isempty": "latest", - "@types/lodash.merge": "latest", - "@types/node": "latest", - "@types/qs": "latest", - "debug": "latest", - "lodash.isempty": "latest", - "lodash.merge": "latest", - "mingo": "latest", - "qs-to-mongo": "latest" + "@types/debug": "4.1.5", + "@types/lodash.isempty": "4.4.6", + "@types/lodash.merge": "4.6.6", + "@types/node": "15.3.1", + "@types/qs": "6.9.6", + "debug": "4.3.1", + "lodash.isempty": "4.4.0", + "lodash.merge": "4.6.2", + "mingo": "4.1.2", + "qs-to-mongo": "2.0.0" }, "devDependencies": { "@babel/eslint-parser": "latest", @@ -63,6 +63,7 @@ "@typescript-eslint/eslint-plugin": "latest", "@typescript-eslint/parser": "latest", "@zerollup/ts-transform-paths": "latest", + "axios": "latest", "dotenv-cli": "latest", "eslint": "latest", "eslint-config-prettier": "latest", @@ -86,7 +87,6 @@ "ts-jest": "27.0.0-next.9", "ts-node": "latest", "ttypescript": "latest", - "type-plus": "latest", "typescript": "4.2.4", "underscore-cli": "latest", "yarn": "latest" diff --git a/src/config/logger.ts b/src/config/logger.ts new file mode 100644 index 0000000..7847c10 --- /dev/null +++ b/src/config/logger.ts @@ -0,0 +1,9 @@ +import debug from 'debug' + +/** + * @file Config - Logger + * @module config/logger + * @see https://github.com/visionmedia/debug + */ + +export default debug('mango') diff --git a/src/dto/index.ts b/src/dto/index.ts new file mode 100644 index 0000000..733048d --- /dev/null +++ b/src/dto/index.ts @@ -0,0 +1,6 @@ +/** + * @file Entry Point - Data Transfer Objects + * @module dto + */ + +export type { MangoOptionsDTO } from './mango-options.dto' diff --git a/src/dto/mango-options.dto.ts b/src/dto/mango-options.dto.ts new file mode 100644 index 0000000..765fdfb --- /dev/null +++ b/src/dto/mango-options.dto.ts @@ -0,0 +1,35 @@ +import type { MangoOptions } from '@/interfaces' +import type { PlainObject } from '@flex-development/tutils' + +/** + * @file Interface - MangoOptions + * @module interfaces/MangoOptions + */ + +/** + * Options accepted by the `Mango` class constructor. + * + * @template D - Document (collection object) + * @template U - Name of document uid field + */ +export interface MangoOptionsDTO< + D extends PlainObject = PlainObject, + U extends keyof D = '_id' +> { + /** + * Initial data cache. + */ + cache?: { collection: D[] } + + /** + * Aggregation and query client options. + * + * See: https://github.com/kofrasa/mingo + */ + mingo?: Partial['mingo']> + + /** + * `MangoParser` options. + */ + parser?: MangoOptions['parser'] +} diff --git a/src/index.ts b/src/index.ts index 7aef486..d7adedf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,9 @@ * @author Lexus Drumgold */ +export * from './dto' export * from './enums' export * from './interfaces' export * from './mixins' +export * from './plugins' export * from './types' diff --git a/src/interfaces/accumulator-operators.interface.ts b/src/interfaces/accumulator-operators.interface.ts index 67effe7..d1e5ed0 100644 --- a/src/interfaces/accumulator-operators.interface.ts +++ b/src/interfaces/accumulator-operators.interface.ts @@ -1,4 +1,5 @@ -import type { Document, Expression } from '@/types' +import type { Expression } from '@/types' +import type { UnknownObject } from '@flex-development/tutils' import type { CustomAccumulator } from './custom-accumulator.interface' /** @@ -14,7 +15,7 @@ import type { CustomAccumulator } from './custom-accumulator.interface' * [1]: https://docs.mongodb.com/manual/reference/operator/aggregation/#accumulators---group- * [2]: https://docs.mongodb.com/manual/reference/operator/aggregation/group */ -export interface AccumulatorOperators { +export interface AccumulatorOperators { /** * Returns the result of a user-defined accumulator function. * diff --git a/src/interfaces/aggregation-operators.interface.ts b/src/interfaces/aggregation-operators.interface.ts index 9da3e61..d7015c9 100644 --- a/src/interfaces/aggregation-operators.interface.ts +++ b/src/interfaces/aggregation-operators.interface.ts @@ -1,7 +1,7 @@ import { BSONTypeAlias } from '@/enums/bson-type-alias.enum' import { BSONTypeCode } from '@/enums/bson-type-code.enum' -import type { Document, Expression } from '@/types' -import type { JSONValue } from '@flex-development/tutils' +import type { Expression } from '@/types' +import type { JSONValue, UnknownObject } from '@flex-development/tutils' import type { AccumulatorOperators } from './accumulator-operators.interface' import type { CustomAccumulator } from './custom-accumulator.interface' @@ -19,7 +19,7 @@ import type { CustomAccumulator } from './custom-accumulator.interface' * * [1]: https://docs.mongodb.com/manual/reference/operator/aggregation/#alphabetical-listing-of-expression-operators */ -export interface AggregationOperators +export interface AggregationOperators extends AccumulatorOperators { /** * Support package users loading additional operators. diff --git a/src/interfaces/aggregation-stages.interface.ts b/src/interfaces/aggregation-stages.interface.ts index 86f8b37..af8fc40 100644 --- a/src/interfaces/aggregation-stages.interface.ts +++ b/src/interfaces/aggregation-stages.interface.ts @@ -1,13 +1,12 @@ import { SortOrder } from '@/enums/sort-order.enum' import type { - Document, DocumentPath, Expression, FieldPath, ProjectStage, QueryCriteria } from '@/types' -import type { OneOrMany } from '@flex-development/tutils' +import type { OneOrMany, PlainObject } from '@flex-development/tutils' import type { RawObject } from 'mingo/util' import type { AccumulatorOperators } from './accumulator-operators.interface' import type { BucketStageAuto } from './bucket-stage-auto.interface' @@ -26,7 +25,7 @@ import type { QueryOperators } from './query-operators.interface' * * [1]: https://docs.mongodb.com/manual/reference/operator/aggregation-pipeline */ -export interface AggregationStages { +export interface AggregationStages { /** * Adds new fields to documents. * diff --git a/src/interfaces/bucket-stage-auto.interface.ts b/src/interfaces/bucket-stage-auto.interface.ts index ac52790..ce4803d 100644 --- a/src/interfaces/bucket-stage-auto.interface.ts +++ b/src/interfaces/bucket-stage-auto.interface.ts @@ -1,4 +1,4 @@ -import type { Document } from '@/types' +import type { UnknownObject } from '@flex-development/tutils' import type { BucketStage } from './bucket-stage.interface' /** @@ -13,7 +13,7 @@ import type { BucketStage } from './bucket-stage.interface' * * [1]: https://docs.mongodb.com/manual/reference/operator/aggregation/bucketAuto */ -export interface BucketStageAuto +export interface BucketStageAuto extends Pick, 'groupBy' | 'output'> { /** * A positive 32-bit integer that specifies the number of buckets into which diff --git a/src/interfaces/bucket-stage.interface.ts b/src/interfaces/bucket-stage.interface.ts index ff965f8..82f2b5e 100644 --- a/src/interfaces/bucket-stage.interface.ts +++ b/src/interfaces/bucket-stage.interface.ts @@ -1,5 +1,5 @@ -import type { Document, Expression } from '@/types' -import type { NumberString } from '@flex-development/tutils' +import type { Expression } from '@/types' +import type { NumberString, UnknownObject } from '@flex-development/tutils' import type { AccumulatorOperators } from './accumulator-operators.interface' /** @@ -14,7 +14,7 @@ import type { AccumulatorOperators } from './accumulator-operators.interface' * * [1]: https://docs.mongodb.com/manual/reference/operator/aggregation/bucket */ -export interface BucketStage { +export interface BucketStage { /** * An array of values based on the `groupBy` expression that specify the * boundaries for each bucket. diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index ad2951c..5166e48 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -14,6 +14,8 @@ export type { MangoCache } from './mango-cache.interface' export type { MangoOptions } from './mango-options.interface' export type { MangoParserOptions } from './mango-parser-options.interface' export type { IMangoParser } from './mango-parser.interface' +export type { IMango } from './mango.interface' +export type { MingoOptions } from './mingo-options.interface' export type { ProjectionOperators } from './projection-operators.interface' export type { QueryCriteriaOptions } from './query-criteria-options.interface' export type { QueryOperators } from './query-operators.interface' diff --git a/src/interfaces/mango-cache.interface.ts b/src/interfaces/mango-cache.interface.ts index 553f27a..cd983bb 100644 --- a/src/interfaces/mango-cache.interface.ts +++ b/src/interfaces/mango-cache.interface.ts @@ -1,4 +1,4 @@ -import type { Document } from '@/types' +import type { PlainObject } from '@flex-development/tutils' /** * @file Interface - MangoCache @@ -6,10 +6,10 @@ import type { Document } from '@/types' */ /** - * `Mango` client data cache. + * `Mango` plugin data cache. * * @template D - Document (collection object) */ -export interface MangoCache { - collection: D[] +export interface MangoCache { + readonly collection: Readonly } diff --git a/src/interfaces/mango-options.interface.ts b/src/interfaces/mango-options.interface.ts index af351ed..451ada9 100644 --- a/src/interfaces/mango-options.interface.ts +++ b/src/interfaces/mango-options.interface.ts @@ -1,5 +1,6 @@ -import type { Options as MingoOptions } from 'mingo/core' +import type { UnknownObject } from '@flex-development/tutils' import type { MangoParserOptions } from './mango-parser-options.interface' +import type { MingoOptions } from './mingo-options.interface' /** * @file Interface - MangoOptions @@ -7,18 +8,24 @@ import type { MangoParserOptions } from './mango-parser-options.interface' */ /** - * Options accepted by the `Mango` class. + * Options used by the `Mango` class. + * + * @template D - Document (collection object) + * @template U - Name of document uid field */ -export interface MangoOptions { +export interface MangoOptions< + D extends UnknownObject = UnknownObject, + U extends keyof D = '_id' +> { /** * Aggregation and query client options. * * See: https://github.com/kofrasa/mingo */ - mingo?: MingoOptions + mingo: MingoOptions /** * `MangoParser` options. */ - parser?: MangoParserOptions + parser: MangoParserOptions } diff --git a/src/interfaces/mango-parser-options.interface.ts b/src/interfaces/mango-parser-options.interface.ts index b79002a..e401a71 100644 --- a/src/interfaces/mango-parser-options.interface.ts +++ b/src/interfaces/mango-parser-options.interface.ts @@ -1,4 +1,5 @@ -import type { OneOrMany } from '@flex-development/tutils' +import type { DocumentPath } from '@/types' +import type { OneOrMany, UnknownObject } from '@flex-development/tutils' import type { CustomQSMongoParser } from './custom-qs-mongo-parser.interface' /** @@ -12,21 +13,23 @@ import type { CustomQSMongoParser } from './custom-qs-mongo-parser.interface' * * **NOTE**: `objectIdFields` and `parameters` are not accepted. * + * @template D - Document (collection object) + * * [1]: https://github.com/fox1t/qs-to-mongo#options */ -export interface MangoParserOptions { +export interface MangoParserOptions { /** * Fields that will be converted to `Date`. If no fields are passed, any valid * date string will be converted to [ISOString][1]. * * [1]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString */ - dateFields?: OneOrMany + dateFields?: OneOrMany> /** * Fields that will be used as criteria when passing `text` query parameter. */ - fullTextFields?: OneOrMany + fullTextFields?: OneOrMany> /** * Array of query parameters that are ignored, in addition to the defaults: diff --git a/src/interfaces/mango-parser.interface.ts b/src/interfaces/mango-parser.interface.ts index 6a0b634..133f88c 100644 --- a/src/interfaces/mango-parser.interface.ts +++ b/src/interfaces/mango-parser.interface.ts @@ -1,4 +1,5 @@ -import type { Document, MangoParsedUrlQuery, MangoSearchParams } from '@/types' +import type { MangoParsedUrlQuery, MangoSearchParams } from '@/types' +import type { UnknownObject } from '@flex-development/tutils' import qsm from 'qs-to-mongo' import type { ParsedOptions } from 'qs-to-mongo/lib/query/options-to-mongo' import type { MangoParserOptions } from './mango-parser-options.interface' @@ -14,9 +15,9 @@ import type { QueryCriteriaOptions } from './query-criteria-options.interface' * * @template D - Document (collection object) */ -export interface IMangoParser { +export interface IMangoParser { readonly parser: typeof qsm - readonly options: MangoParserOptions + readonly options: MangoParserOptions params(query?: MangoParsedUrlQuery | string): MangoSearchParams queryCriteriaOptions(base?: Partial): QueryCriteriaOptions diff --git a/src/interfaces/mango.interface.ts b/src/interfaces/mango.interface.ts new file mode 100644 index 0000000..9e765dc --- /dev/null +++ b/src/interfaces/mango.interface.ts @@ -0,0 +1,59 @@ +import type { + AggregationPipelineResult, + DocumentPartial, + MangoParsedUrlQuery, + MangoSearchParams +} from '@/types' +import type { + NumberString, + OneOrMany, + PlainObject +} from '@flex-development/tutils' +import type { Debugger } from 'debug' +import mingo from 'mingo' +import type { AggregationStages } from './aggregation-stages.interface' +import type { MangoCache } from './mango-cache.interface' +import type { MangoOptions } from './mango-options.interface' +import type { IMangoParser } from './mango-parser.interface' + +/** + * @file Interface - Mango + * @module interfaces/Mango + */ + +/** + * `Mango` plugin interface. + * + * - https://github.com/kofrasa/mingo + * - https://github.com/fox1t/qs-to-mongo + * + * @template D - Document (collection object) + * @template U - Name of document uid field + * @template P - Search parameters (query criteria and options) + * @template Q - Parsed URL query object + */ +export interface IMango< + D extends PlainObject = PlainObject, + U extends keyof D = '_id', + P extends MangoSearchParams = MangoSearchParams, + Q extends MangoParsedUrlQuery = MangoParsedUrlQuery +> { + readonly cache: Readonly> + readonly logger: Debugger + readonly mingo: typeof mingo + readonly mparser: IMangoParser + readonly options: MangoOptions + + aggregate( + pipeline?: OneOrMany> + ): AggregationPipelineResult + find(params?: P): DocumentPartial[] + findByIds(uids?: NumberString[], params?: P): DocumentPartial[] + findOne(uid: NumberString, params?: P): DocumentPartial | null + findOneOrFail(uid: NumberString, params?: P): DocumentPartial + query(query?: Q | string): DocumentPartial[] + queryByIds(uids?: NumberString[], query?: Q | string): DocumentPartial[] + queryOne(uid: NumberString, query?: Q | string): DocumentPartial | null + queryOneOrFail(uid: NumberString, query?: Q | string): DocumentPartial + resetCache(collection?: D[]): MangoCache +} diff --git a/src/interfaces/mingo-options.interface.ts b/src/interfaces/mingo-options.interface.ts new file mode 100644 index 0000000..c9a1b9f --- /dev/null +++ b/src/interfaces/mingo-options.interface.ts @@ -0,0 +1,27 @@ +import type { UnknownObject } from '@flex-development/tutils' +import type { Options } from 'mingo/core' + +/** + * @file Interface - MingoOptions + * @module interfaces/MingoOptions + */ + +/** + * Options used by the [`mingo`][1] module. + * + * @template D - Document (collection object) + * @template U - Name of document uid field + * + * [1]: https://github.com/kofrasa/mingo + */ +export interface MingoOptions< + D extends UnknownObject = UnknownObject, + U extends keyof D = '_id' +> extends Omit { + /** + * Name of the field containing a unique identifier for a document. + * + * @default '_id' + */ + idKey: U +} diff --git a/src/interfaces/query-criteria-options.interface.ts b/src/interfaces/query-criteria-options.interface.ts index 83dd5f9..2238fee 100644 --- a/src/interfaces/query-criteria-options.interface.ts +++ b/src/interfaces/query-criteria-options.interface.ts @@ -1,4 +1,5 @@ -import type { Document, DocumentSortingRules, ProjectStage } from '@/types' +import type { DocumentSortingRules, ProjectStage } from '@/types' +import type { UnknownObject } from '@flex-development/tutils' import type { ParsedOptions } from 'qs-to-mongo/lib/query/options-to-mongo' /** @@ -11,7 +12,7 @@ import type { ParsedOptions } from 'qs-to-mongo/lib/query/options-to-mongo' * * @template D - Document (collection object) */ -export interface QueryCriteriaOptions +export interface QueryCriteriaOptions extends Partial> { $project?: ProjectStage sort?: DocumentSortingRules diff --git a/src/interfaces/query-operators.interface.ts b/src/interfaces/query-operators.interface.ts index 0c166fa..7a1e47c 100644 --- a/src/interfaces/query-operators.interface.ts +++ b/src/interfaces/query-operators.interface.ts @@ -1,7 +1,7 @@ import { BSONTypeAlias } from '@/enums/bson-type-alias.enum' import { BSONTypeCode } from '@/enums/bson-type-code.enum' -import type { Document, Expression } from '@/types' -import type { JSONValue } from '@flex-development/tutils' +import type { Expression } from '@/types' +import type { JSONValue, UnknownObject } from '@flex-development/tutils' /** * @file Interface - QueryOperators @@ -15,7 +15,7 @@ import type { JSONValue } from '@flex-development/tutils' * * [1]: https://docs.mongodb.com/manual/reference/operator/query/#query-selectors */ -export interface QueryOperators { +export interface QueryOperators { /** * Selects the documents where the value of a field is an array that contains * all the specified elements. diff --git a/src/mango.ts b/src/mango.ts deleted file mode 100644 index 48c50c2..0000000 --- a/src/mango.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * @file Mango Client - * @module mango - */ - -export default {} diff --git a/src/mixins/__tests__/mango-parser.mixin.spec.ts b/src/mixins/__tests__/mango-parser.mixin.spec.ts index fa7506f..2f8ae69 100644 --- a/src/mixins/__tests__/mango-parser.mixin.spec.ts +++ b/src/mixins/__tests__/mango-parser.mixin.spec.ts @@ -12,39 +12,51 @@ import TestSubject from '../mango-parser.mixin' describe('unit:mixins/MangoParser', () => { describe('constructor', () => { it('should remove options.objectIdFields', () => { + // Arrange const options = ({ objectIdFields: [] } as unknown) as MangoParserOptions + // Act const mparser = new TestSubject(options) + // Expect // @ts-expect-error testing expect(mparser.options.objectIdFields).not.toBeDefined() }) it('should remove options.parameters', () => { + // Arrange const options = ({ parameters: '' } as unknown) as MangoParserOptions + // Act const mparser = new TestSubject(options) + // Expect // @ts-expect-error testing expect(mparser.options.parameters).not.toBeDefined() }) }) describe('#params', () => { - const Subject = new TestSubject({ fullTextFields: ['created_at', 'id'] }) + const options = { fullTextFields: ['created_at', 'id'] } + + const Subject = new TestSubject(options as MangoParserOptions) const spy_parser = jest.spyOn(Subject, 'parser') it('should parse url query string', () => { + // Arrange const querystring = 'fields=created_at&sort=created_at,-id&limit=10' + // Act Subject.params(querystring) + // Expect expect(spy_parser).toBeCalledTimes(1) expect(spy_parser.mock.calls[0][0]).toBe(querystring) }) it('should parse url query object', () => { + // Arrange const query = { fields: 'created_at,-updated_at', limit: '10', @@ -52,23 +64,27 @@ describe('unit:mixins/MangoParser', () => { sort: 'created_at,-id' } + // Act Subject.params(query) + // Expect expect(spy_parser).toBeCalledTimes(1) expect(spy_parser.mock.calls[0][0]).toBe(query) }) it('should throw Exception if #parser throws', () => { + // Arrange const query = { q: 'will cause error' } - let exception = {} as Exception + // Act try { new TestSubject().params(query) } catch (error) { exception = error } + // Expect expect(exception.code).toBe(ExceptionStatusCode.BAD_REQUEST) expect(exception.data).toMatchObject({ parser_options: {}, query }) }) @@ -78,10 +94,13 @@ describe('unit:mixins/MangoParser', () => { const Subject = new TestSubject() it('should convert base options into QueryCriteriaOptions object', () => { + // Arrange const projection = { created_at: ProjectRule.PICK } + // Act const options = Subject.queryCriteriaOptions({ projection }) + // Expect expect(options.$project).toMatchObject(projection) }) }) diff --git a/src/mixins/mango-parser.mixin.ts b/src/mixins/mango-parser.mixin.ts index e7a3ca0..593beb8 100644 --- a/src/mixins/mango-parser.mixin.ts +++ b/src/mixins/mango-parser.mixin.ts @@ -4,10 +4,14 @@ import type { MangoParserOptions, QueryCriteriaOptions } from '@/interfaces' -import type { Document, MangoParsedUrlQuery, MangoSearchParams } from '@/types' +import type { MangoParsedUrlQuery, MangoSearchParams } from '@/types' import { ExceptionStatusCode } from '@flex-development/exceptions/enums' import Exception from '@flex-development/exceptions/exceptions/base.exception' -import type { OneOrMany, PlainObject } from '@flex-development/tutils' +import type { + OneOrMany, + PlainObject, + UnknownObject +} from '@flex-development/tutils' import qsm from 'qs-to-mongo' import type { ParsedOptions } from 'qs-to-mongo/lib/query/options-to-mongo' @@ -24,7 +28,7 @@ import type { ParsedOptions } from 'qs-to-mongo/lib/query/options-to-mongo' * @class * @implements {IMangoParser} */ -export default class MangoParser +export default class MangoParser implements IMangoParser { /** * @readonly @@ -37,9 +41,9 @@ export default class MangoParser /** * @readonly * @instance - * @property {MangoParserOptions} options - `qs-to-mongo` module options + * @property {MangoParserOptions} options - `qs-to-mongo` module options */ - readonly options: MangoParserOptions + readonly options: MangoParserOptions /** * Creates a new `MangoParser` client. @@ -50,7 +54,7 @@ export default class MangoParser * - https://github.com/fox1t/qs-to-mongo * - https://github.com/kofrasa/mingo * - * @param {MangoParserOptions} [options] - Parser options + * @param {MangoParserOptions} [options] - Parser options * @param {OneOrMany} [options.dateFields] - Fields that will be * converted to `Date`; if no fields are passed, any valid date string will be * converted to an ISO-8601 string @@ -63,7 +67,7 @@ export default class MangoParser * @param {CustomQSMongoParser} [options.parser] - Custom query parser * @param {any} [options.parserOptions] - Custom query parser options */ - constructor(options: MangoParserOptions = {}) { + constructor(options: MangoParserOptions = {}) { const parser_options = Object.assign({}, options) Reflect.deleteProperty(parser_options, 'objectIdFields') diff --git a/src/plugins/__tests__/mango.plugin.spec.ts b/src/plugins/__tests__/mango.plugin.spec.ts new file mode 100644 index 0000000..ba662e0 --- /dev/null +++ b/src/plugins/__tests__/mango.plugin.spec.ts @@ -0,0 +1,446 @@ +import type { MangoOptionsDTO } from '@/dto' +import { SortOrder } from '@/enums/sort-order.enum' +import type { AggregationStages } from '@/interfaces' +import { ExceptionStatusCode } from '@flex-development/exceptions/enums' +import Exception from '@flex-development/exceptions/exceptions/base.exception' +import type { UnknownObject } from '@flex-development/tutils' +import type { + CarParams, + CarQuery, + CarUID, + ICar +} from '@tests/fixtures/cars.fixture' +import { + CARS_IDKEY, + CARS_MOCK_CACHE, + CARS_MOCK_CACHE_EMPTY +} from '@tests/fixtures/cars.fixture' +import faker from 'faker' +import TestSubject from '../mango.plugin' + +/** + * @file Unit Tests - Mango + * @module plugins/tests/Mango + */ + +describe('unit:plugins/Mango', () => { + const dto: MangoOptionsDTO = { + cache: CARS_MOCK_CACHE as MangoOptionsDTO['cache'], + mingo: { idKey: CARS_IDKEY } + } + + const dto_e: MangoOptionsDTO = { + cache: CARS_MOCK_CACHE_EMPTY as MangoOptionsDTO['cache'], + mingo: dto.mingo + } + + const Subject = new TestSubject(dto) + const SubjectE = new TestSubject(dto_e) + + const FUID = `vin-0${faker.datatype.number(5)}` + + describe('constructor', () => { + it('should initialize instance properties', () => { + expect(Subject.cache).toMatchObject(CARS_MOCK_CACHE) + expect(Subject.logger).toBeDefined() + expect(Subject.mingo).toBeDefined() + expect(Subject.mparser).toBeDefined() + expect(Subject.options).toMatchObject({ mingo: dto.mingo, parser: {} }) + }) + }) + + describe('#aggregate', () => { + const spy_aggregate = jest.spyOn(Subject.mingo, 'aggregate') + + it('should not call #mingo.aggregate if cache is empty', () => { + // Arrange + const this_spy_aggregate = jest.spyOn(SubjectE.mingo, 'aggregate') + + // Act + SubjectE.aggregate() + + // Expect + expect(this_spy_aggregate).toBeCalledTimes(0) + }) + + it('should throw Exception if error occurs', () => { + // Arrange + const error_message = 'Test aggregate error' + let exception = {} as Exception + + // Act + try { + spy_aggregate.mockImplementationOnce(() => { + throw new Error(error_message) + }) + + Subject.aggregate() + } catch (error) { + exception = error + } + + // Expect + expect(exception.code).toBe(ExceptionStatusCode.BAD_REQUEST) + expect(exception.data).toMatchObject({ pipeline: [] }) + expect(exception.message).toBe(error_message) + }) + + describe('runs pipeline', () => { + const collection = Subject.cache.collection + const options = Subject.options.mingo + + const stage: AggregationStages = { $count: 'total_cars' } + const pipeline: AggregationStages[] = [stage] + + it('should run pipeline after converting stage into stages array', () => { + // Act + Subject.aggregate(stage) + + // Expect + expect(spy_aggregate).toBeCalledTimes(1) + expect(spy_aggregate).toBeCalledWith(collection, pipeline, options) + }) + + it('should run pipeline with stages array', () => { + // Act + Subject.aggregate(pipeline) + + // Expect + expect(spy_aggregate).toBeCalledTimes(1) + expect(spy_aggregate).toBeCalledWith(collection, pipeline, options) + }) + }) + }) + + describe('#find', () => { + const mockCursor = { + all: jest.fn().mockReturnValue(CARS_MOCK_CACHE.collection), + limit: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + sort: jest.fn().mockReturnThis() + } + + const mockFind = jest.fn().mockReturnValue(mockCursor) + + const spy_mingo_find = jest.spyOn(Subject.mingo, 'find') + + beforeAll(() => { + spy_mingo_find.mockImplementation(mockFind) + }) + + it('should not call #mingo.find if cache is empty', () => { + // Arrange + const this_spy_mingo_find = jest.spyOn(SubjectE.mingo, 'find') + this_spy_mingo_find.mockImplementationOnce(mockFind) + + // Act + SubjectE.find() + + // Expect + expect(SubjectE.mingo.find).toBeCalledTimes(0) + }) + + it('should run aggregation pipeline with $project stage', () => { + // Arrange + const spy_aggregate = jest.spyOn(Subject, 'aggregate') + const options = { $project: { model: true } } + + // Act + Subject.find({ options }) + + // Expect + expect(spy_aggregate).toBeCalledTimes(1) + expect(spy_aggregate).toBeCalledWith({ $project: options.$project }) + }) + + it('should handle query criteria', () => { + // Arrange + const { collection } = Subject.cache + const params = { [CARS_IDKEY]: collection[0][CARS_IDKEY] } + const eargs = [collection, params, {}, Subject.options.mingo] + + // Act + Subject.find(params) + + // Expect + expect(Subject.mingo.find).toBeCalledTimes(1) + expect(Subject.mingo.find).toBeCalledWith(...eargs) + }) + + it('should sort results', () => { + // Arrange + const options = { sort: { [CARS_IDKEY]: SortOrder.ASCENDING } } + + // Act + Subject.find({ options }) + + // Expect + expect(Subject.mingo.find).toBeCalledTimes(1) + expect(mockCursor.sort).toBeCalledWith(options.sort) + }) + + it('should offset results', () => { + // Arrange + const options = { skip: 2 } + + // Act + Subject.find({ options }) + + // Expect + expect(Subject.mingo.find).toBeCalledTimes(1) + expect(mockCursor.skip).toBeCalledWith(options.skip) + }) + + it('should limit results', () => { + // Arrange + const options = { limit: 1 } + + // Act + Subject.find({ options }) + + // Expect + expect(Subject.mingo.find).toBeCalledTimes(1) + expect(mockCursor.limit).toBeCalledWith(options.limit) + }) + + it('should throw Exception if error occurs', () => { + // Arrange + const error_message = 'Test find error' + let exception = {} as Exception + + // Act + try { + spy_mingo_find.mockImplementationOnce(() => { + throw new Error(error_message) + }) + + Subject.find() + } catch (error) { + exception = error + } + + // Expect + expect(exception.code).toBe(ExceptionStatusCode.BAD_REQUEST) + expect(exception.data).toMatchObject({ params: {} }) + expect(exception.message).toBe(error_message) + }) + }) + + describe('#findByIds', () => { + const spy_find = jest.spyOn(Subject, 'find') + + it('should return specified documents', () => { + // Arrange + const { collection } = Subject.cache + const ids = [collection[0].vin, collection[2].vin] + + // Act + const documents = Subject.findByIds(ids) + + // Expect + expect(spy_find).toBeCalledTimes(1) + expect(spy_find).toBeCalledWith({}) + expect(documents.length).toBe(ids.length) + }) + + describe('throws Exception', () => { + it('should throw Exception if error is Error class type', () => { + // Arrange + let exception = {} as Exception + + // Act + try { + // @ts-expect-error mocking + jest.spyOn(Array.prototype, 'includes').mockImplementationOnce(() => { + throw new Error() + }) + + Subject.findByIds() + } catch (error) { + exception = error + } + + // Expect + expect(exception.constructor.name).toBe('Exception') + expect(exception.data).toMatchObject({ params: {}, uids: [] }) + }) + + it('should throw Exception if error is Exception class type', () => { + // Arrange + let exception = {} as Exception + + // Act + try { + spy_find.mockImplementationOnce(() => { + throw new Exception() + }) + + Subject.findByIds() + } catch (error) { + exception = error + } + + // Expect + expect(exception.constructor.name).toBe('Exception') + expect(exception.data).toMatchObject({ params: {}, uids: [] }) + }) + }) + }) + + describe('#findOne', () => { + const spy_find = jest.spyOn(Subject, 'find') + + it('should return document', () => { + // Arrange + const DOCUMENT = Subject.cache.collection[3] + spy_find.mockReturnValue([DOCUMENT]) + + // Act + const result = Subject.findOne(DOCUMENT[CARS_IDKEY]) + + // Expect + expect(spy_find).toBeCalledTimes(1) + expect(spy_find).toBeCalledWith({ [CARS_IDKEY]: DOCUMENT[CARS_IDKEY] }) + expect(result).toMatchObject(DOCUMENT) + }) + + it('should return null if document is not found', () => { + // Arrange + spy_find.mockReturnValue(CARS_MOCK_CACHE_EMPTY.collection as ICar[]) + + // Act + const result = Subject.findOne(FUID) + + // Expect + expect(spy_find).toBeCalledTimes(1) + expect(spy_find).toBeCalledWith({ [CARS_IDKEY]: FUID }) + expect(result).toBe(null) + }) + }) + + describe('#findOneOrFail', () => { + const spy_findOne = jest.spyOn(Subject, 'findOne') + + it('should return document', () => { + // Arrange + const DOCUMENT = Subject.cache.collection[2] + spy_findOne.mockReturnValueOnce(DOCUMENT) + + // Act + const result = Subject.findOneOrFail(DOCUMENT[CARS_IDKEY]) + + // Expect + expect(spy_findOne).toBeCalledTimes(1) + expect(spy_findOne).toBeCalledWith(DOCUMENT[CARS_IDKEY], {}) + expect(result).toMatchObject(DOCUMENT) + }) + + it('should throw Exception if document is not found', () => { + // Arrange + let exception = {} as Exception + + // Act + try { + Subject.findOneOrFail(FUID) + } catch (error) { + exception = error + } + + // Expect + expect(exception.code).toBe(ExceptionStatusCode.NOT_FOUND) + expect(exception.data).toMatchObject({ params: {} }) + expect((exception.errors as UnknownObject)[CARS_IDKEY]).toBe(FUID) + expect(exception.message).toMatch(new RegExp(`"${FUID}" does not exist`)) + }) + }) + + describe('#query', () => { + const spy_mparser_params = jest.spyOn(Subject.mparser, 'params') + const spy_find = jest.spyOn(Subject, 'find') + + beforeEach(() => { + Subject.query() + }) + + it('should call #mparser.params', () => { + expect(spy_mparser_params).toBeCalledTimes(1) + expect(spy_mparser_params).toBeCalledWith(undefined) + }) + + it('should call #find', () => { + expect(spy_find).toBeCalledTimes(1) + }) + }) + + describe('#queryByIds', () => { + const spy_mparser_params = jest.spyOn(Subject.mparser, 'params') + const spy_findByIds = jest.spyOn(Subject, 'findByIds') + + beforeEach(() => { + Subject.queryByIds() + }) + + it('should call #mparser.params', () => { + expect(spy_mparser_params).toBeCalledTimes(1) + expect(spy_mparser_params).toBeCalledWith(undefined) + }) + + it('should call #findByIds', () => { + expect(spy_findByIds).toBeCalledTimes(1) + }) + }) + + describe('#queryOne', () => { + const spy_mparser_params = jest.spyOn(Subject.mparser, 'params') + const spy_findOne = jest.spyOn(Subject, 'findOne') + + beforeEach(() => { + Subject.queryOne(Subject.cache.collection[0][CARS_IDKEY]) + }) + + it('should call #mparser.params', () => { + expect(spy_mparser_params).toBeCalledTimes(1) + expect(spy_mparser_params).toBeCalledWith(undefined) + }) + + it('should call #findOne', () => { + expect(spy_findOne).toBeCalledTimes(1) + }) + }) + + describe('#queryOneOrFail', () => { + const DOCUMENT = Subject.cache.collection[4] + + const spy_mparser_params = jest.spyOn(Subject.mparser, 'params') + const spy_findOneOrFail = jest.spyOn(Subject, 'findOneOrFail') + + beforeEach(() => { + spy_findOneOrFail.mockReturnValueOnce(DOCUMENT) + Subject.queryOneOrFail(DOCUMENT[CARS_IDKEY]) + }) + + it('should call #mparser.params', () => { + expect(spy_mparser_params).toBeCalledTimes(1) + expect(spy_mparser_params).toBeCalledWith(undefined) + }) + + it('should call #findOneOrFail', () => { + expect(spy_findOneOrFail).toBeCalledTimes(1) + }) + }) + + describe('#resetCache', () => { + it('should update documents in cache', () => { + // Arrange + const SubjectE = new TestSubject() + const collection = Subject.cache.collection as ICar[] + + // Act + SubjectE.resetCache(collection) + + // Expect + expect(SubjectE.cache).toMatchObject(Subject.cache) + expect(collection).toIncludeAllMembers(Subject.cache.collection as ICar[]) + }) + }) +}) diff --git a/src/plugins/index.ts b/src/plugins/index.ts new file mode 100644 index 0000000..455ae69 --- /dev/null +++ b/src/plugins/index.ts @@ -0,0 +1,6 @@ +/** + * @file Entry Point - Plugins + * @module plugins + */ + +export { default as Mango } from './mango.plugin' diff --git a/src/plugins/mango.plugin.ts b/src/plugins/mango.plugin.ts new file mode 100644 index 0000000..fcfebec --- /dev/null +++ b/src/plugins/mango.plugin.ts @@ -0,0 +1,388 @@ +import logger from '@/config/logger' +import MINGO from '@/config/mingo' +import type { MangoOptionsDTO } from '@/dto' +import type { + AggregationStages, + IMango, + IMangoParser, + MangoCache, + MangoOptions, + MangoParserOptions, + MingoOptions, + QueryCriteriaOptions +} from '@/interfaces' +import MangoParser from '@/mixins/mango-parser.mixin' +import type { + AggregationPipelineResult, + DocumentPartial, + DocumentSortingRules, + MangoParsedUrlQuery, + MangoSearchParams, + ProjectStage +} from '@/types' +import { ExceptionStatusCode } from '@flex-development/exceptions/enums' +import Exception from '@flex-development/exceptions/exceptions/base.exception' +import type { + NumberString, + OneOrMany, + PlainObject, + UnknownObject +} from '@flex-development/tutils' +import type { Debugger } from 'debug' +import isEmpty from 'lodash.isempty' +import merge from 'lodash.merge' +import type { Options as OriginalMingoOptions } from 'mingo/core' + +/** + * @file Plugin - Mango + * @module plugins/Mango + */ + +/** + * Plugin for [`mingo`][1] and [`qs-to-mongo`][2]. + * + * [1]: https://github.com/kofrasa/mingo + * [2]: https://github.com/fox1t/qs-to-mongo + * + * @template D - Document (collection object) + * @template P - Search parameters (query criteria and options) + * @template Q - Parsed URL query object + * @template U - Name of document uid field + * + * @class + * @implements {IMango} + */ +export default class Mango< + D extends PlainObject = PlainObject, + U extends keyof D = '_id', + P extends MangoSearchParams = MangoSearchParams, + Q extends MangoParsedUrlQuery = MangoParsedUrlQuery +> implements IMango { + /** + * @readonly + * @instance + * @property {Readonly>} cache - Data cache + */ + readonly cache: Readonly> + + /** + * @readonly + * @instance + * @property {Debugger} logger - Internal logger + */ + readonly logger: Debugger = logger.extend('plugin') + + /** + * @readonly + * @instance + * @property {typeof MINGO} mingo - MongoDB query language client + */ + readonly mingo: typeof MINGO = MINGO + + /** + * @readonly + * @instance + * @property {IMangoParser} mparser - MangoParser instance + */ + readonly mparser: IMangoParser + + /** + * @readonly + * @instance + * @property {MangoOptions} options - Plugin options + */ + readonly options: MangoOptions + + /** + * Creates a new Mango plugin. + * + * By default, collection objects are assumed to have an `_id` field that maps + * to a unique identifier (uid) for the document. The name of the uid can be + * changed by setting {@param options.mingo.idKey}. + * + * See: + * + * - https://github.com/kofrasa/mingo + * - https://github.com/fox1t/qs-to-mongo + * + * @param {MangoOptionsDTO} [options] - Plugin options + * @param {MingoOptions} [options.mingo] - Global mingo options + * @param {U} [options.mingo.idKey] - Name of document uid field + * @param {MangoParserOptions} [options.parser] - MangoParser options + */ + constructor({ cache, mingo = {}, parser = {} }: MangoOptionsDTO = {}) { + const { collection = [] } = cache || {} + const { idKey: midk } = mingo + + const documents = Object.freeze(Array.isArray(collection) ? collection : []) + const idKey = (typeof midk === 'string' && midk.length ? midk : '_id') as U + + this.cache = Object.freeze({ collection: documents }) + this.options = { mingo: { ...mingo, idKey }, parser } + this.mparser = new MangoParser(this.options.parser) + } + + /** + * Runs an aggregation pipeline for `this.cache.collection`. + * + * If the cache is empty, a warning will be logged to the console instructing + * developers to call {@method Mango#resetCache}. + * + * @param {OneOrMany>} pipeline - Aggregation stage(s) + * @return {AggregationPipelineResult} Pipeline results + * @throws {Exception} + */ + aggregate( + pipeline: OneOrMany> = [] + ): AggregationPipelineResult { + const collection = Object.assign([], this.cache.collection) + + if (!collection.length) { + this.logger('Cache empty; calling #resetCache before running pipeline.') + return collection + } + + let _pipeline = pipeline as UnknownObject[] + if (!Array.isArray(_pipeline)) _pipeline = [_pipeline] + + const options = this.options.mingo as OriginalMingoOptions + + try { + return this.mingo.aggregate(collection, _pipeline, options) + } catch ({ message, stack }) { + const data = { pipeline: _pipeline } + + throw new Exception(ExceptionStatusCode.BAD_REQUEST, message, data, stack) + } + } + + /** + * Executes a search against `this.cache.collection`. + * + * If the cache is empty, a warning will be logged to the console instructing + * developers to call {@method Mango#resetCache}. + * + * @param {P} [params] - Search parameters + * @param {QueryCriteriaOptions} [params.options] - Search options + * @param {ProjectStage} [params.options.$project] - Fields to include + * @param {number} [params.options.limit] - Limit number of results + * @param {number} [params.options.skip] - Skips the first n documents + * @param {DocumentSortingRules} [params.options.sort] - Sorting rules + * @return {DocumentPartial[]} Search results + * @throws {Exception} + */ + find(params: P = {} as P): DocumentPartial[] { + const { options = {}, ...criteria } = params + const { $project = {}, limit, skip, sort } = options + + const collection = Object.assign([], this.cache.collection) + + if (!collection.length) { + this.logger('Cache empty; consider calling #resetCache before search.') + return collection + } + + let source = Object.assign([], collection) as DocumentPartial[] + + const mingo_options = this.options.mingo as OriginalMingoOptions + + try { + // Pick fields from each document + if ($project && !isEmpty($project)) { + source = this.aggregate({ $project }) as DocumentPartial[] + } + + // Handle query criteria + let cursor = this.mingo.find(source, criteria, {}, mingo_options) + + // Apply sorting rules + if (sort && !isEmpty(sort)) cursor = cursor.sort(sort) + + // Apply offset + if (typeof skip === 'number') cursor = cursor.skip(skip) + + // Limit results + if (typeof limit === 'number') cursor = cursor.limit(limit) + + // Return search results + return cursor.all() as DocumentPartial[] + } catch (error) { + const { message, stack } = error + const data = { params } + + if (error.constructor.name === 'Exception') { + error.data = merge(error.data, data) + throw error + } + + throw new Exception(ExceptionStatusCode.BAD_REQUEST, message, data, stack) + } + } + + /** + * Finds multiple documents by id. + * + * @param {NumberString[]} [uids] - Array of unique identifiers + * @param {P} [params] - Search parameters + * @param {QueryCriteriaOptions} [params.options] - Search options + * @param {ProjectStage} [params.options.$project] - Fields to include + * @param {number} [params.options.limit] - Limit number of results + * @param {number} [params.options.skip] - Skips the first n documents + * @param {DocumentSortingRules} [params.options.sort] - Sorting rules + * @return {DocumentPartial[]} Documents + * @throws {Exception} + */ + findByIds( + uids: NumberString[] = [], + params: P = {} as P + ): DocumentPartial[] { + try { + // Perform search + const documents = this.find(params) + + // Get specified documents + const idKey = this.options.mingo.idKey as string + return documents.filter(doc => uids.includes(doc[idKey] as NumberString)) + } catch (error) { + /* eslint-disable-next-line sort-keys */ + const data = { uids, params } + + if (error.constructor.name === 'Exception') { + error.data = merge(error.data, data) + throw error + } + + const { message, stack } = error + + throw new Exception(ExceptionStatusCode.BAD_REQUEST, message, data, stack) + } + } + + /** + * Finds a document by unique identifier. + * + * Returns `null` if the document isn't found. + * + * @param {NumberString} uid - Unique identifier for document + * @param {P} [params] - Search parameters + * @param {QueryCriteriaOptions} [params.options] - Search options + * @param {ProjectStage} [params.options.$project] - Fields to include + * @param {number} [params.options.limit] - Limit number of results + * @param {number} [params.options.skip] - Skips the first n documents + * @param {DocumentSortingRules} [params.options.sort] - Sorting rules + * @return {DocumentPartial | null} Document or null + * @throws {Exception} + */ + findOne( + uid: NumberString, + params: P = {} as P + ): DocumentPartial | null { + // Perform search + const documents = this.find({ ...params, [this.options.mingo.idKey]: uid }) + const doc = documents[0] + + // Return document or null if not found + return doc && doc[this.options.mingo.idKey as string] === uid ? doc : null + } + + /** + * Finds a document by unique identifier. + * + * Throws an error if the document isn't found. + * + * @param {NumberString} uid - Unique identifier for document + * @param {P} [params] - Search parameters + * @param {QueryCriteriaOptions} [params.options] - Search options + * @param {ProjectStage} [params.options.$project] - Fields to include + * @param {number} [params.options.limit] - Limit number of results + * @param {number} [params.options.skip] - Skips the first n documents + * @param {DocumentSortingRules} [params.options.sort] - Sorting rules + * @return {DocumentPartial} Document + * @throws {Exception} + */ + findOneOrFail(uid: NumberString, params: P = {} as P): DocumentPartial { + const document = this.findOne(uid, params) + + if (!document) { + const { idKey } = this.options.mingo + const uidstr = typeof uid === 'number' ? uid : `"${uid}"` + + const message = `Document with ${idKey} ${uidstr} does not exist` + const data = { errors: { [idKey]: uid }, params } + + throw new Exception(ExceptionStatusCode.NOT_FOUND, message, data) + } + + return document + } + + /** + * Queries `this.cache.collection`. + * + * If the cache is empty, a warning will be logged to the console instructing + * developers to call {@method Mango#resetCache}. + * + * @param {Q | string} [query] - Document query object or string + * @return {DocumentPartial[]} Search results + */ + query(query?: Q | string): DocumentPartial[] { + return this.find(this.mparser.params(query) as P) + } + + /** + * Queries multiple documents by unique identifier. + * + * @param {NumberString[]} [uids] - Array of unique identifiers + * @param {Q | string} [query] - Document query object or string + * @return {DocumentPartial[]} Documents + */ + queryByIds( + uids: NumberString[] = [], + query?: Q | string + ): DocumentPartial[] { + return this.findByIds(uids, this.mparser.params(query) as P) + } + + /** + * Queries a document by unique identifier. + * + * Returns `null` if the document isn't found. + * + * @param {NumberString} uid - Unique identifier for document + * @param {Q | string} [query] - Document query object or string + * @return {DocumentPartial | null} Document or null + */ + queryOne( + uid: NumberString, + query?: Q | string + ): DocumentPartial | null { + return this.findOne(uid, this.mparser.params(query) as P) + } + + /** + * Queries a document by id. + * + * Throws an error if the document isn't found. + * + * @param {NumberString} uid - Unique identifier for document + * @param {Q | string} [query] - Document query object or string + * @return {DocumentPartial} Document + */ + queryOneOrFail(uid: NumberString, query?: Q | string): DocumentPartial { + return this.findOneOrFail(uid, this.mparser.params(query) as P) + } + + /** + * Updates the plugin's the data cache. + * + * @return {MangoCache} Copy of updated cache + */ + resetCache(collection: D[] = []): MangoCache { + const documents = Object.freeze(Object.assign([], collection)) + + // @ts-expect-error resetting cache + this.cache = Object.freeze({ collection: documents }) + + return { ...this.cache } + } +} diff --git a/src/types/document.types.ts b/src/types/document.types.ts index 2fa96d2..302964b 100644 --- a/src/types/document.types.ts +++ b/src/types/document.types.ts @@ -1,7 +1,9 @@ import { SortOrder } from '@/enums/sort-order.enum' import type { + NumberString, ObjectPath, OrPartial, + PlainObject, UnknownObject } from '@flex-development/tutils' @@ -17,7 +19,7 @@ import type { * @template D - Document (collection object) */ export type DocumentEnhanced< - D extends Document = Document + D extends UnknownObject = UnknownObject > = DocumentPartial & { [x: string]: any } @@ -26,35 +28,30 @@ export type DocumentEnhanced< * Response that includes all attributes of a document or a subset. * * Even when a subset of attributes are requested, a partial `Document` response - * will always include the `id` field, or the field used as the id key. + * will always include the `id` field, or the selected uid field. * * @template D - Document (collection object) - * @template ID - Field used as id key + * @template U - Name of document uid field */ export type DocumentPartial< - D extends Document = Document, - ID extends string = 'id' -> = Omit, 'id'> & Record + D extends UnknownObject = UnknownObject, + U extends keyof D = '_id' +> = Omit, 'id'> & Record /** * Nested or top level document key. * * @template D - Document (collection object) */ -export type DocumentPath< - D extends Document = Document -> = ObjectPath extends string ? ObjectPath : never +export type DocumentPath = ObjectPath extends string + ? ObjectPath + : never /** * Document sorting rules. * * @template D - Document (collection object) */ -export type DocumentSortingRules = Partial< - Record, SortOrder> -> - -/** - * Type representing a collection object. - */ -export type Document = UnknownObject +export type DocumentSortingRules< + D extends UnknownObject = UnknownObject +> = Partial, SortOrder>> diff --git a/src/types/mango.types.ts b/src/types/mango.types.ts index f20ef50..abe9135 100644 --- a/src/types/mango.types.ts +++ b/src/types/mango.types.ts @@ -1,6 +1,6 @@ import type { QueryCriteriaOptions } from '@/interfaces' -import type { OneOrMany } from '@flex-development/tutils' -import type { Document, DocumentPath } from './document.types' +import type { OneOrMany, PlainObject } from '@flex-development/tutils' +import type { DocumentPath } from './document.types' import type { QueryCriteria } from './mingo.types' /** @@ -14,7 +14,7 @@ import type { QueryCriteria } from './mingo.types' * @template D - Document (collection object) */ export type MangoSearchParams< - D extends Document = Document + D extends PlainObject = PlainObject > = QueryCriteria & { options?: QueryCriteriaOptions } @@ -24,7 +24,7 @@ export type MangoSearchParams< * * @template D - Document (collection object) */ -export type MangoParsedUrlQuery = Partial< +export type MangoParsedUrlQuery = Partial< Record, OneOrMany> > & { fields?: string diff --git a/src/types/mingo.types.ts b/src/types/mingo.types.ts index 8ef6274..d50ebd0 100644 --- a/src/types/mingo.types.ts +++ b/src/types/mingo.types.ts @@ -4,8 +4,14 @@ import type { ProjectionOperators, QueryOperators } from '@/interfaces' -import type { JSONValue, OneOrMany } from '@flex-development/tutils' -import type { Document, DocumentPath } from './document.types' +import type { + JSONValue, + OneOrMany, + OrPartial, + UnknownObject +} from '@flex-development/tutils' +import { RawArray } from 'mingo/util' +import type { DocumentEnhanced, DocumentPath } from './document.types' /** * @file Type Definitions - Mingo @@ -13,6 +19,15 @@ import type { Document, DocumentPath } from './document.types' * @see https://github.com/kofrasa/mingo */ +/** + * Result from running aggregation pipeline. + * + * @template D - Document (collection object) + */ +export type AggregationPipelineResult< + D extends UnknownObject = UnknownObject +> = OrPartial>[] | RawArray | UnknownObject + /** * Type representing an [Aggregation expression][1]. * @@ -22,7 +37,7 @@ import type { Document, DocumentPath } from './document.types' * * [1]: https://docs.mongodb.com/manual/meta/aggregation-quick-reference/#expressions */ -export type ExpressionBase = +export type ExpressionBase = | AggregationOperators | JSONValue | LiteralExpression @@ -40,7 +55,7 @@ export type ExpressionBase = * * [1]: https://docs.mongodb.com/manual/meta/aggregation-quick-reference/#expression-objects */ -export type ExpressionObject = Record< +export type ExpressionObject = Record< DocumentPath, ExpressionBase > @@ -52,7 +67,7 @@ export type ExpressionObject = Record< * * [1]: https://docs.mongodb.com/manual/meta/aggregation-quick-reference/#expression-objects */ -export type ExpressionObject2 = +export type ExpressionObject2 = | ExpressionObject | Record, ExpressionObject> @@ -63,7 +78,7 @@ export type ExpressionObject2 = * * [1]: https://docs.mongodb.com/manual/meta/aggregation-quick-reference/#expressions */ -export type Expression = +export type Expression = | ExpressionBase | ExpressionObject2 @@ -72,7 +87,9 @@ export type Expression = * * @template D - Document (collection object) */ -export type FieldPath = `$${DocumentPath}` +export type FieldPath< + D extends UnknownObject = UnknownObject +> = `$${DocumentPath}` /** * MongoDB [Literal expression][1]. @@ -90,7 +107,7 @@ export type LiteralExpression = { $literal: T } * * [1]: https://docs.mongodb.com/manual/reference/operator/query/#projection-operators */ -export type Projection = Partial< +export type Projection = Partial< Record, ProjectionOperators> > @@ -101,7 +118,7 @@ export type Projection = Partial< * * [1]: https://docs.mongodb.com/manual/reference/operator/aggregation/project */ -export type ProjectStage = Partial< +export type ProjectStage = Partial< Record, ProjectRule | boolean> > @@ -113,6 +130,6 @@ export type ProjectStage = Partial< * [1]: https://restfulapi.net/json-data-types * [2]: https://docs.mongodb.com/manual/reference/operator/query/#query-selectors */ -export type QueryCriteria = Partial< +export type QueryCriteria = Partial< Record, JSONValue | QueryOperators> > diff --git a/tsconfig.prod.json b/tsconfig.prod.json index 23e4a71..1e8d66a 100644 --- a/tsconfig.prod.json +++ b/tsconfig.prod.json @@ -16,7 +16,7 @@ "rootDir": "./src", "sourceMap": true }, - "exclude": ["**/__mocks__/**", "**/__tests__/**"], + "exclude": ["**/__mocks__/**", "**/__tests__/**", "node_modules"], "extends": "./tsconfig.json", "include": ["src"] } diff --git a/yarn.lock b/yarn.lock index 702ed52..d046ce6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -926,7 +926,7 @@ dependencies: "@babel/types" "^7.3.0" -"@types/debug@latest": +"@types/debug@4.1.5": version "4.1.5" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.5.tgz#b14efa8852b7768d898906613c23f688713e02cd" integrity sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ== @@ -988,7 +988,7 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= -"@types/lodash.isempty@latest": +"@types/lodash.isempty@4.4.6": version "4.4.6" resolved "https://registry.yarnpkg.com/@types/lodash.isempty/-/lodash.isempty-4.4.6.tgz#48a5576985727d9b85d59a60199d6b11ac756a3e" integrity sha512-AauKrFlA4z3Usog5HLGDupKzkCP7h5KXGlfAcRGUfvTmL7guVuEzDSNI6lYJ7syO7J2RE2F47179pSLr26UHIw== @@ -1002,7 +1002,7 @@ dependencies: "@types/lodash" "*" -"@types/lodash.merge@latest": +"@types/lodash.merge@4.6.6": version "4.6.6" resolved "https://registry.yarnpkg.com/@types/lodash.merge/-/lodash.merge-4.6.6.tgz#b84b403c1d31bc42d51772d1cd5557fa008cd3d6" integrity sha512-IB90krzMf7YpfgP3u/EvZEdXVvm4e3gJbUvh5ieuI+o+XqiNEt6fCzqNRaiLlPVScLI59RxIGZMQ3+Ko/DJ8vQ== @@ -1031,7 +1031,7 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-15.0.2.tgz#51e9c0920d1b45936ea04341aa3e2e58d339fb67" integrity sha512-p68+a+KoxpoB47015IeYZYRrdqMUcpbK8re/zpFB8Ld46LHC1lPEbp3EXgkEhAYEcPvjJF6ZO+869SQ0aH1dcA== -"@types/node@latest": +"@types/node@15.3.1": version "15.3.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-15.3.1.tgz#23a06b87eedb524016616e886b116b8fdcb180af" integrity sha512-weaeiP4UF4XgF++3rpQhpIJWsCTS4QJw5gvBhQu6cFIxTwyxWIe3xbnrY/o2lTCQ0lsdb8YIUDUvLR4Vuz5rbw== @@ -1051,7 +1051,7 @@ resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.2.3.tgz#ef65165aea2924c9359205bf748865b8881753c0" integrity sha512-PijRCG/K3s3w1We6ynUKdxEc5AcuuH3NBmMDP8uvKVp6X43UY7NQlTzczakXP3DJR0F4dfNQIGjU2cUeRYs2AA== -"@types/qs@latest": +"@types/qs@6.9.6": version "6.9.6" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.6.tgz#df9c3c8b31a247ec315e6996566be3171df4b3b1" integrity sha512-0/HnwIfW4ki2D8L8c9GVcG5I72s9jP5GSLVF0VIXDW00kmIpA6O33G7a8n59Tmh7Nz0WUC3rSb7PTY/sdW2JzA== @@ -1222,7 +1222,7 @@ acorn@^7.1.1, acorn@^7.4.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.0.5, acorn@^8.1.0: +acorn@^8.1.0: version "8.2.4" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.2.4.tgz#caba24b08185c3b56e3168e97d15ed17f4d31fd0" integrity sha512-Ibt84YwBDDA890eDiDCEqcbwvHlBvzzDkU2cGBBDDI1QWT12jTiXIOn2CIw5KK4i6N5Z2HUxwYjzriDyqaqqZg== @@ -1492,6 +1492,13 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== +axios@latest: + version "0.21.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8" + integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA== + dependencies: + follow-redirects "^1.10.0" + babel-jest@^27.0.0-next.9: version "27.0.0-next.9" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-27.0.0-next.9.tgz#58e0b34e81495ba019d8a18c18f0a2cc5d1ad33e" @@ -2307,7 +2314,7 @@ dateformat@^3.0.0: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== -debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@latest: +debug@4, debug@4.3.1, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== @@ -3089,6 +3096,11 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.1.1.tgz#c4b489e80096d9df1dfc97c79871aea7c617c469" integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA== +follow-redirects@^1.10.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.1.tgz#d9114ded0a1cfdd334e164e6662ad02bfd91ff43" + integrity sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg== + for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -3674,11 +3686,6 @@ is-buffer@^1.1.5: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== -is-buffer@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" - integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== - is-callable@^1.1.4, is-callable@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.3.tgz#8b1e0500b73a1d76c70487636f368e519de8db8e" @@ -4891,7 +4898,7 @@ lodash.clonedeep@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= -lodash.isempty@latest: +lodash.isempty@4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz#6f86cbedd8be4ec987be9aaf33c9684db1b31e7e" integrity sha1-b4bL7di+TsmHvpqvM8loTbGzHn4= @@ -4906,7 +4913,7 @@ lodash.isplainobject@latest: resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= -lodash.merge@latest: +lodash.merge@4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== @@ -5118,7 +5125,7 @@ min-indent@^1.0.0: resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== -mingo@latest: +mingo@4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/mingo/-/mingo-4.1.2.tgz#c8b96c6a78fe9b37b42ea1264d785c8e5cd9eb02" integrity sha512-WQKhtFDV7nRIMlPP7tfo0Zx4bpvmhfBuHKvNPA9DbISjYJtrU4g1kacQPxPhMSEebJ6tGz7pHXDzQOyANpJ9jA== @@ -6098,7 +6105,7 @@ qrcode-terminal@^0.12.0: resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz#bb5b699ef7f9f0505092a3748be4464fe71b5819" integrity sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ== -qs-to-mongo@latest: +qs-to-mongo@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/qs-to-mongo/-/qs-to-mongo-2.0.0.tgz#d4d26dc90395b39924ea565d88765631cc34613b" integrity sha512-OHgOMJnZdxw8pavgMKve/1WHZu2bcHXzPvRMprpVDsVz7c2K6ugXa2me8dbwjkbywvLvspK33zIOHq9OWUVLNw== @@ -7048,15 +7055,6 @@ terminal-link@^2.0.0: ansi-escapes "^4.2.1" supports-hyperlinks "^2.0.0" -tersify@^3.7.7: - version "3.7.8" - resolved "https://registry.yarnpkg.com/tersify/-/tersify-3.7.8.tgz#4ea998262b4c1a701d23a73b01fa8a3bb276fc86" - integrity sha512-fa5WwVR5HDOLb/j1KIl/bw1OijyWTiOPotWLdlzn0HUCDey28Og8i7+e90gM68I167bKIhCkpjHL2+l3j4J7Pg== - dependencies: - acorn "^8.0.5" - is-buffer "^2.0.5" - unpartial "^0.6.3" - test-exclude@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" @@ -7305,14 +7303,6 @@ type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== -type-plus@latest: - version "3.11.0" - resolved "https://registry.yarnpkg.com/type-plus/-/type-plus-3.11.0.tgz#f7dba3df1ac1b4b90b3608d10901fce594095bfa" - integrity sha512-srUIGRTdyQ2rPODOBMuU+ieHFIHfO6BnsO8un5hM4IyZk/cKm2PqImXMCfJDLfMYT0lGBOVkot4QfVligxWgDQ== - dependencies: - tersify "^3.7.7" - unpartial "^0.6.3" - typedarray-to-buffer@^3.1.5: version "3.1.5" resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" @@ -7402,11 +7392,6 @@ universalify@^2.0.0: resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== -unpartial@^0.6.3: - version "0.6.3" - resolved "https://registry.yarnpkg.com/unpartial/-/unpartial-0.6.3.tgz#75557ac68fce3e9fcc983cd76e3d505b3a5ce4a3" - integrity sha512-U4pMxDxAyV7n5Hp6q9OMjCHLkTU6JwTxzignyXv60u44HlhUmaT+u18AAKM87+EtPkdkOlsSRmzJL4OoHROxbw== - unset-value@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559"