From 160a157d330c53fd505bef81f95ed5bd97c52272 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 May 2024 20:12:35 +0000 Subject: [PATCH 1/4] build(deps): bump github/codeql-action from 2 to 3 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 2 to 3. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v2...v3) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml index 75db4b6..21dc055 100644 --- a/.github/workflows/ci-codeql-analysis.yml +++ b/.github/workflows/ci-codeql-analysis.yml @@ -33,7 +33,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -42,4 +42,4 @@ jobs: # queries: ./path/to/local/query, your-org/your-repo/queries@main - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 From b2518ac292bea0e143a06b004eaac167a0240c84 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 May 2024 20:12:38 +0000 Subject: [PATCH 2/4] build(deps): bump actions/setup-node from 3 to 4 Bumps [actions/setup-node](https://github.com/actions/setup-node) from 3 to 4. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/setup-node dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci-build.yml | 4 ++-- .github/workflows/ci-npm-publish.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 1b44dea..f22e529 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -30,7 +30,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 18 registry-url: https://registry.npmjs.org/ @@ -52,7 +52,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 18 registry-url: https://registry.npmjs.org/ diff --git a/.github/workflows/ci-npm-publish.yml b/.github/workflows/ci-npm-publish.yml index b19e92f..dc51904 100644 --- a/.github/workflows/ci-npm-publish.yml +++ b/.github/workflows/ci-npm-publish.yml @@ -21,7 +21,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 18 registry-url: https://registry.npmjs.org/ @@ -43,7 +43,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 18 registry-url: https://registry.npmjs.org/ From a6ae448192faad46a87427a5dd498c2344b6d8c5 Mon Sep 17 00:00:00 2001 From: Ryan Kotzen Date: Fri, 7 Jun 2024 10:39:42 +0200 Subject: [PATCH 3/4] adding types for mongo query & collection --- .eslintrc.json | 3 +- .vscode/settings.json | 2 +- lib/Collection.js | 98 +++++++++++++++++++++++------------ lib/MongoQuery.js | 117 +++++++++++++++++++++++++++++------------- lib/types.ts | 76 +++++++++++++++++++++++++++ 5 files changed, 226 insertions(+), 70 deletions(-) create mode 100644 lib/types.ts diff --git a/.eslintrc.json b/.eslintrc.json index 559677e..32a0997 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -5,13 +5,14 @@ "es6": true, "mocha": true }, - "parserOptions": { "ecmaVersion": 2018 }, + "parserOptions": {"ecmaVersion": 2018}, "extends": ["eslint:recommended", "google", "prettier"], "globals": {}, "rules": { "indent": ["error", 4, {"SwitchCase": 1}], "max-len": ["off"], "no-prototype-builtins": "off", + "valid-jsdoc": "off", "require-jsdoc": [ "warn", { diff --git a/.vscode/settings.json b/.vscode/settings.json index 1af91d7..ad64faa 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "cSpell.words": ["Aggr"] + "cSpell.words": ["Aggr", "deepmerge", "Mergeable"] } diff --git a/lib/Collection.js b/lib/Collection.js index 04added..9b5cd42 100644 --- a/lib/Collection.js +++ b/lib/Collection.js @@ -17,11 +17,12 @@ const _defaultQueryTimeoutMS = 120000; /** A wrapper around the mongodb collection with some helper functions * @class Collection + * @template {Document} TSchema */ class Collection { /** Creates an instance of Collection. * - * @param {import('mongodb').Collection} collection - the underlying mongo collection + * @param {import('mongodb').Collection} collection - the underlying mongo collection * @param {object} [options] - the query options * @param {number} [options.queryTimeout] - the query timeout defaults to 120000ms * @param {boolean} [options.estimatedDocumentCount] - use estimated document count when calling count @@ -37,9 +38,9 @@ class Collection { /** * Counts the documents in the collection * @deprecated in favour of using countDocuments - * @param {Object} query + * @param {MongoQuery} query * @param {EstimatedDocumentCountOptions | CountDocumentsOptions} [options={}] - * @param {Function} callback + * @param {CountDocumentsCallback} callback * @return {*} */ count(query, options = {}, callback) { @@ -47,10 +48,14 @@ class Collection { } /** + * @callback CountDocumentsCallback + * @param {Error|null} [err] + * @param {number} [count] + * * Counts the documents in the collection - * @param {Object} query - * @param {EstimatedDocumentCountOptions | CountDocumentsOptions} [options={}] - * @param {Function} callback + * @param {MongoQuery} query + * @param {EstimatedDocumentCountOptions | CountDocumentsOptions|CountDocumentsCallback} [options={}] + * @param {CountDocumentsCallback} callback * @return {*} */ countDocuments(query, options = {}, callback) { @@ -60,7 +65,9 @@ class Collection { } else { options = options || {}; } - if (!$check.instanceStrict(query, MongoQuery)) return callback('Invalid query object'); + if (!$check.instanceStrict(query, MongoQuery)) { + return callback(new Error('Invalid query object')); + } const filter = query.parsedQuery.query || {}; this._collection @@ -77,9 +84,9 @@ class Collection { /** * Creates a cursor given the MongoQuery Object - * @param {Object} query - the MongoQuery Object - * @param {import('mongodb').FindOptions} [options={}] - * @return {import('mongodb').FindCursor} + * @param {MongoQuery} query - the MongoQuery Object + * @param {import('mongodb').FindOptions} [options={}] + * @return {import('mongodb').FindCursor} */ queryAsCursor(query, options = {}) { if (!$check.instanceStrict(query, MongoQuery)) throw new Error('Invalid query object'); @@ -113,11 +120,16 @@ class Collection { return cursor; } + /** + * @callback QueryCallback + * @param {Error|null} [err] + * @param {TSchema[]} [results] + */ /** * Executes the MongoQuery as an Array - * @param {Object} query - the MongoQuery Object - * @param {import('mongodb').FindOptions} [options={}] - * @param {function} callback + * @param {MongoQuery} query - the MongoQuery Object + * @param {import('mongodb').FindOptions} [options={}] + * @param {QueryCallback} callback * @return {*} */ query(query, options = {}, callback) { @@ -127,18 +139,23 @@ class Collection { } else { options = options || {}; } - if (!$check.instanceStrict(query, MongoQuery)) return callback('Invalid query object'); + if (!$check.instanceStrict(query, MongoQuery)) { + return callback(new Error('Invalid query object')); + } try { const cursor = this.queryAsCursor(query, options); - cursor.toArray().then((results)=>{ - if (!results) { - return callback(null, []); - } - return callback(null, results); - }).catch((err) => { - return callback(err); - }); + cursor + .toArray() + .then((results) => { + if (!results) { + return callback(null, []); + } + return callback(null, results); + }) + .catch((err) => { + return callback(err); + }); } catch (exp) { return callback(exp); } @@ -146,7 +163,7 @@ class Collection { /** * Executes the MongoQuery as a stream - * @param {Object} query - the MongoQuery Object + * @param {MongoQuery} query - the MongoQuery Object * @param {import('mongodb').FindOptions} [options={}] * @param {CursorStreamOptions} [streamOptions={}] * @return {import('stream').Readable} @@ -160,16 +177,28 @@ class Collection { } /** - * @param {Object} options + * @callback UpdateStatsCallback + * @param {Error|null} [err] + * @param {import('mongodb'.UpdateResult} [result] + * // + * @param {import('./types').UpdateStatsOptions} options * @param {function} callback - * @return {function} + * @return {UpdateStatsCallback} * @memberof Collection */ updateStats(options, callback) { - if (!options.statsField) return callback('Missing stats field'); - if (!options.increments) return callback('Missing increments field'); - if (!options.date) return callback('Missing date field'); - if (!options.query) return callback('Missing query'); + if (!options.statsField) { + return callback(new Error('Missing stats field')); + } + if (!options.increments) { + return callback(new Error('Missing increments field')); + } + if (!options.date) { + return callback(new Error('Missing date field')); + } + if (!options.query) { + return callback(new Error('Missing query')); + } let incrementFields = null; if ($check.array(options.increments)) { @@ -196,14 +225,17 @@ class Collection { update.$inc[dayPath + '.' + increment.field] = increment.value; update.$inc[hourPath + '.' + increment.field] = increment.value; } - this._collection.updateOne(options.query, update).then((result) => { - return callback(null, result); - }).catch(callback); + this._collection + .updateOne(options.query, update) + .then((result) => { + return callback(null, result); + }) + .catch(callback); } /** * Gets the underlying MongoDB collection - * @return {import('mongodb').Collection} + * @return {import('mongodb').Collection} */ get collection() { return this._collection; diff --git a/lib/MongoQuery.js b/lib/MongoQuery.js index fa274be..2314186 100644 --- a/lib/MongoQuery.js +++ b/lib/MongoQuery.js @@ -5,16 +5,24 @@ const oDataParser = require('./oDataParser.js'); const utils = require('./Utils.js'); const _defaultLimit = 50; +/** + * @typedef {import('./types').ParsedQuery} ParsedQuery + * @typedef {import('mongodb').Document} Document + */ + /** * Provides functions to manage mongo query objects. A mongo query object contains specifiers for a mongo query + * @template {Document} TSchema */ class MongoQuery { /** Constructs a Mongo Query object * - * @param {object} query - the query object - * @param {object} [defaults] - the defaults for the mongo query + * @param {import('./types').Query|string} query - the query object + * @param {import('mongodb').Filter} [defaults] - the defaults for the mongo query + * @param {TSchema} [type] used to infer the types, not required */ - constructor(query, defaults = {}) { + constructor(query, defaults = {}, type) { + /** @type {import('./types').Query} */ let originalQuery = query; if (!$check.assigned(query)) { originalQuery = {}; @@ -29,6 +37,7 @@ class MongoQuery { } this.originalQuery = originalQuery; + /** @type {ParsedQuery} */ this.parsedQuery = {}; this.parsedQuery.select = getSelect(originalQuery); this.parsedQuery.projection = getProjection(originalQuery); @@ -38,15 +47,20 @@ class MongoQuery { this.parsedQuery.sort = this.parsedQuery.orderBy; this.parsedQuery.skip = getSkip(originalQuery); - if (originalQuery.$rawQuery) this.parsedQuery.rawQuery = MongoQuery.parseRawQuery(originalQuery.$rawQuery); - if (originalQuery.$filter) this.parsedQuery.filter = oDataParser.parse(originalQuery.$filter); + if (originalQuery.$rawQuery) { + this.parsedQuery.rawQuery = MongoQuery.parseRawQuery(originalQuery.$rawQuery); + } + if (originalQuery.$filter) { + this.parsedQuery.filter = oDataParser.parse(originalQuery.$filter); + } this.parsedQuery.query = getQuery(this.parsedQuery, defaults); } /** Parses a string query to a object * - * @param {string} rawQueryString - the raw query string to parse - * @return {null|*} + * @param {string|Record} rawQueryString - the raw query string to parse + * @return {null|import('mongodb').Filter} + * @template {Document} TSchema */ static parseRawQuery(rawQueryString) { if (!rawQueryString) { @@ -112,10 +126,11 @@ class MongoQuery { /** Merges mongo queries * - * @param {string|object} fromQuery - the query to merge from - * @param {string|object} toQuery - the query to merge into + * @param {string|import('mongodb').Filter} fromQuery - the query to merge from + * @param {string|import('mongodb').Filter} toQuery - the query to merge into * @param {string} [type] - the type of merge, and/or. default is and - * @return {{$and: [any, *]}|{$and}|*|null} + * @return {import('mongodb').Filter|null} + * @template {Document} TSchema */ static mergeQuery(fromQuery, toQuery, type) { if (!fromQuery && !toQuery) { @@ -160,11 +175,14 @@ class MongoQuery { /** Retrieves the projection clause * - * @param {object} query - the query object - * @return {null|*} + * @param {import('./types').Query} query - the query object + * @return {import('./types').ProjectionOrSort | null} + * @template {Document} TSchema */ function getProjection(query) { - if (!query.$projection) return null; + if (!query.$projection) { + return null; + } let projection = null; if ($check.string(query.$projection)) { @@ -181,12 +199,16 @@ function getProjection(query) { /** Retrieves the projections from a $select clause * - * @param {object} query - the query object - * @return {{}|null} + * @param {import('./types').Query} query - the query object + * @return {import('./types').Select|null} + * @template {Document} TSchema */ function getSelect(query) { - if (!query.$select) return null; + if (!query.$select) { + return null; + } + /** @type {import('./types').Select} */ const selectedFields = {}; let hasNegative = false; let hasPositive = false; @@ -209,32 +231,45 @@ function getSelect(query) { /** Gets the sort from a sort order by field * - * @param {object} query - the query object - * @return {{}|null} + * @param {import('./types').Query} query - the query object + * @return {import('./types').ProjectionOrSort | null} + * @template {Document} TSchema */ function getSortOrOrderBy(query) { - if (!query.$orderby && !query.$sort) return null; const sortStr = query.$orderby ? query.$orderby : query.$sort; + if (!sortStr) { + return null; + } return sortStr.split(',').reduce((orderByFields, orderByField) => { orderByField = orderByField.trim(); - if (orderByField.endsWith(' desc')) orderByFields[orderByField.replace(' desc', '')] = -1; - else if (orderByField.endsWith(' asc')) orderByFields[orderByField.replace(' asc', '')] = 1; - else if (orderByField.startsWith('-')) orderByFields[orderByField.substring(1)] = -1; - else if (orderByField.startsWith('+')) orderByFields[orderByField.substring(1)] = 1; - else orderByFields[orderByField] = 1; + if (orderByField.endsWith(' desc')) { + orderByFields[orderByField.replace(' desc', '')] = -1; + } else if (orderByField.endsWith(' asc')) { + orderByFields[orderByField.replace(' asc', '')] = 1; + } else if (orderByField.startsWith('-')) { + orderByFields[orderByField.substring(1)] = -1; + } else if (orderByField.startsWith('+')) { + orderByFields[orderByField.substring(1)] = 1; + } else { + orderByFields[orderByField] = 1; + } return orderByFields; }, {}); } /** Retrieves the limit from the top or limit clause * - * @param {object} query - the query object + * @param {import('./types').Query} query - the query object * @return {number} + * @template {Document} TSchema */ function getTopOrLimit(query) { let limit = _defaultLimit; - if (query.$top) limit = parseInt(query.$top); - else if (query.$limit) limit = parseInt(query.$limit); + if (query.$top) { + limit = typeof query.$top === 'string' ? parseInt(query.$top) : query.$top; + } else if (query.$limit) { + limit = typeof query.$limit === 'string' ? parseInt(query.$limit) : query.$limit; + } if (isNaN(limit)) { limit = _defaultLimit; @@ -245,13 +280,18 @@ function getTopOrLimit(query) { /** Retrieves the skip value from a query * - * @param {object} query - the query object + * @param {import('./types').Query} query - the query object * @return {number} + * @template {Document} TSchema */ function getSkip(query) { - if (!query.$skip) return 0; - let skip = parseInt(query.$skip); - if (isNaN(skip)) skip = 0; + if (!query.$skip) { + return 0; + } + let skip = typeof query.$skip === 'string' ? parseInt(query.$skip) : query.$skip; + if (isNaN(skip)) { + skip = 0; + } return skip; } @@ -319,15 +359,22 @@ function combineMerge(target, source, options) { /** Retrieves the query component from a query object * - * @param {object} parsedQuery - the parsed query object to get the final query from + * @param {ParsedQuery} parsedQuery - the parsed query object to get the final query from * @param {object} [defaults] - the default query to merge in regardless * @return {*} + * @template {} TSchema */ function getQuery(parsedQuery, defaults) { let where = {}; - if (parsedQuery.filter) where = $merge(where, parsedQuery.filter, {isMergeableObject: isMergeableObject, arrayMerge: combineMerge}); - if (parsedQuery.rawQuery) where = $merge(where, parsedQuery.rawQuery, {isMergeableObject: isMergeableObject, arrayMerge: combineMerge}); - if (defaults) where = $merge(where, defaults, {isMergeableObject: isMergeableObject, arrayMerge: combineMerge}); + if (parsedQuery.filter) { + where = $merge(where, parsedQuery.filter, {isMergeableObject: isMergeableObject, arrayMerge: combineMerge}); + } + if (parsedQuery.rawQuery) { + where = $merge(where, parsedQuery.rawQuery, {isMergeableObject: isMergeableObject, arrayMerge: combineMerge}); + } + if (defaults) { + where = $merge(where, defaults, {isMergeableObject: isMergeableObject, arrayMerge: combineMerge}); + } return Object.keys(where).length === 0 ? {} : where; } diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..51b4572 --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,76 @@ +import {Filter, Document} from 'mongodb'; +import {MomentInput} from 'moment'; + +export interface Query { + /** A comma separated list of fields to select. Start with a `-` character to exclude it. Can only have all negatives or all positives in one query */ + $select?: string; + /** A JSON string or a projection object */ + $projection?: string | ProjectionOrSort; + /** + * Comma separated string specifying the sort order for results. + * Start with `+` or `-` for ascending/descending + * Alternatively end with ` desc` or ` asc` for ascending/descending + * Default ordering if neither are specified is ascending + * @alias sort + * @default ascending + * @example + * const query = { + * $orderBy: "+name,-description,price desc,date asc,index" + * } + * */ + $orderby?: string; + /** + * $orderby takes priority over $sort, see it for docs + * @alias $orderby + */ + $sort?: string; + /** + * specifies how many results to return + * @alias $limit + * @default 50 + */ + $top?: number | string; + /** + * $top takes priority over limit + * @alias $top + */ + $limit?: number | string; + /** Specifies how many results to skip */ + $skip?: number | string; + /** A raw mongodb query or JSON string of one, will look through the object for $date, $objectId, $int, $float, $bool, $string */ + $rawQuery?: string | Filter; + $filter?: string; +} + +export interface ParsedQuery { + select: Select | null; + projection: ProjectionOrSort | null; + orderBy: ProjectionOrSort | null; + limit: number; + top: number; + sort: ProjectionOrSort | null; + skip: number; + rawQuery?: Filter | null; + filter?: Filter | null; + query: Filter; +} + +export type Select = { + [key in keyof TSchema]?: boolean; +}; + +export type ProjectionOrSort = { + [key in keyof TSchema]?: 1 | -1; +}; + +export interface UpdateStatsOptions { + statsField: string; + increments: Increment[] | Increment; + date: MomentInput; + query: Filter; +} + +export interface Increment { + value: number; + field: string; +} From c49fb3fff7b82fbfa0ef3474a92a2521cc195804 Mon Sep 17 00:00:00 2001 From: Ryan Kotzen Date: Fri, 7 Jun 2024 10:40:06 +0200 Subject: [PATCH 4/4] 2.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 968ecf3..a1f0d5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@synatic/mongo-magic", - "version": "2.1.0", + "version": "2.2.0", "description": "Synatic utility classes for interacting with MongoDB", "main": "index.js", "files": [