|
| 1 | +// (c) Copyright 2023, SAP SE and ClearlyDefined contributors. Licensed under the MIT license. |
| 2 | +// SPDX-License-Identifier: MIT |
| 3 | + |
| 4 | +const MongoClient = require('mongodb').MongoClient |
| 5 | +const promiseRetry = require('promise-retry') |
| 6 | +const EntityCoordinates = require('../../lib/entityCoordinates') |
| 7 | +const logger = require('../logging/logger') |
| 8 | +const { get } = require('lodash') |
| 9 | +const base64 = require('base-64') |
| 10 | + |
| 11 | +const sortOptions = { |
| 12 | + type: ['coordinates.type'], |
| 13 | + provider: ['coordinates.provider'], |
| 14 | + name: ['coordinates.name', 'coordinates.revision'], |
| 15 | + namespace: ['coordinates.namespace', 'coordinates.name', 'coordinates.revision'], |
| 16 | + revision: ['coordinates.revision'], |
| 17 | + license: ['licensed.declared'], |
| 18 | + releaseDate: ['described.releaseDate'], |
| 19 | + licensedScore: ['licensed.score.total'], |
| 20 | + describedScore: ['described.score.total'], |
| 21 | + effectiveScore: ['scores.effective'], |
| 22 | + toolScore: ['scores.tool'] |
| 23 | +} |
| 24 | + |
| 25 | +const valueTransformers = { |
| 26 | + 'licensed.score.total': value => value && parseInt(value), |
| 27 | + 'described.score.total': value => value && parseInt(value), |
| 28 | + 'scores.effective': value => value && parseInt(value), |
| 29 | + 'scores.tool': value => value && parseInt(value) |
| 30 | +} |
| 31 | + |
| 32 | +const SEPARATOR = '&' |
| 33 | + |
| 34 | +class AbstractMongoDefinitionStore { |
| 35 | + constructor(options) { |
| 36 | + this.logger = options.logger || logger() |
| 37 | + this.options = options |
| 38 | + } |
| 39 | + |
| 40 | + initialize() { |
| 41 | + return promiseRetry(async retry => { |
| 42 | + try { |
| 43 | + this.client = await MongoClient.connect(this.options.connectionString, { useNewUrlParser: true }) |
| 44 | + this.db = this.client.db(this.options.dbName) |
| 45 | + this.collection = this.db.collection(this.options.collectionName) |
| 46 | + } catch (error) { |
| 47 | + retry(error) |
| 48 | + } |
| 49 | + }) |
| 50 | + } |
| 51 | + |
| 52 | + async close() { |
| 53 | + await this.client.close() |
| 54 | + } |
| 55 | + |
| 56 | + /** |
| 57 | + * List all of the matching components for the given coordinates. |
| 58 | + * Accepts partial coordinates. |
| 59 | + * |
| 60 | + * @param {EntityCoordinates} coordinates |
| 61 | + * @returns A list of matching coordinates i.e. [ 'npm/npmjs/-/JSONStream/1.3.3' ] |
| 62 | + */ |
| 63 | + // eslint-disable-next-line no-unused-vars |
| 64 | + async list(coordinates) { |
| 65 | + throw new Error('Unsupported Operation') |
| 66 | + } |
| 67 | + |
| 68 | + /** |
| 69 | + * Get and return the object at the given coordinates. |
| 70 | + * |
| 71 | + * @param {Coordinates} coordinates - The coordinates of the object to get |
| 72 | + * @returns The loaded object |
| 73 | + */ |
| 74 | + // eslint-disable-next-line no-unused-vars |
| 75 | + async get(coordinates) { |
| 76 | + throw new Error('Unsupported Operation') |
| 77 | + } |
| 78 | + |
| 79 | + /** |
| 80 | + * Query and return the objects based on the query |
| 81 | + * |
| 82 | + * @param {object} query - The filters and sorts for the request |
| 83 | + * @returns The data and continuationToken if there is more results |
| 84 | + */ |
| 85 | + async find(query, continuationToken = '', pageSize = 100, projection) { |
| 86 | + const sort = this._buildSort(query) |
| 87 | + const combinedFilters = this._buildQueryWithPaging(query, continuationToken, sort) |
| 88 | + this.logger.debug(`filter: ${JSON.stringify(combinedFilters)}\nsort: ${JSON.stringify(sort)}`) |
| 89 | + const cursor = await this.collection.find(combinedFilters, { |
| 90 | + projection, |
| 91 | + sort, |
| 92 | + limit: pageSize |
| 93 | + }) |
| 94 | + const data = await cursor.toArray() |
| 95 | + continuationToken = this._getContinuationToken(pageSize, data, sort) |
| 96 | + return { data, continuationToken } |
| 97 | + } |
| 98 | + |
| 99 | + // eslint-disable-next-line no-unused-vars |
| 100 | + async store(definition) { |
| 101 | + throw new Error('Unsupported Operation') |
| 102 | + } |
| 103 | + |
| 104 | + // eslint-disable-next-line no-unused-vars |
| 105 | + async delete(coordinates) { |
| 106 | + throw new Error('Unsupported Operation') |
| 107 | + } |
| 108 | + |
| 109 | + getId(coordinates) { |
| 110 | + if (!coordinates) return '' |
| 111 | + return EntityCoordinates.fromObject(coordinates) |
| 112 | + .toString() |
| 113 | + .toLowerCase() |
| 114 | + } |
| 115 | + |
| 116 | + buildQuery(parameters) { |
| 117 | + const filter = { } |
| 118 | + if (parameters.type) filter['coordinates.type'] = parameters.type |
| 119 | + if (parameters.provider) filter['coordinates.provider'] = parameters.provider |
| 120 | + if (parameters.namespace) filter['coordinates.namespace'] = parameters.namespace |
| 121 | + if (parameters.name) filter['coordinates.name'] = parameters.name |
| 122 | + if (parameters.type === null) filter['coordinates.type'] = null |
| 123 | + if (parameters.provider === null) filter['coordinates.provider'] = null |
| 124 | + if (parameters.name === null) filter['coordinates.name'] = null |
| 125 | + if (parameters.namespace === null) filter['coordinates.namespace'] = null |
| 126 | + if (parameters.license) filter['licensed.declared'] = parameters.license |
| 127 | + if (parameters.releasedAfter) filter['described.releaseDate'] = { $gt: parameters.releasedAfter } |
| 128 | + if (parameters.releasedBefore) filter['described.releaseDate'] = { $lt: parameters.releasedBefore } |
| 129 | + if (parameters.minEffectiveScore) filter['scores.effective'] = { $gt: parseInt(parameters.minEffectiveScore) } |
| 130 | + if (parameters.maxEffectiveScore) filter['scores.effective'] = { $lt: parseInt(parameters.maxEffectiveScore) } |
| 131 | + if (parameters.minToolScore) filter['scores.tool'] = { $gt: parseInt(parameters.minToolScore) } |
| 132 | + if (parameters.maxToolScore) filter['scores.tool'] = { $lt: parseInt(parameters.maxToolScore) } |
| 133 | + if (parameters.minLicensedScore) filter['licensed.score.total'] = { $gt: parseInt(parameters.minLicensedScore) } |
| 134 | + if (parameters.maxLicensedScore) filter['licensed.score.total'] = { $lt: parseInt(parameters.maxLicensedScore) } |
| 135 | + if (parameters.minDescribedScore) filter['described.score.total'] = { $gt: parseInt(parameters.minDescribedScore) } |
| 136 | + if (parameters.maxDescribedScore) filter['described.score.total'] = { $lt: parseInt(parameters.maxDescribedScore) } |
| 137 | + return filter |
| 138 | + } |
| 139 | + |
| 140 | + _buildSort(parameters) { |
| 141 | + const sort = sortOptions[parameters.sort] || [] |
| 142 | + const clause = {} |
| 143 | + sort.forEach(item => clause[item] = parameters.sortDesc ? -1 : 1) |
| 144 | + //Always sort ascending on partitionKey for continuation token |
| 145 | + const coordinateKey = this.getCoordinatesKey() |
| 146 | + clause[coordinateKey] = 1 |
| 147 | + return clause |
| 148 | + } |
| 149 | + |
| 150 | + _buildQueryWithPaging(query, continuationToken, sort) { |
| 151 | + const filter = this.buildQuery(query) |
| 152 | + const paginationFilter = this._buildPaginationQuery(continuationToken, sort) |
| 153 | + return paginationFilter ? { $and: [filter, paginationFilter] } : filter |
| 154 | + } |
| 155 | + |
| 156 | + _buildPaginationQuery(continuationToken, sort) { |
| 157 | + if (!continuationToken.length) return |
| 158 | + const queryExpressions = this._buildQueryExpressions(continuationToken, sort) |
| 159 | + return queryExpressions.length <= 1 ? |
| 160 | + queryExpressions [0] : |
| 161 | + { $or: [ ...queryExpressions ] } |
| 162 | + } |
| 163 | + |
| 164 | + _buildQueryExpressions(continuationToken, sort) { |
| 165 | + const lastValues = base64.decode(continuationToken) |
| 166 | + const sortValues = lastValues.split(SEPARATOR).map(value => value.length ? value : null) |
| 167 | + |
| 168 | + const queryExpressions = [] |
| 169 | + const sortConditions = Object.entries(sort) |
| 170 | + for (let nSorts = 1; nSorts <= sortConditions.length; nSorts++) { |
| 171 | + const subList = sortConditions.slice(0, nSorts) |
| 172 | + queryExpressions.push(this._buildQueryExpression(subList, sortValues)) |
| 173 | + } |
| 174 | + return queryExpressions |
| 175 | + } |
| 176 | + |
| 177 | + _buildQueryExpression(sortConditions, sortValues) { |
| 178 | + return sortConditions.reduce((filter, [sortField, sortDirection], index) => { |
| 179 | + const transform = valueTransformers[sortField] |
| 180 | + let sortValue = sortValues[index] |
| 181 | + sortValue = transform ? transform(sortValue) : sortValue |
| 182 | + const isLast = index === sortConditions.length - 1 |
| 183 | + const filterForSort = this._buildQueryForSort(isLast, sortField, sortValue, sortDirection) |
| 184 | + return { ...filter, ...filterForSort} |
| 185 | + }, {}) |
| 186 | + } |
| 187 | + |
| 188 | + _buildQueryForSort(isTieBreaker, sortField, sortValue, sortDirection) { |
| 189 | + let operator = '$eq' |
| 190 | + if (isTieBreaker) { |
| 191 | + if (sortDirection === 1) { |
| 192 | + operator = sortValue === null ? '$ne' : '$gt' |
| 193 | + } else { |
| 194 | + operator = '$lt' |
| 195 | + } |
| 196 | + } |
| 197 | + const filter = { [sortField]: { [operator]: sortValue } } |
| 198 | + |
| 199 | + //Less than non null value should include null as well |
| 200 | + if (operator === '$lt' && sortValue) { |
| 201 | + return { |
| 202 | + $or: [ |
| 203 | + filter, |
| 204 | + { [sortField]: null } |
| 205 | + ] |
| 206 | + } |
| 207 | + } |
| 208 | + return filter |
| 209 | + } |
| 210 | + |
| 211 | + _getContinuationToken(pageSize, data, sortClause) { |
| 212 | + if (data.length !== pageSize) return '' |
| 213 | + const lastItem = data[data.length - 1] |
| 214 | + const lastValues = Object.keys(sortClause) |
| 215 | + .map(key => get(lastItem, key)) |
| 216 | + .join(SEPARATOR) |
| 217 | + return base64.encode(lastValues) |
| 218 | + } |
| 219 | + |
| 220 | +} |
| 221 | +module.exports = AbstractMongoDefinitionStore |
0 commit comments