diff --git a/api/lib/infrastructure/repositories/framework-repository.js b/api/lib/infrastructure/repositories/framework-repository.js index 270393860bf..32709745a01 100644 --- a/api/lib/infrastructure/repositories/framework-repository.js +++ b/api/lib/infrastructure/repositories/framework-repository.js @@ -1,28 +1,33 @@ -import { DomainTransaction } from '../../../src/shared/domain/DomainTransaction.js'; import { NotFoundError } from '../../../src/shared/domain/errors.js'; -import { Framework } from '../../../src/shared/domain/models/Framework.js'; +import { Framework } from '../../../src/shared/domain/models/index.js'; +import { LearningContentRepository } from '../../../src/shared/infrastructure/repositories/learning-content-repository.js'; const TABLE_NAME = 'learningcontent.frameworks'; export async function list() { - const knex = DomainTransaction.getConnection(); - const frameworkDtos = await knex.select('*').from(TABLE_NAME).orderBy('name'); - return frameworkDtos.map(toDomain); + const cacheKey = 'list'; + const listCallback = (knex) => knex.orderBy('name'); + return getInstance().find(cacheKey, listCallback); } export async function getByName(name) { - const knex = DomainTransaction.getConnection(); - const frameworkDto = await knex.select('*').from(TABLE_NAME).where('name', name).first(); - if (!frameworkDto) { + const cacheKey = `getByName(${name})`; + const findByNameCallback = (knex) => knex.where('name', name); + const [framework] = await getInstance().find(cacheKey, findByNameCallback); + if (!framework) { throw new NotFoundError(`Framework not found for name ${name}`); } - return toDomain(frameworkDto); + return framework; } -export async function findByRecordIds(frameworkIds) { - const knex = DomainTransaction.getConnection(); - const frameworkDtos = await knex.select('*').from(TABLE_NAME).whereIn('id', frameworkIds).orderBy('name'); - return frameworkDtos.map(toDomain); +export async function findByRecordIds(ids) { + const cacheKey = `findByRecordIds(${ids.sort()})`; + const findByIdsCallback = (knex) => knex.whereIn('id', ids).orderBy('name'); + return getInstance().find(cacheKey, findByIdsCallback); +} + +export function clear() { + return getInstance().clear(); } function toDomain(frameworkData) { @@ -32,3 +37,12 @@ function toDomain(frameworkData) { areas: [], }); } + +let instance; + +function getInstance() { + if (!instance) { + instance = new LearningContentRepository({ tableName: TABLE_NAME, toDomain }); + } + return instance; +} diff --git a/api/package-lock.json b/api/package-lock.json index 38c1cb11d18..53baf711c44 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -15,6 +15,8 @@ "@aws-sdk/lib-storage": "^3.121.0", "@aws-sdk/s3-request-presigner": "^3.145.0", "@getbrevo/brevo": "^2.1.1", + "@graphql-yoga/redis-event-target": "^3.0.1", + "@graphql-yoga/subscription": "^5.0.1", "@hapi/accept": "^6.0.0", "@hapi/boom": "^10.0.1", "@hapi/hapi": "^21.0.0", @@ -29,6 +31,7 @@ "axios": "^1.0.0", "bcrypt": "^5.0.1", "cron-parser": "^4.9.0", + "dataloader": "^2.2.2", "dayjs": "^1.11.5", "debug": "^4.3.4", "dotenv": "^16.0.1", @@ -1796,6 +1799,50 @@ "rewire": "^7.0.0" } }, + "node_modules/@graphql-yoga/redis-event-target": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@graphql-yoga/redis-event-target/-/redis-event-target-3.0.1.tgz", + "integrity": "sha512-AUQAQdJcJuRmvNdWh/v8ub8Pxq3J+++IOzAPHpzMOs81GEYQkatSrBiiESyiUdILazYoE4AYnKBVeKUVrH0Iyw==", + "license": "MIT", + "dependencies": { + "@graphql-yoga/typed-event-target": "^3.0.0", + "@whatwg-node/events": "^0.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "ioredis": "^5.0.6" + } + }, + "node_modules/@graphql-yoga/subscription": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@graphql-yoga/subscription/-/subscription-5.0.1.tgz", + "integrity": "sha512-1wCB1DfAnaLzS+IdoOzELGGnx1ODEg9nzQXFh4u2j02vAnne6d+v4A7HIH9EqzVdPLoAaMKXCZUUdKs+j3z1fg==", + "license": "MIT", + "dependencies": { + "@graphql-yoga/typed-event-target": "^3.0.0", + "@repeaterjs/repeater": "^3.0.4", + "@whatwg-node/events": "^0.1.0", + "tslib": "^2.5.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@graphql-yoga/typed-event-target": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@graphql-yoga/typed-event-target/-/typed-event-target-3.0.0.tgz", + "integrity": "sha512-w+liuBySifrstuHbFrHoHAEyVnDFVib+073q8AeAJ/qqJfvFvAwUPLLtNohR/WDVRgSasfXtl3dcNuVJWN+rjg==", + "license": "MIT", + "dependencies": { + "@repeaterjs/repeater": "^3.0.4", + "tslib": "^2.5.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@hapi/accept": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@hapi/accept/-/accept-6.0.3.tgz", @@ -2667,6 +2714,12 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@repeaterjs/repeater": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.6.tgz", + "integrity": "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==", + "license": "MIT" + }, "node_modules/@sentry-internal/tracing": { "version": "7.118.0", "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.118.0.tgz", @@ -3887,6 +3940,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@whatwg-node/events": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/events/-/events-0.1.2.tgz", + "integrity": "sha512-ApcWxkrs1WmEMS2CaLLFUEem/49erT3sxIVjpzU5f6zmVcnijtDSrhoK2zVobOIikZJdH63jdAXOrvjf6eOUNQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.3.tgz", @@ -5068,6 +5133,12 @@ "node": ">=0.10" } }, + "node_modules/dataloader": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.2.tgz", + "integrity": "sha512-8YnDaaf7N3k/q5HnTJVuzSyLETjoZjVmHc4AeKAzOvKHEFQKcn64OKBfzHYtE9zGjctNM7V9I0MfnUVLpi7M5g==", + "license": "MIT" + }, "node_modules/dateformat": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", diff --git a/api/package.json b/api/package.json index fcc909c1653..6378a1a987f 100644 --- a/api/package.json +++ b/api/package.json @@ -21,6 +21,8 @@ "@aws-sdk/lib-storage": "^3.121.0", "@aws-sdk/s3-request-presigner": "^3.145.0", "@getbrevo/brevo": "^2.1.1", + "@graphql-yoga/redis-event-target": "^3.0.1", + "@graphql-yoga/subscription": "^5.0.1", "@hapi/accept": "^6.0.0", "@hapi/boom": "^10.0.1", "@hapi/hapi": "^21.0.0", @@ -35,6 +37,7 @@ "axios": "^1.0.0", "bcrypt": "^5.0.1", "cron-parser": "^4.9.0", + "dataloader": "^2.2.2", "dayjs": "^1.11.5", "debug": "^4.3.4", "dotenv": "^16.0.1", diff --git a/api/src/shared/domain/models/Framework.js b/api/src/shared/domain/models/Framework.js index 4649d7e07ba..ed7ee1ef04c 100644 --- a/api/src/shared/domain/models/Framework.js +++ b/api/src/shared/domain/models/Framework.js @@ -4,6 +4,14 @@ class Framework { this.name = name; this.areas = areas; } + + clone() { + return new Framework({ + id: this.id, + name: this.name, + areas: this.areas, + }); + } } export { Framework }; diff --git a/api/src/shared/infrastructure/caches/learning-content-pubsub.js b/api/src/shared/infrastructure/caches/learning-content-pubsub.js new file mode 100644 index 00000000000..406cca6b33a --- /dev/null +++ b/api/src/shared/infrastructure/caches/learning-content-pubsub.js @@ -0,0 +1,45 @@ +import { createRedisEventTarget } from '@graphql-yoga/redis-event-target'; +import { createPubSub } from '@graphql-yoga/subscription'; +import { Redis } from 'ioredis'; + +import { config } from '../../config.js'; + +/** + * @typedef {import('@graphql-yoga/subscription').PubSub<{ + * [key: string]: [message: object] + * }>} LearningContentPubSub + */ + +/** @type {LearningContentPubSub} */ +let pubSub; + +/** @type {import('ioredis').Redis} */ +let publishClient; +/** @type {import('ioredis').Redis} */ +let subscribeClient; + +export function getPubSub() { + if (pubSub) return pubSub; + + if (!config.caching.redisUrl) { + pubSub = createPubSub(); + return pubSub; + } + + publishClient = new Redis(config.caching.redisUrl); + subscribeClient = new Redis(config.caching.redisUrl); + + pubSub = createPubSub({ + eventTarget: createRedisEventTarget({ + publishClient, + subscribeClient, + }), + }); + + return pubSub; +} + +export async function quit() { + await publishClient?.quit(); + await subscribeClient?.quit(); +} diff --git a/api/src/shared/infrastructure/repositories/learning-content-repository.js b/api/src/shared/infrastructure/repositories/learning-content-repository.js new file mode 100644 index 00000000000..e5814f7d52b --- /dev/null +++ b/api/src/shared/infrastructure/repositories/learning-content-repository.js @@ -0,0 +1,114 @@ +import Dataloader from 'dataloader'; + +import { knex } from '../../../../db/knex-database-connection.js'; +import * as learningContentPubSub from '../caches/learning-content-pubsub.js'; + +export class LearningContentRepository { + #tableName; + #toDomain; + #idType; + #dataloader; + #findCache; + #findCacheMiss; + + constructor({ tableName, toDomain, idType = 'text', pubSub = learningContentPubSub.getPubSub() }) { + this.#tableName = tableName; + this.#toDomain = toDomain; + this.#idType = idType; + + this.#dataloader = new Dataloader((ids) => this.#batchLoad(ids), { + cacheMap: new LearningContentCache({ + name: `${tableName}:entities`, + pubSub, + }), + }); + + this.#findCache = new LearningContentCache({ + name: `${tableName}:results`, + pubSub, + }); + + this.#findCacheMiss = new Map(); + } + + async find(cacheKey, callback) { + const dtos = await this.#findDtos(callback, cacheKey); + return dtos.map(this.#toDomain); + } + + #findDtos(callback, cacheKey) { + let dtos = this.#findCache.get(cacheKey); + if (dtos) return dtos; + + dtos = this.#findCacheMiss.get(cacheKey); + if (dtos) return dtos; + + dtos = this.#loadDtos(callback, cacheKey).finally(() => { + this.#findCacheMiss.delete(cacheKey); + }); + this.#findCacheMiss.set(cacheKey, dtos); + + return dtos; + } + + async #loadDtos(callback, cacheKey) { + const ids = await callback(knex.pluck('id').from(this.#tableName)); + const dtos = await this.#dataloader.loadMany(ids); + this.#findCache.set(cacheKey, dtos); + return dtos; + } + + async #batchLoad(ids) { + return knex + .select(`${this.#tableName}.*`) + .from(knex.raw(`unnest(?::${this.#idType}[]) with ordinality as ids(id, idx)`, [ids])) // eslint-disable-line knex/avoid-injections + .leftJoin(this.#tableName, `${this.#tableName}.id`, 'ids.id') + .orderBy('ids.idx'); + } + + clear() { + this.#dataloader.clearAll(); + this.#findCache.clear(); + } +} + +class LearningContentCache { + #map = new Map(); + #pubSub; + #name; + + /** + * @param {{ + * pubSub: import('../caches/learning-content-pubsub.js').LearningContentPubSub + * name: string + * }} config + * @returns + */ + constructor({ pubSub, name }) { + this.#pubSub = pubSub; + this.#name = name; + + (async () => { + for await (const message of pubSub.subscribe(name)) { + if (message.type === 'clear') this.#map.clear(); + if (message.type === 'delete') this.#map.delete(this.message.key); + } + })(); + } + + get(key) { + return this.#map.get(key); + } + + set(key, value) { + return this.#map.set(key, value); + } + + delete(key) { + return this.#pubSub.publish(this.#name, { type: 'delete', key }); + } + + clear() { + return this.#pubSub.publish(this.#name, { type: 'clear' }); + } +} diff --git a/api/tests/integration/infrastructure/repositories/framework-repository_test.js b/api/tests/integration/infrastructure/repositories/framework-repository_test.js index 82dabf2cd6b..b0cce183f8e 100644 --- a/api/tests/integration/infrastructure/repositories/framework-repository_test.js +++ b/api/tests/integration/infrastructure/repositories/framework-repository_test.js @@ -1,38 +1,49 @@ import * as frameworkRepository from '../../../../lib/infrastructure/repositories/framework-repository.js'; import { NotFoundError } from '../../../../src/shared/domain/errors.js'; -import { catchErr, domainBuilder, expect, mockLearningContent } from '../../../test-helper.js'; +import { catchErr, databaseBuilder, domainBuilder, expect, knex } from '../../../test-helper.js'; describe('Integration | Repository | framework-repository', function () { - const framework0 = { + const frameworkData0 = { id: 'recId0', name: 'mon framework 0', }; - const framework1 = { + const frameworkData1 = { id: 'recId1', name: 'mon framework 1', }; - const framework2 = { + const frameworkData2 = { id: 'recId2', name: 'mon framework 2', }; - const learningContent = { frameworks: [framework0, framework1, framework2] }; - beforeEach(async function () { - await mockLearningContent(learningContent); + databaseBuilder.factory.learningContent.buildFramework(frameworkData0); + databaseBuilder.factory.learningContent.buildFramework(frameworkData2); + databaseBuilder.factory.learningContent.buildFramework(frameworkData1); + await databaseBuilder.commit(); }); describe('#list', function () { - it('should return all frameworks', async function () { + it('should return all frameworks when there are some in DB', async function () { // when const frameworks = await frameworkRepository.list(); // then - const expectedFramework0 = domainBuilder.buildFramework({ ...framework0, areas: [] }); - const expectedFramework1 = domainBuilder.buildFramework({ ...framework1, areas: [] }); - const expectedFramework2 = domainBuilder.buildFramework({ ...framework2, areas: [] }); + const expectedFramework0 = domainBuilder.buildFramework({ ...frameworkData0, areas: [] }); + const expectedFramework1 = domainBuilder.buildFramework({ ...frameworkData1, areas: [] }); + const expectedFramework2 = domainBuilder.buildFramework({ ...frameworkData2, areas: [] }); expect(frameworks).to.deepEqualArray([expectedFramework0, expectedFramework1, expectedFramework2]); }); + + it('should return an empty array when no frameworks in DB', async function () { + await knex('learningcontent.frameworks').truncate(); + + // when + const frameworks = await frameworkRepository.list(); + + // then + expect(frameworks).to.deep.equal([]); + }); }); describe('#getByName', function () { @@ -41,21 +52,21 @@ describe('Integration | Repository | framework-repository', function () { const framework = await frameworkRepository.getByName('mon framework 1'); // then - const expectedFramework1 = domainBuilder.buildFramework({ ...framework1, areas: [] }); - expect(framework).to.deepEqualInstance(expectedFramework1); + const expectedFramework = domainBuilder.buildFramework({ ...frameworkData1, areas: [] }); + expect(framework).to.deepEqualInstance(expectedFramework); }); context('when framework is not found', function () { it('should return a rejection', async function () { //given - const frameworkName = 'framework123'; + const frameworkName = 'frameworkData123'; // when const error = await catchErr(frameworkRepository.getByName)(frameworkName); // then expect(error).to.be.an.instanceof(NotFoundError); - expect(error.message).to.equal('Framework not found for name framework123'); + expect(error.message).to.equal('Framework not found for name frameworkData123'); }); }); }); @@ -66,11 +77,20 @@ describe('Integration | Repository | framework-repository', function () { const frameworks = await frameworkRepository.findByRecordIds(['recId2', 'recId0']); // then - const expectedFramework0 = domainBuilder.buildFramework({ ...framework0, areas: [] }); - const expectedFramework2 = domainBuilder.buildFramework({ ...framework2, areas: [] }); + const expectedFramework0 = domainBuilder.buildFramework({ ...frameworkData0, areas: [] }); + const expectedFramework2 = domainBuilder.buildFramework({ ...frameworkData2, areas: [] }); expect(frameworks).to.deepEqualArray([expectedFramework0, expectedFramework2]); }); + it('should return frameworks it managed to find', async function () { + // when + const frameworks = await frameworkRepository.findByRecordIds(['recId2', 'recIdCAVA']); + + // then + const expectedFramework2 = domainBuilder.buildFramework({ ...frameworkData2, areas: [] }); + expect(frameworks).to.deepEqualArray([expectedFramework2]); + }); + it('should return an empty array when no frameworks found for ids', async function () { // when const frameworks = await frameworkRepository.findByRecordIds(['recIdCOUCOU', 'recIdCAVA']); diff --git a/api/tests/test-helper.js b/api/tests/test-helper.js index b488f1b30d1..5dac645fec4 100644 --- a/api/tests/test-helper.js +++ b/api/tests/test-helper.js @@ -18,6 +18,7 @@ import sinonChai from 'sinon-chai'; import { DatabaseBuilder } from '../db/database-builder/database-builder.js'; import { disconnect, knex } from '../db/knex-database-connection.js'; +import * as frameworkRepository from '../lib/infrastructure/repositories/framework-repository.js'; import { PIX_ADMIN } from '../src/authorization/domain/constants.js'; import { config } from '../src/shared/config.js'; import { Membership } from '../src/shared/domain/models/index.js'; @@ -71,6 +72,7 @@ afterEach(function () { restore(); LearningContentCache.instance.flushAll(); nock.cleanAll(); + frameworkRepository.clear(); return databaseBuilder.clean(); });