@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
459 lines (419 loc) • 20.6 kB
JavaScript
const { EOL } = require('node:os')
const { inspect } = require('node:util')
const cds = require('../cds')
const { copy, read, write, exists, path: {resolve, join}, yaml: YAML } = cds.utils
const { readYAML, writeYAML, readJSON } = require('../util/fs')
const { detectIndent } = require('./indent')
const { colors } = require('../util/term')
function _mergeObject(target, source) {
const _isObject = item => item && typeof item === 'object' && !Array.isArray(item)
const unique = array => [...new Set(array.map(JSON.stringify))].map(JSON.parse)
if (_isObject(target) && _isObject(source)) {
for (const key in source) {
if (_isObject(source[key])) {
if (!target[key]) Object.assign(target, { [key]: source[key] })
else _mergeObject(target[key], source[key])
} else if (Array.isArray(source[key]) && Array.isArray(target[key])) {
target[key] = unique([...source[key], ...target[key]])
} else {
Object.assign(target, { [key]: target[key] || source[key] })
}
}
} else if (Array.isArray(target) && Array.isArray(source)) {
target = unique([...source, ...target])
}
return target ?? source
}
module.exports.sort = async function(path, key) {
const json = await read(path)
json[key] = Object.fromEntries(
Object.entries(json[key] ?? {}).sort(([a],[b]) => a>b ? 1 : -1)
)
await write(path, json, { spaces: 2 });
}
/**
* @param {string} into - file path to merge into
* @param {string | string[]} from - either path to file (string) or lines from source gitignore (string[])
* @returns {Promise<string[]>} the lines of the updated .gitignore
*/
module.exports.mergeGitignore = async function(into, from) {
const source = typeof from === 'string' ? (await read(from)).split(EOL) : from
if (typeof into === 'string' && !exists(into)) {
return write(into, source.join(EOL))
}
const target = (await read(into)).split(EOL)
const enhanced = target.concat(source.filter(line => !target.includes(line)))
await write(into, enhanced.join(EOL))
return enhanced
}
/**
* @typedef {Object} MatchedNode
* @property {string | MatchedNode} in - key path or matched node to start search from
* @property {Object} where - list of constraints
*/
/**
* @typedef {Object} Overwrites
* @property {(string | MatchedNode)[]} item - path to item to be overwritten
* @property {string} withValue - replacement string
*/
/**
* @typedef {Object} Deletion
* @property {MatchedNode} item - matched node to delete
* @property {MatchedNode} [key] - key path to property to delete in node (optional)
* @property {Object[]} [relationships] - list of relationships to delete
* @property {string} relationships[].removeProperty - key path to to-be-removed property
* @property {string[]} relationships[].allWithin - key path to relationship
*/
/**
* @typedef {Object} Relationship
* @property {(string | MatchedNode)[]} insert - source path to matched node
* @property {(string | MatchedNode)[]} into - target path for inserted relationship
*/
/**
* @typedef {Object} MergingSemantics
* @property {boolean} [forceOverwrite] - overwrite values for existing keys
* @property {MatchedNode[]} [additions] - matched nodes to be added from source template
* @property {Overwrites[]} [overwrites] - matched keys to be overwritten with given string
* @property {Deletion[]} [deletions] - matched nodes to be deleted
* @property {Relationship[]} [relationships] - relationships to be inserted
*/
/**
* @param {string | JSON} into - file path or JSON to merge into
* @param {string | JSON} from - file path or JSON to merge from
* @param {import('./projectReader').readProject} [project] - project descriptor used for Mustache replacements
* @param {MergingSemantics} [semantics] - merging algorithm semantics
* @returns {Promise<JSON>} - merged JSON
*/
async function _mergeJSON(into, from, project, semantics) {
const source = typeof from === 'string' ? await readJSON(from, project) : from
let result
if (typeof into === 'string' && !exists(into)) {
result = source
} else if (semantics) {
const target = await read(into)
const targetYAML = YAML.parseDocument(YAML.stringify(target))
const sourceYAML = YAML.parseDocument(YAML.stringify(source))
const resultYAML = await _mergeYAML(targetYAML, sourceYAML, project, semantics)
result = YAML.parse(YAML.stringify(resultYAML))
} else {
const target = typeof into === 'string' ? await read(into, 'json') : into
result = _mergeObject(target, source)
}
// we need to stringify result on site to be able to apply the appropriate indentation.
// cds-utils.write is currently hardcoded to use spaces for indentation.
if (typeof into === 'string') {
const indent = exists(into) && detectIndent((await read(into, 'utf-8')).split(EOL)) || ' '
await write(into, JSON.stringify(result, null, indent) + EOL)
}
return result
}
/**
* @param {string | YAML.Document} into - file path or YAML to merge into
* @param {string | YAML.Document} from - file path or YAML to merge from
* @param {Object} [project] - project descriptor used for Mustache replacements
* @param {MergingSemantics} [semantics] - merging algorithm semantics
* @returns {Promise<YAML.Document>} - merged YAML
* @throws {Error} Throws an error if empty target YAML is passed in
*/
async function _mergeYAML(into, from, project, semantics = {}) {
const target = typeof into === 'string' ? await readYAML(into) :
into instanceof YAML.Document ? into :
new YAML.Document(into)
const source = typeof from === 'string' ? await readYAML(from, project) :
from instanceof YAML.Document ? from :
new YAML.Document(from)
if (!target?.contents) {
if (typeof into === 'string' && typeof from === 'string') {
if (project) await writeYAML(into, source)
else await copy(from, into)
return source
}
throw new Error(`Target YAML doesn't exist`)
}
const entryMap = new Map, templateEntryMap = new Map
const { additions, overwrites, deletions, relationships } = semantics
for (const entry of additions ?? []) {
entryMap.set(entry, undefined)
templateEntryMap.set(entry, undefined)
}
for (const deletion of deletions ?? []) {
entryMap.set(deletion.item, undefined)
}
for (const relationship of relationships ?? []) {
const [entry] = relationship.into
const [insertEntry] = relationship.insert
const hash = JSON.stringify(insertEntry) + ' -> ' + JSON.stringify(entry)
entryMap.set(hash, undefined)
}
let collectionStack = [target.contents]
const _getProperty = (object, keyPath) => keyPath.split('.').filter(k => k).reduce((p, k) => p && p[k], object)
const _getYAMLProperty = (object, keyPath) => keyPath.split('.').filter(k => k).reduce((p, k) => p && p.get(k), object)
const _validateWhere = (node, dict, index) => {
const _addToMap = entries => entries?.filter(entry => {
if (typeof entry.in === 'string') return true // just a key path on the document root
const neededParent = dict.get(entry.in[0])?.node
return collectionStack.includes(neededParent) // lookbehind in parent collection stack
// REVISIT: Only look behind until sequence is reached?
}).forEach(item => {
const json = JSON.parse(String(node))
const whereFulfilled = Object.entries(item.where).every(([key, value]) =>
_getProperty(json, key) === value
)
if (whereFulfilled) {
const [collection] = collectionStack
dict.set(item, { json, node, index, collection })
}
})
if (additions) _addToMap(additions)
if (deletions) _addToMap(deletions.map(deletion => deletion.item))
if (relationships) _addToMap(relationships?.map(({into: [entry]}) => entry).filter(e => e))
if (overwrites) _addToMap(overwrites.map(overwrite => overwrite.item[0]))
}
function _traverseYAMLNode(node, index, actions, templateNode) {
let shifted = false
if (YAML.isMap(node)) {
actions.visitMap?.(node, index)
const [collection] = collectionStack
if (YAML.isSeq(collection)) {
collectionStack.unshift(node)
shifted = true
}
} else if (YAML.isPair(node) && node.value?.items) {
collectionStack.unshift(node.value)
} else if (YAML.isScalar(node) && semantics.forceOverwrite && templateNode && node.comment != 'cds.noOverwrite') {
node.value = templateNode.value ?? templateNode.items?.[0]?.value?.value
}
if (node.items) {
if (YAML.isSeq(node) && templateNode?.items) {
actions.mergeCollection?.(node, templateNode)
_traverseYAMLCollection(node, actions, templateNode)
} else {
_traverseYAMLCollection(node, actions, templateNode)
}
} else if(node.value?.source === '' && YAML.isSeq(templateNode?.value)) {
node.value = new YAML.YAMLSeq()
collectionStack.unshift(node.value)
_traverseYAMLNode(node.value, index, actions, templateNode?.value)
} else if (node.value?.items || node.value && semantics.forceOverwrite) {
_traverseYAMLNode(node.value, index, actions, templateNode?.value)
}
if (node.value?.items || shifted) {
collectionStack.shift()
}
}
_traverseYAMLNode.bind(this)
function _traverseYAMLCollection (collection, actions, templateCollection) {
if (!collection) return
const keyToIndex = new Map, templateIndexToIndex = new Map
// Map collection items to their semantic counterpart(s)
collection.items.forEach((node, i) => {
if (node.key) keyToIndex.set(node.key.value, i)
const entry = [...entryMap.entries()].find(([,value]) => value?.node === node)
if (entry) {
const [entryKey] = entry
const templateEntry = entryKey && templateEntryMap.get(entryKey)
const templateIndex = templateEntry?.index
if (templateIndex !== undefined) templateIndexToIndex.set(templateIndex, i)
}
if (!templateCollection) _traverseYAMLNode(node, i, actions)
})
if (!templateCollection?.items) return
templateCollection.items?.forEach((templateNode, templateIndex) => {
if (YAML.isScalar(templateNode)) {
if (!collection.items.map(item => item.value).includes(templateNode.value)) {
collection.add(templateNode)
}
} else if (YAML.isPair(templateNode)) {
const i = keyToIndex.get(templateNode.key.value)
const [collection] = collectionStack
const targetNode = collection.items?.[i]
if (targetNode) {
_traverseYAMLNode(targetNode, templateIndex, actions, templateNode)
} else {
actions.mergePair?.(collection, templateNode)
}
} else if (YAML.isMap(templateNode)) {
const targetNode = collection.items[templateIndexToIndex.get(templateIndex)]
actions.mergeCollection?.(targetNode, templateNode)
if (targetNode) {
_traverseYAMLNode(targetNode, templateIndex, actions, templateNode)
}
}
})
}
_traverseYAMLCollection.bind(this)
// 1. Register the entries in the template
_traverseYAMLNode(source.contents, null, {
visitMap: (node, index) => _validateWhere(node, templateEntryMap, index)
})
// 2. Register the entries in the project
_traverseYAMLNode(target.contents, null, {
visitMap: (node, index) => _validateWhere(node, entryMap, index)
})
// 3. Apply overwrites to already found entries
overwrites?.forEach(({ item, withValue }) => {
const keyPath = Array.isArray(item) && typeof item[0] === 'string' ? item[0] : typeof item === 'string' ? item : item[1]
const inEntry = Array.isArray(item) && typeof item[0] === 'object' ? item[0] : typeof item === 'object' ? item : item[1]
const node = inEntry && entryMap.get(inEntry) ? entryMap.get(inEntry).node : collectionStack[collectionStack.length - 1]
const keys = keyPath.split('.')
if (!node.getIn(keys)) return
_getYAMLProperty(node, keys.slice(0, keys.length - 1).join('.'))
.set(keys[keys.length - 1], withValue)
})
// 4. Delete entries from the project (e.g. separate deployer module when adding mtx)
deletions?.forEach(({ item, relationships, key }) => {
const entry = entryMap.get(item)
if (!entry) return
if (key) entry.collection.deleteIn([entry.index, ...key.split('.')])
else entry.collection.delete(entry.index)
relationships?.forEach(relationship => {
const [allWithinKeyPath, inKeyPath, into] = relationship.allWithin
const parent = _getYAMLProperty(target, allWithinKeyPath)
for (const child of parent.items) {
const grandchild = _getYAMLProperty(child, inKeyPath)
const i = grandchild?.items?.findIndex(node =>
node.get(into) === _getProperty(entry.json, relationship.removeProperty)
) ?? - 1
if (i > -1) grandchild.delete(i)
}
})
})
// 5. Create missing entries and pairs
_traverseYAMLNode(target.contents, null, {
mergePair: (collection, templateNode) => {
if (YAML.isMap(collection)) {
collection.add(templateNode)
}
},
mergeCollection: (targetNode, templateNode) => {
if (YAML.isSeq(templateNode)) {
let [,parent] = collectionStack
additions?.filter(addition => {
if (typeof addition.in === 'string' || Array.isArray(addition.in) && typeof addition.in[0] === 'string') return true
const inEntry = Array.isArray(addition.in) ? entryMap.get(addition.in[0]) : entryMap.get(addition.in)
return inEntry?.node === parent
})
.filter(addition => {
const keyPath = typeof addition.in === 'string' ? addition.in : Array.isArray(addition.in) && typeof addition.in[0] === 'string' ? addition.in[0] : addition.in[1]
const keys = keyPath.split('.')
if (keys.length > collectionStack.length + 1) return false
if (keys.length > 1) parent = collectionStack[keys.length]
return _getYAMLProperty(parent, keyPath) === targetNode
})
.filter(item => !entryMap.get(item))
.forEach(item => {
if (!templateEntryMap.get(item)) throw 'Error: did not find entry in template for ' + inspect(item, { colors, depth:11 }) + ' in ' + from
const templateNode = templateEntryMap.get(item).node
item.at !== undefined ? targetNode.items.splice(item.at, 0, templateNode) : targetNode.add(templateNode)
})
}
},
}, source.contents)
// 6. Re-register the entries in the project
_traverseYAMLNode(target.contents, null, {
visitMap: (node, index) => _validateWhere(node, entryMap, index)
})
// 7. Create missing relationships
_traverseYAMLNode(target.contents, null, {
mergeCollection: (targetNode, templateNode) => {
if (YAML.isSeq(templateNode)) {
const targetJSON = YAML.parse(String(targetNode))
const relationships = semantics.relationships?.filter(relationship => {
const [entry, keyPath] = relationship.into
if (!entryMap.get(entry)) return false
const existingNode = _getYAMLProperty(entryMap.get(entry).node, keyPath)
return targetNode === existingNode
}) ?? []
for (const { into, insert } of relationships) {
const intoKey = into[into.length - 1]
const [insEntry, entryKeyPath] = insert
const missingPairs = [insEntry]
.filter(item => {
if (!entryMap.get(item)) {
const spec = inspect(item, { colors, depth:11 })
throw new Error(`Did not find entry in template for ${spec} in ${from}:\n${source}`)
}
return !targetJSON.some(targetItem =>
_getProperty(entryMap.get(item).json, entryKeyPath) === targetItem[intoKey]
)
})
.map(item => entryMap.get(item).node.get(intoKey))
for (const pair of missingPairs) {
targetNode.add({ [intoKey]: pair })
}
}
}
}
}, source.contents)
typeof into === 'string' && await writeYAML(into, target)
return target
}
/**
* @param {...(string | Object | YAML.Document)} src The source to merge from.
* @returns {{
* into: (dst: (string | Object | YAML.Document), options?: {project?: Object, semantics?: MergingSemantics}) => Promise<any>,
* remove: (items: (string | object)[]) => Promise<void>
* }} An object with an 'into' method to merge the sources into the specified destination.
*/
module.exports.merge = (...src) => {
return {
/**
* Merges sources into the specified destination.
* @param {(string | Object | YAML.Document)} dst File path or object to merge into.
* @param {Object} [options] Merging options.
* @param {Object} [options.project] Project descriptor used for Mustache replacements.
* @param {MergingSemantics} [options.semantics] Merging algorithm semantics.
* @returns {Promise} A promise resolving to the merged object.
*/
into: (dst, { with: withProject, project, ...semantics } = {}) => {
if (Object.keys(semantics).length === 0) semantics = undefined
project ??= withProject // to allow { with: project } for a more fluent API
const isYAML = typeof dst === 'string' && ['.yaml', '.yml', '.yaml.hbs', '.yml.hbs'].some(ext => dst.endsWith(ext))
const _merge = isYAML ? _mergeYAML : _mergeJSON
const target = typeof dst === 'string' ? resolve(cds.root, dst) : dst
if (typeof src[src.length - 1] === 'string') {
return _merge(target, join(...src), project, semantics)
} else {
return _merge(target, ...src, project, semantics)
}
},
/**
* Removes items from the specified YAML file.
* @param {(string | object)[]} items An array of dotted‐path strings or objects to remove.
* @returns {Promise<void>} A promise that resolves once the file is updated.
*/
remove: async items => {
const yaml = await readYAML(resolve(cds.root, ...src))
for (const item of items) {
if (typeof item === 'string') {
const keys = item.split('.')
let node = yaml
for (let i = 0; i < keys.length - 1; i++) {
node = node.get(keys[i])
if (node === undefined) break
}
node?.delete?.(keys[keys.length - 1])
} else if (item && typeof item === 'object') {
const stack = [{ node: yaml, obj: item }]
while (stack.length) {
const { node, obj } = stack.pop()
for (const [k, v] of Object.entries(obj)) {
const child = node.get(k)
if (YAML.isSeq(child)) {
child.items = child.items.filter(x => {
if (YAML.isMap(x)) {
return !Object.entries(v).every(([kk, vv]) => x.get(kk) === vv)
}
return true
})
} else if (YAML.isMap(child)) {
stack.push({ node: child, obj: v })
}
}
}
}
await writeYAML(join(...src), yaml)
}
}
}
}