diff --git a/app/lib/database/DatabaseManager.js b/app/lib/database/DatabaseManager.js index 34dcae6fa..7d62db52f 100644 --- a/app/lib/database/DatabaseManager.js +++ b/app/lib/database/DatabaseManager.js @@ -18,6 +18,7 @@ const path = require('path') const config = require('../../config'); const modelsFactory = require('./models'); const repositoriesFactory = require('./repositories'); +const cls = require('cls-hooked'); /** * Sequelize implementation of the Database. @@ -26,6 +27,8 @@ class DatabaseManager { constructor() { this.logger = new Log(DatabaseManager.name); this.schema = 'public'; + const o2rct_namespace = cls.createNamespace('o2rct-namespace'); + Sequelize.useCLS(o2rct_namespace); this.sequelize = new Sequelize({ ...config.database, diff --git a/app/lib/database/repositories/Repository.js b/app/lib/database/repositories/Repository.js index e442c2aa6..2ab7433e6 100644 --- a/app/lib/database/repositories/Repository.js +++ b/app/lib/database/repositories/Repository.js @@ -11,6 +11,23 @@ * or submit itself to any jurisdiction. */ +const { throwWrapper, NotFoundEntityError } = require('../../utils'); + +const nonTransactionalFunctions = new Set(['constructor', 'asT', '_asT']) + +const getTransactionalMethodsNames = (classObj, ) => { + const classesStack = []; + while (typeof Object.getPrototypeOf(classObj) !== 'object') { + classesStack.push(classObj); + classObj = Object.getPrototypeOf(classObj); + } + + return classesStack + .map(cl => Object.getOwnPropertyNames(cl.prototype)) + .flat() + .filter(name => ! nonTransactionalFunctions.has(name)); +} + /** * Sequelize implementation of the Repository. */ @@ -31,12 +48,71 @@ class Repository { /** * Returns all entities. * - * @param {Object} queryClauses the find query (see sequelize findAll options) or a find query builder + * @param {Object} queryClauses the find query (see sequelize findAll options) * @returns {Promise} Promise object representing the full mock data */ async findAll(queryClauses = {}) { return this.model.findAll(queryClauses); } + + /** + * Returns one entity. + * + * @param {Object} queryClauses the find query (see sequelize findOne options) + * @returns {Promise} Promise object representing the full mock data + */ + async findOne(queryClauses = {}) { + return this.model.findOne(queryClauses); + } + + /** + * Apply a patch on a given dbObject and save the dbObject to the database + * + * @param {Object} dbOject the database object on which to apply the patch + * @param {Object} patch the patch to apply + * @return {Promise} promise that resolves when the patch has been applied + */ + async updateOne(dbOject, patch) { + return dbOject.update(patch); + } + + /** + * Find a dbObject using query clause, apply given patch to it and save the dbObject to the database + * + * @param {Object} dbOject the database object on which to apply the patch + * @param {Object} patch the patch to apply + * @throws {NotFoundEntityError} if cannot find dbObject with given query clause + * @return {Promise} promise that resolves when the patch has been applied + */ + async findOneAndUpdate(query, patch) { + const entity = await this.model.findOne(query) ?? + throwWrapper(new NotFoundEntityError(`No entity of model ${this.model.name} for clause ${JSON.stringify(query)}`)); + await entity.update(patch); + } + + _asT(customOptions) { + const { sequelize } = this.model; + getTransactionalMethodsNames(this.constructor).forEach(transactionalMethodName => { + const boundMethodWithoutTransaction = this[transactionalMethodName].bind(this); + this[transactionalMethodName] = async (...args) => + sequelize.transaction(customOptions, async (t) => { + return await boundMethodWithoutTransaction(...args); + }); + }); + } + + /** + * Create copy of repository object which all business related methods are wrapped with sequelize.transcation(), + * e.g: Repository.asT().findAll() is equal to sequelize.transaction((t) => Repository.findAll()) + * Module cls-hooked handles passing transaction object to sequelize queries automatically. + * @property {Object} customOptions - options passed to sequelize.transaction(options, callback) + * @returns {Repository} + */ + asT(customOptions) { + const instanceWithTransactions = new this.constructor(this.model); + instanceWithTransactions._asT(customOptions); + return instanceWithTransactions; + } } module.exports = Repository; diff --git a/app/lib/database/repositories/index.js b/app/lib/database/repositories/index.js index 86d2ff252..0aafe5807 100644 --- a/app/lib/database/repositories/index.js +++ b/app/lib/database/repositories/index.js @@ -41,6 +41,7 @@ const validateSpecificRepositoriesConfiguration = (models) => { /** * Instantiate sequelize models repositories according to repositiry pattern. + * Each Repository Object has transactional version of itself under field 'T' @see {Repository.asT}. Those versions use global sequelize options for transactions. * @param {Object} models dict: modelName -> sequelize model, @see specificallyDefinedRepositories * @returns {Object} dict: repositoryName -> repository instance per one model, (repositoryName = modelName + 'Repository') */ @@ -51,6 +52,7 @@ const repositoriesFactory = (models) => { [modelName + 'Repository', new (specificallyDefinedRepositories[modelName] ?? Repository) (model), ]); + modelNameToRepository.forEach(([_, repository]) => { repository.T = repository.asT() }); return Object.fromEntries(modelNameToRepository); }; diff --git a/app/lib/server/controllers/run.controller.js b/app/lib/server/controllers/run.controller.js index 7b2f7f6e9..6f9f540cc 100644 --- a/app/lib/server/controllers/run.controller.js +++ b/app/lib/server/controllers/run.controller.js @@ -87,9 +87,32 @@ const listRunsPerSimulationPassHandler = async (req, res, next) => { } }; +const updateRunDetectorQualityHandler = async (req, res) => { + const customDTO = stdDataRequestDTO.keys({ + params: { + runNumber: Joi.number(), + detectorSubsystemId: Joi.number(), + }, + query: { + quality: Joi.string().required(), + }, + }); + + const validatedDTO = await validateDtoOrRepondOnFailure(customDTO, req, res); + if (validatedDTO) { + await runService.updateRunDetectorQuality( + validatedDTO.params.runNumber, + validatedDTO.params.detectorSubsystemId, + validatedDTO.query.quality, + ); + res.end(); + } +}; + module.exports = { listRunsHandler, listRunsPerPeriodHandler, listRunsPerDataPass, listRunsPerSimulationPassHandler, + updateRunDetectorQualityHandler, }; diff --git a/app/lib/server/routers/run.router.js b/app/lib/server/routers/run.router.js index 6ebac9672..ff672be87 100644 --- a/app/lib/server/routers/run.router.js +++ b/app/lib/server/routers/run.router.js @@ -22,5 +22,11 @@ module.exports = { controller: RunController.listRunsHandler, description: 'List all runs which are present in DB', }, + { + method: 'patch', + path: '/:runNumber/detector-subsystems/:detectorSubsystemId', + controller: RunController.updateRunDetectorQualityHandler, + description: 'Update run/detectorSubsystem based quality', + }, ], }; diff --git a/app/lib/services/runs/RunService.js b/app/lib/services/runs/RunService.js index 3e98a8b80..5eae3ead3 100644 --- a/app/lib/services/runs/RunService.js +++ b/app/lib/services/runs/RunService.js @@ -15,6 +15,7 @@ const { databaseManager: { repositories: { RunRepository, + RunDetectorsRepository, }, models: { DataPass, @@ -107,6 +108,17 @@ class RunService { }); return runs.map((run) => runAdapter.toEntity(run)); } + + async updateRunDetectorQuality(runNumber, detectorId, newQuality) { + const queryClause = { + where: { + run_number: runNumber, + detector_id: detectorId, + }, + }; + const patch = { quality: newQuality }; + await RunDetectorsRepository.T.findOneAndUpdate(queryClause, patch); + } } module.exports = { diff --git a/app/lib/utils/errors.js b/app/lib/utils/errors.js new file mode 100644 index 000000000..571d74f8f --- /dev/null +++ b/app/lib/utils/errors.js @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +class NotFoundEntityError extends Error {} + +module.exports = { + NotFoundEntityError, +}; diff --git a/app/lib/utils/index.js b/app/lib/utils/index.js index af25b184d..9d34bbb03 100644 --- a/app/lib/utils/index.js +++ b/app/lib/utils/index.js @@ -16,6 +16,7 @@ const LogsStacker = require('./LogsStacker.js'); const objUtils = require('./obj-utils.js'); const ResProvider = require('./ResProvider.js'); const sqlUtils = require('./sql-utils.js'); +const errors = require('./errors.js'); module.exports = { ResProvider, @@ -23,4 +24,5 @@ module.exports = { ...sqlUtils, ...httpUtils, ...objUtils, + ...errors, }; diff --git a/package-lock.json b/package-lock.json index 6dedd6f76..3c7210cfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.9", "bundleDependencies": [ "@aliceo2/web-ui", + "cls-hooked", "deepmerge", "esm", "joi", @@ -20,6 +21,7 @@ "license": "GPL-3.0", "dependencies": { "@aliceo2/web-ui": "^2.0.0", + "cls-hooked": "^4.2.2", "csvtojson": "^2.0.10", "deepmerge": "^4.3.1", "esm": "^3.2.25", @@ -1184,6 +1186,18 @@ "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==", "inBundle": true }, + "node_modules/async-hook-jl": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/async-hook-jl/-/async-hook-jl-1.7.6.tgz", + "integrity": "sha512-gFaHkFfSxTjvoxDMYqDuGHlcRyUuamF8s+ZTtJdDzqjws4mCt7v0vuV79/E2Wr2/riMQgtG4/yUtXWs1gZ7JMg==", + "inBundle": true, + "dependencies": { + "stack-chain": "^1.3.7" + }, + "engines": { + "node": "^4.7 || >=6.9 || >=7.3" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1561,6 +1575,29 @@ "node": ">=12" } }, + "node_modules/cls-hooked": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/cls-hooked/-/cls-hooked-4.2.2.tgz", + "integrity": "sha512-J4Xj5f5wq/4jAvcdgoGsL3G103BtWpZrMo8NEinRltN+xpTZdI+M38pyQqhuFU/P792xkMFvnKSf+Lm81U1bxw==", + "inBundle": true, + "dependencies": { + "async-hook-jl": "^1.7.6", + "emitter-listener": "^1.0.1", + "semver": "^5.4.1" + }, + "engines": { + "node": "^4.7 || >=6.9 || >=7.3 || >=8.2.1" + } + }, + "node_modules/cls-hooked/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "inBundle": true, + "bin": { + "semver": "bin/semver" + } + }, "node_modules/color": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", @@ -2013,6 +2050,15 @@ "integrity": "sha512-XbCRs/34l31np/p33m+5tdBrdXu9jJkZxSbNxj5I0H1KtV2ZMSB+i/HYqDiRzHaFx2T5EdytjoBRe8QRJE2vQg==", "dev": true }, + "node_modules/emitter-listener": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/emitter-listener/-/emitter-listener-1.1.2.tgz", + "integrity": "sha512-Bt1sBAGFHY9DKY+4/2cV6izcKJUf5T7/gkdmkxzX/qv9CcGH8xSwVRW5mtX03SWJtRTWSOpzCuWN9rBFYZepZQ==", + "inBundle": true, + "dependencies": { + "shimmer": "^1.2.0" + } + }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -5528,6 +5574,12 @@ "node": ">=8" } }, + "node_modules/shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==", + "inBundle": true + }, "node_modules/side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -5715,6 +5767,12 @@ "node": ">= 0.6" } }, + "node_modules/stack-chain": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/stack-chain/-/stack-chain-1.3.7.tgz", + "integrity": "sha512-D8cWtWVdIe/jBA7v5p5Hwl5yOSOrmZPWDPe2KxQ5UAGD+nxbxU0lKXA4h85Ta6+qgdKVL3vUxsbIZjc1kBG7ug==", + "inBundle": true + }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", diff --git a/package.json b/package.json index e6c02860c..babe89909 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "homepage": "https://github.com/AliceO2Group/RunConditionTable#readme", "dependencies": { "@aliceo2/web-ui": "^2.0.0", + "cls-hooked": "^4.2.2", "csvtojson": "^2.0.10", "deepmerge": "^4.3.1", "esm": "^3.2.25", @@ -88,6 +89,7 @@ ], "bundleDependencies": [ "@aliceo2/web-ui", + "cls-hooked", "deepmerge", "esm", "joi", diff --git a/test/database/Repository.test.js b/test/database/Repository.test.js new file mode 100644 index 000000000..7231dbae5 --- /dev/null +++ b/test/database/Repository.test.js @@ -0,0 +1,33 @@ +/** + * @license + * Copyright CERN and copyright holders of ALICE O2. This software is + * distributed under the terms of the GNU General Public License v3 (GPL + * Version 3), copied verbatim in the file "COPYING". + * + * See http://alice-o2.web.cern.ch/license for full licensing information. + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ +const { databaseManager: { repositories } } = require('../../app/lib/database/DatabaseManager.js'); +const assert = require('assert'); + +module.exports = () => { + describe('RepositoriesSuite', () => { + describe('testing if transaction methods give the same result as non-transactional ones', () => { + const testableMethodNames = new Set(['count', 'findAll', 'findOne']); + Object.values(repositories).map((repo) => + describe(`${repo.model.name}Repository`, () => Object.getOwnPropertyNames(repo.T) + .filter(n => testableMethodNames.has(n)) + .map((methodName) => { + it(`should acquire the same result from transaction and non-transactional method #${methodName}`, async () => { + const nonTransactionResult = await repo[methodName](); + const transationResult = await repo.T[methodName](); + assert.deepStrictEqual(nonTransactionResult, transationResult); + }) + })) + ); + }); + }); +}; diff --git a/test/database/index.js b/test/database/index.js index fe9925b21..223615557 100644 --- a/test/database/index.js +++ b/test/database/index.js @@ -13,8 +13,10 @@ const UtilitiesSuite = require('./utilities'); const DatabaseManagerSuite = require('./DatabaseManger.test'); +const RepositoriesSuite = require('./Repository.test'); module.exports = () => { - describe('DatabaseManager', DatabaseManagerSuite); + DatabaseManagerSuite(); describe('Utilities', UtilitiesSuite); + RepositoriesSuite(); }; diff --git a/test/lib/server/routes.test.js b/test/lib/server/routes.test.js index b6b3427f9..0f542f974 100644 --- a/test/lib/server/routes.test.js +++ b/test/lib/server/routes.test.js @@ -32,7 +32,7 @@ module.exports = () => { }); }); describe('Endpoints', () => { - routes.map(async ({ path }) => { + routes.filter(({ method }) => method === 'get').map(async ({ path }) => { const url = `${config.http.tls ? 'https' : 'http'}://localhost:${config.http.port}/api${replaceAll(path, /:[^/]+/, '0')}`; it(`should fetch from ${path} <${url}> without errors`, async () => { await assert.doesNotReject(makeHttpRequestForJSON(url));