UNPKG

@amag-ch/sap_cap_common_objectstore

Version:

NodeJS library to communicate with an objectstore

302 lines (231 loc) 9.57 kB
const cds = require('@sap/cds') const namespace = 'amag.common.objectstore' const entities = { Files: `${namespace}.Files`, FilesForDelete: `${namespace}.FilesForDelete` } const { BackgroundJob } = require('@amag-ch/sap_cap_common_jobs') module.exports = class Persistance { #service #deleteJob /** * Create Persinstance. * * @param {import('./Service.js')} service * @returns {Promise<Persistance>} */ static create = async (service) => new Persistance(service).init() /** * @param {import('./Service.js')} service */ constructor(service) { this.#service = service } init = async () => { await Promise.all([ this.#checkEntities(), this.#registerDeepHandler(), this.#registerReadContentHandler(), this.#registerDeleteProcessor() ]) return this } /** * @param {String} ID */ exists = (ID) => cds.read(entities.Files, ID, ['1']) /** * @param {String} ID */ read = async (ID) => cds.read(entities.Files, ID) create = async (data) => { await cds.delete(entities.FilesForDelete, data.ID) return (await this.exists(data.ID)) ? this.update(data) : cds.create(entities.Files, data) } update = async (data) => cds.update(entities.Files, data) delete = async (ID) => { if (!await this.exists(ID)) return await Promise.all([ cds.delete(entities.Files, ID), cds.create(entities.FilesForDelete, { ID }).then(result => result, (err) => { if (err.code !== 400) throw err if (err.message !== 'Entity already exists' && err.message !== 'ENTITY_ALREADY_EXISTS') throw err }) ]) this.#deleteJob.start() } #checkEntities = () => { if (!cds.model?.definitions?.[entities.Files]) throw new Error(`The entity '${entities.Files}' is missing but needed for the objectstore.`) if (!cds.model?.definitions?.[entities.FilesForDelete]) throw new Error(`The entity '${entities.FilesForDelete}' is missing but needed for the objectstore.`) } #handleDeepUpdate = async (req, next) => { if (hasDeepUpdate(req.query, req.target)) await this.#handleDeep(await getDeepUpdateQueries(req, this.#service)) return next() } #handleDeepDelete = async (req, next) => { if (!req.target.compositions) return next() const cqn4sql = require('@cap-js/db-service/lib/cqn4sql') const queries = [] const addQuery = (query, target) => { queries.push(cqn4sql(query, cds.model)) if (!target?.compositions) return let { from, where } = query.DELETE if (typeof from === 'string') from = { ref: [from] } if (where) { let last = from.ref.at(-1) if (last.where) [last, where] = [last.id, [{ xpr: last.where }, 'and', { xpr: where }]] from = { ref: [...from.ref.slice(0, -1), { id: last, where }] } } Object.values(target?.compositions || {}).map(composition => { addQuery(DELETE.from({ ref: [...from.ref, composition.name] }), composition._target) }) } addQuery(req.query, req.target) await this.#handleDeep(queries) return next() } #handleDeep = async (queries) => { if (!queries.length) return const fileDeletes = queries.flat().filter(query => { if (!query.DELETE) return false const from = query.DELETE?.from?.ref?.[0] || query.DELETE?.from const definition = cds.model?.definitions[from] if (from === entities.Files) return true if (definition?.isDraft) return false if (definition?.projection?.from?.ref?.[0] === entities.Files) return true return false }) if (!fileDeletes.length) return await Promise.all(fileDeletes.map(({ DELETE: { from, where } }) => cds.create(entities.FilesForDelete).entries(cds.read(from, ['ID']).where(where)).then(result => result, (err) => { if (err.code !== 400) throw err if (err.message !== 'Entity already exists' && err.message !== 'ENTITY_ALREADY_EXISTS') throw err }) )) this.#deleteJob.start() } #handleContentRead = async (req, next) => { if (req?.query?.SELECT?.columns?.[0]?.ref?.[0] !== 'content') return next() const query = cds.clone(req.query) if (query._streaming) { query.SELECT.columns[0].ref[0] = 'ID' query._streaming = false } else if (query.SELECT.columns[0].ref[0] === 'content' && query.SELECT.columns[4]?.ref[0] !== 'ID') { query.SELECT.columns[0].ref[0] = 'ID' } else { query.SELECT.columns = query.SELECT.columns.slice(1) } const file = await cds.db.read(query).then(result => Array.isArray(result) ? result[0] : result) if (!file) return { value: null } const result = { value: await this.#service.readContent(file.ID) } for (const property of ['$mediaContentType', '$mediaContentDispositionType', '$mediaContentDispositionFilename']) if (property in file) result[property] = file[property] return result } #registerDeepHandler = () => { const registered = {} const register = (entity, definition) => { if (definition?.projection?.from?.ref?.[0] === entities.Files) registered[entity.name] = entity if (definition?.name === entities.Files && entity?.name !== entities.Files) registered[entity.name] = entity Object.values(definition?.compositions || {}).forEach(composition => register(entity, composition._target) ) } Object.values(cds.model?.definitions || {}).forEach(definition => register(definition, definition) ) Object.values(registered).forEach(entity => { cds.db.prepend(db => db.on('UPDATE', entity, this.#handleDeepUpdate)) cds.db.prepend(db => db.on('DELETE', entity, this.#handleDeepDelete)) }) } #registerReadContentHandler = () => { const projections = Object.values(cds.model?.definitions || {}) .filter(({ kind, query }) => kind === 'entity' && query?._target?.name === entities.Files) for (const projection of projections) cds.db.prepend(db => db.on('READ', projection, this.#handleContentRead)) } #registerDeleteProcessor = () => { this.#deleteJob = new BackgroundJob(this.#deleteProcessorSelection, this.#deleteProcessorExecuter, { name: 'objectstore-deletejob' }) } #deleteProcessorSelection = async () => { return cds.read(entities.FilesForDelete, ['ID']).limit(1) } #deleteProcessorExecuter = async (files) => { for await (const file of files) { const exists = await cds.read(entities.FilesForDelete, file.ID, ['1']).forUpdate() if (exists) { await this.#service.deleteFromStore(file.ID) await cds.delete(entities.Files, file.ID) await cds.delete(entities.FilesForDelete, file.ID) } } } } const hasDeepUpdate = (query, target) => { const data = (query.UPDATE?.data && [query.UPDATE.data]) || (query.UPDATE?.with && [query.UPDATE.with]) if (!data) return false for (const composition in target.compositions) for (const row of data) if (row[composition] !== undefined) return true } const getExpandForDeepUpdate = (query, target) => { const { getExpandForDeep } = require('@cap-js/db-service/lib/deep-queries') const expands = {} const addExpand = (expands, name, target) => { expands[name] = false if (name && target.projection?.from?.ref?.[0] === entities.Files) expands[name] = true Object.values(target.compositions || {}).forEach(composition => { expands[name] = expands[name] || {} addExpand(expands[name], composition.name, composition._target) }) } const filterExpands = (columns, relevant) => { columns.forEach((column, index) => { if (!column.expand) return if (!relevant[column.ref[0]]) { columns.splice(index, 1) return } filterExpands(column.expand, relevant[column.ref[0]]) }) } const result = getExpandForDeep(query, target) addExpand(expands, '', target) filterExpands(result.SELECT.columns, expands['']) return result } const getDeepUpdateQueries = async (req, service) => { const fill_in_keys = require('@cap-js/db-service/lib/fill-in-keys') const { getDeepQueries } = require('@cap-js/db-service/lib/deep-queries') await fill_in_keys.bind(service)(req, () => { }) const beforeData = await getExpandForDeepUpdate(req.query, req.target) if (!beforeData.length) return [] return getDeepQueries(req.query, beforeData, req.target) }