@amag-ch/sap_cap_common_objectstore
Version:
NodeJS library to communicate with an objectstore
302 lines (231 loc) • 9.57 kB
JavaScript
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)
}