UNPKG

@sap/cds-dk

Version:

Command line client and development toolkit for the SAP Cloud Application Programming Model

574 lines (516 loc) 21.5 kB
const { randomUUID } = require('node:crypto') const { dirname } = require('node:path') const cds = require('../../../cds') let counter = Math.round(Math.random() * 10000) // Default random seed // Non-deterministic behaviour const MAX_ASSOC_DEPTH = 5 /** * Creates JSON test data for the given entities * * @param {Record<string, object[]>|undefined} data the data with entities and their records to be created * @param {object} csn the model * @param {Number} numRecords the number of records to be created for each artifact * @param {{referenceData? : Record<string, string}} options misc. options * @returns {Record<string, object[]>} the filled data */ const asJson = module.exports = async (data, csn, numRecords=1, options={}) => { let { referenceData, contentType, artifact='entity' } = options // if no data is given, use all data if (!data) { data = csn.all(artifact).reduce((all, e) => { all[e.name] = []; return all }, {}) } // if no reference data is given, scan for it using cds.deploy.resources if (!referenceData) referenceData = await cds.deploy.resources(csn) // sort all entities first that are referenced by others, so that dependant entities can be created after them const { definitions: defs } = csn const names = Object.keys(data).sort((n1, n2) => { const n1HasComp = Object.values(defs[n1].elements??[]).some(el => el.type === 'cds.Composition') const n2HasComp = Object.values(defs[n2].elements??[]).some(el => el.type === 'cds.Composition') if (n1HasComp && !n2HasComp) return -1 if (!n1HasComp && n2HasComp) return 1 return 0 }) // requested entities shall always be created, so remove it from reference data Object.entries(referenceData).filter(([, name]) => names.includes(name)).forEach(([file]) => delete referenceData[file]) addSemantics(csn) for (const name of names) { if (!data[name] // removed on purpose because records are created inline through compositions || data[name].length >= numRecords) { // numRecords already created by following associations continue } let numRecordsToGenerate // numRecords might be reduced if some records were created by following associations let records = [] if (data[name].length > 0) { numRecordsToGenerate = numRecords - data[name].length records = Array(numRecordsToGenerate).fill({}).map(() => ({})) } else { numRecordsToGenerate = numRecords records = data[name] = Array(numRecordsToGenerate).fill({}).map(() => ({})) } const artifact = defs[name] const idNum = randomNumber() for (let i = 0; i < numRecordsToGenerate; i++) { fillRecord(records[i], idNum+i, artifact, csn, data, null, 0, referenceData) } data[name] = data[name].concat(records) // append the new records to the possibly existing and referenced records // remove records w/ duplicate keys. This can happen if the key is an enum (and we have less enum values than requested records). data[name] = uniqRecords(data[name], artifact) } for (let [key, d] of Object.entries(data)) { if (contentType === 'json' && d.deleteReference && !d.keepReferece) { delete data[key] } delete d.keepReferece delete d.deleteReference uniqRecords(d, csn.definitions[key]) } return data } function fillRecord(record, recordID, artifact, csn, data, parent, depth, referenceData) { Object.defineProperty(record, '$id', {value: recordID, enumerable: false}) if (data[artifact.name]) data[artifact.name].push(record) // add id to data, to be referenced by associations to prevent circular dependencies const elems = Object.values(artifact.elements ?? artifact.params ?? {}).sort(asJson.elementsSorter(csn)) elems.forEach(el => { record[el.name] = dataForField(el, artifact, csn, record, data, parent, depth, referenceData) }) return record } asJson.elementsSorter = function (csn) { return (el1, el2) => { // keys first, need to have them ready first for foreign key resolution anyway if ( el1.key && !el2.key) return -1 if (!el1.key && el2.key) return 1 // own elements first if ( isOwnElement(el1, el1.parent) && !isOwnElement(el2, el2.parent)) return -1 if (!isOwnElement(el1, el1.parent) && isOwnElement(el2, el2.parent)) return 1 return 0 } function isOwnElement(el, entity) { if (entity && entity === el.parent && (!entity.includes || entity.includes.length === 0)) { // shortcut if element's entity has no includes return true } // look for elements in entity includes/parents for (const inc of entity?.includes||[]) { if (csn.definitions[inc]?.elements?.[el.name]) return false if (!isOwnElement(el, csn.definitions[inc])) return false } return true } } function dataForField(element, artifact, csn, record, data, parent, depth, referenceData) { if (element.virtual || (element['@Core.Computed'] && element.value)) return if (record[element.name] !== undefined) return record[element.name] // value is already set by previous run, e.g. CodeList.code sets .name and .descr if (element.items?.elements) // `array of` / `many` with complex type return [ fillRecord({}, record['$id'], element.items, csn, data, null, depth+1, referenceData) ] else if (element.items?.type) // `array of` / `many` with simple type return [ dataForField(element.items, artifact, csn, record, data, null, depth+1, referenceData) ] if (element.is_struct) { let struct = {} for (const el of element.elements) { struct[el.name] = dataForField(el, artifact, csn, record, data, null, depth+1, referenceData) } return struct } const type = element._type || element.type switch (type) { case 'cds.Time': return '22:59:53' case 'cds.Timestamp': { const range = {from: new Date('2000-01-01'), to: new Date('2024-01-01')} return randomDateInRange(range).toISOString() } case 'cds.Date': { const range = {from: new Date('2000-01-01'), to: new Date('2024-01-01')} annotatedDateRange(element, range) const generatedDate = randomDateInRange(range) const date = generatedDate.toISOString().split('T', 1)[0] return date } case 'cds.DateTime': { const range = {from: new Date('2000-01-01'), to: new Date('2024-01-01')} annotatedDateRange(element, range) const generatedDate = randomDateInRange(range) return generatedDate.toISOString() } case 'cds.hana.CHAR': case 'cds.hana.CLOB': case 'cds.hana.NCHAR': case 'cds.hana.VARCHAR': case 'cds.LargeString': case 'cds.String': { if (element['@data.gen']?.startsWith('faker.')) { return fakerValue(element['@data.gen'], csn) } if (element.enum) { const enumName = randomFromList(Object.keys(element.enum)) const value = element.enum[enumName].val ?? element.enum[enumName].value ?? enumName // key of common.CodeList, also fill name and descr with enum name if (element.key && artifact.elements.name && artifact.elements.descr) record.name = record.descr = enumName return value } if (artifact['@Communication.Contact.email']?.some(mail => mail.address?.['='] === element.name) || element['@Communication.IsEmailAddress'] || element['@cds.on.insert']?.['='] === '$user' || element['@cds.on.update']?.['='] === '$user') { return randomEmailAddress(element.name, record['$id'], csn) } let str = `${element.name}-${record['$id']}` if (element.key) { str = `${artifact.name.split('.').pop()}-${record['$id']}` if (element.name === 'locale' && artifact.name.endsWith('texts')) { const record = randomFromReferenceData(artifact, csn, referenceData) if (record) return record.locale return randomLanguage() } } if (element['@assert.format']) { str = randomStrForRegex(element['@assert.format'], csn) } if (element.length && str.length > element.length) { str = str.slice(-element.length) // truncate to length } return str } case 'cds.UUID': { // create a pseudo-random UUID based on the record ID const idStr = '' + record['$id'] return idStr + randomUUID().slice(idStr.length) } case 'cds.Boolean': return (record['$id'].toString(2).at(-1)) < 0.5 case 'cds.hana.SMALLINT': case 'cds.hana.TINYINT': case 'cds.UInt8': case 'cds.Int16': case 'cds.Int32': case 'cds.Int64': case 'cds.Integer64': case 'cds.Integer': { if (element.key) return record['$id'] if (element.enum) { const enumName = randomFromList(Object.keys(element.enum)) const value = element.enum[enumName].val ?? element.enum[enumName].value ?? enumName return parseInt(value) } const range = {from: 0, to: 100} annotatedIntRange(element, range) return randomIntInRange(range) } case 'cds.hana.REAL': case 'cds.Decimal': { const prec = element.precision ? Math.min(Math.pow(10, element.precision), Number.MAX_SAFE_INTEGER) : 100 const range = { from: 0, to: prec } const scale = element.scale ?? (element.precision ? 0 : 2) // scale is 0 if only precision is given annotatedIntRange(element, range) return +(randomDecimalInRange(range).toFixed(scale)) } case 'cds.Double': { const range = {from: 0, to: 100} return randomDecimalInRange(range) } case 'cds.Composition': { const target = csn.definitions[element.target] if (!target || artifact.name === target.name || depth > MAX_ASSOC_DEPTH) return let targetRecords = [] // in findKeysInOnCondition target records are linked to the entity, we don't want to overwrite any existing data if (data[target.name]) data[target.name].deleteReference = true // for json composites references are nested and will be deleted when writing to file const targetRecordCount = element.is2many ? 2 : 1 // TODO: make this configurable const targetRecordID = randomNumber() for (let i = 0; i < targetRecordCount; i++) { const newRecord = fillRecord({}, targetRecordID+i, target, csn, data, artifact, depth+1, referenceData) if (newRecord) targetRecords.push(newRecord) } const [ownKey, ownKeyStructured, targetKey] = findKeysInOnCondition(element, target) if (ownKey && targetKey) { const value = ownKeyStructured ? { [ownKey]: record[ownKey] } : record[ownKey] targetRecords.forEach(targetRecord => { // Set parent in target as non-enumerable. Json serialization doesn't need it, but csv does. Object.defineProperty(targetRecord, targetKey, { value, enumerable: false }) }) } uniqRecords(targetRecords, target) // consistent return data type: array return element.is2many ? targetRecords : [targetRecords[0]] } case 'cds.Association': { if (element.is2many) return const target = csn.definitions[element.target] if (!target || depth > MAX_ASSOC_DEPTH) return let targetRecord = randomFromReferenceData(target, csn, referenceData) if (targetRecord) { delete data[target.name] // indicate that existing data is used and no new data shall be created delete data[target.name + '.texts'] // also delete associated texts entity } else { let targetRecords = data[target.name] if (!targetRecords && (element['@mandatory']) || element['@assert.target']) targetRecords = data[target.name] = [] else if (!targetRecords) // target entity is not in the list of entities to be created return if (targetRecords.length > 0) { // use record generated by following asociation targetRecord = targetRecords.at(-1) // make sure the localized record references the newly generated texts } else { targetRecord = fillRecord({}, randomNumber(), target, csn, data, artifact, depth+1, referenceData) if (targetRecord) { targetRecords.push(targetRecord) uniqRecords(targetRecords, target) } if (parent && (parent.name != artifact.name)) data[target.name].keepReferece = true } } // TODO: handle foreign keys correctly const assoc = {} Object.keys(target.elements) .filter(keyName => target.elements[keyName].key) .forEach(keyName => assoc[keyName] = targetRecord[keyName]) return assoc } case 'cds.hana.BINARY': case 'cds.Binary': case 'cds.LargeBinary': // TODO add binary return case 'cds.Vector': // TODO add Vector return default: { console.error(`Unknown type ${element._type} (${element.type}) in '${artifact.name}:${element.name}'`) return } } } function randomFromReferenceData (entity, csn, referenceData) { const files = Object.entries(referenceData) .filter(([, name]) => matchesEntityOrProjection(name, entity, csn)) .map(([file]) => file) if (files.length === 0) return const toCSV = (content) => { const records = cds.parse.csv(content) if (records?.length > 1) { const header = records[0] // 0 is header line const randomRecord = records.length === 2 ? records[1] : records[randomIntInRange({from: 1, to: records.length-1} )] let result = {} for (let i = 0; i < records[0].length; i++) { // For csv, which is all strings, convert decimal values to numbers // boolean is already covered by CSV parser const val = /^cds\.(U?Int|Decimal|Double)/.test(entity.elements[header[i]]?.type) ? +randomRecord[i] : randomRecord[i] result[header[i]] = val } return result } } const toJSON = (records) => { if (records.length === 1) return records[0] else if (records.length > 1) return records[randomIntInRange({from: 0, to: records.length-1} )] } if (files[0].endsWith('.csv')) { const content = cds.utils.fs.readFileSync(files[0]).toString() return toCSV(content) } else if (files[0].endsWith('.json')) { const content = cds.utils.fs.readFileSync(files[0]).toString() const records = JSON.parse(content) return toJSON(records) } else { const content = files[0] // for tests, assuming the content is given inline try { const records = JSON.parse(content) return toJSON(records) } catch { return toCSV(content) } } } /* whether the given name matches the entity or one of its projections */ function matchesEntityOrProjection(name, entity, csn) { if (name === entity?.name) return true // follow simle projections const p = entity?.query?.SELECT || entity?.projection if (p?.from?.ref?.length === 1) { if (p.from.ref[0] === name) return true return matchesEntityOrProjection(name, csn.definitions[p.from.ref[0]], csn) } } function findKeysInOnCondition(assocElement, target) { const { on } = assocElement let ownKey, targetKey, ownKeyStructured if (on?.length === 3 && on[1] === '=') { for (let { ref } of [on[0], on[2]]) { if (ref.length > 1 && ref[0] === assocElement.name && target.elements[ref[1]]) { targetKey = ref[1] } else if (ref[0] === '$self') { ownKey = Object.keys(assocElement.parent.keys)[0] // TODO composite keys ownKeyStructured = true } else if (assocElement.parent.elements[ref[0]]) { ownKey = ref[0] } } } return [ownKey, ownKeyStructured, targetKey] } function uniqRecords(records, artifact) { const keys = Object.keys(artifact.keys||{}) if (keys.length === 0) keys.push('$id') // no keys (e.g. in actions) -> use record's $id const keySet = new Set() for (let i = 0; i < records.length; i++) { const key = keys.map(k => records[i][k]).join('|') if (keySet.has(key)) { // console.debug('Removing', keys, key, 'from', artifact.name) records.splice(i, 1) i-- } keySet.add(key) } return records } function annotatedDateRange(el, dateRange) { if (el['@assert.range']?.length === 2) { const dateFrom = new Date(el['@assert.range'][0]) const dateTo = new Date(el['@assert.range'][1]) if (dateRange.from < dateFrom) { dateRange.from = dateFrom } if (dateRange.to > dateTo) { dateRange.to = dateTo } } } function annotatedIntRange(el, intRange) { if (el['@assert.range']?.length === 2) { const rangeFrom = el['@assert.range'][0] const rangeTo = el['@assert.range'][1] if (Number.isInteger(rangeFrom) && intRange.from < rangeFrom) { intRange.from = rangeFrom } if (Number.isInteger(rangeTo) && intRange.to > rangeTo) { intRange.to = rangeTo } } //TODO : decimalRange } const langList = ['ar', 'cs', 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja', 'ko', 'ms', 'nl', 'pl', 'pt', 'ro', 'ru', 'sv', 'th', 'tr', 'zh'] function randomLanguage() { return randomFromList(langList) } function randomFromList(list) { return list[randomNumber() % list.length] } function randomEmailAddress(firstName, num, csn) { let addr = fakerValue('faker.internet.email', csn, false) if (addr) return addr const provider = randomFromList(['example.com', 'example.org', 'example.net']) return `${firstName}.${num.toString(36)}@${provider}` } function randomStrForRegex(regexStr, csn) { // TODO make 'randexp' a hard dependency when this code is in a plugin const randexp = requirePackage('randexp', csn, regexStr) if (!randexp) return `Install package 'randexp' to create data for regex ${regexStr}` return randexp.randexp(regexStr) } function fakerValue(expr, csn, useFallback=true, ...args) { // `en` bundle is way smaller than full faker. TODO use different Faker locales? const Faker = requirePackage('@faker-js/faker/locale/en', csn) if (!Faker && !useFallback) return if (!Faker) return `Install package '@faker-js/faker' to create data for ${expr}` let resultPart let [, category, func] = expr.split('.') const cat = Faker.faker[category] if (typeof cat !== 'object') return `Unknown faker category ${category} in ${expr}` const funcParts = func.split('::') if (funcParts.length === 2) { resultPart = funcParts.pop() func = funcParts.pop() } const fn = cat[func] if (typeof fn !== 'function') return `Unknown faker function ${func} in ${expr}` if (category === 'internet' && func === 'email') { // only allow domains that cannot be registered const provider = randomFromList(['example.com', 'example.org', 'example.net']) return fn({ provider }) } let res = args ? fn(...args) : fn() if (res && resultPart) { return res[resultPart] } return res } const packages = {} function requirePackage(id, csn) { try { if (!packages[id]) { const pkg = resolvePackage(id, csn) if (pkg) packages[id] = require(pkg) } return packages[id] } catch (err) { if (err.code !== 'MODULE_NOT_FOUND') throw err } } function resolvePackage(id, csn) { try { return require.resolve(id, { paths: [...csn.$sources.map(dirname), cds.root, __dirname] }) } catch (err) { if (err.code !== 'MODULE_NOT_FOUND') throw err } } // add semantics to well-known entities function addSemantics(csn) { if (resolvePackage('@faker-js/faker', csn)) { // only if faker is installed csn.all('entity').forEach(entity => { const { elements } = entity if (entity.name === 'sap.common.Countries') { elements.code['@data.gen'] = 'faker.location.countryCode' elements.name['@data.gen'] = elements.descr['@data.gen'] = 'faker.location.country' } else if (entity.name === 'sap.common.Currencies') { elements.code['@data.gen'] = 'faker.finance.currency::code' elements.name['@data.gen'] = elements.descr['@data.gen'] = 'faker.finance.currency::name' elements.symbol['@data.gen'] = 'faker.finance.currency::symbol' } else if (entity.name === 'sap.common.Timezones') { elements.code['@data.gen'] = 'faker.location.timeZone' elements.name['@data.gen'] = elements.descr['@data.gen'] = 'faker.location.timeZone' } }) } } function randomDateInRange(dateRange) { const dateFrom = dateRange.from const returnDate = new Date(dateFrom.getUTCFullYear(), dateFrom.getUTCMonth(), dateFrom.getUTCDate())//needed to avoid overwriting the original dateFrom when generating a random Date const diffTime = dateRange.to.valueOf() - dateRange.from.valueOf(); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); const step = randomNumber() % diffDays returnDate.setUTCDate(returnDate.getUTCDate() + step); return returnDate } function randomDecimalInRange(decimalRange) { const max = decimalRange.to const min = decimalRange.from const num = min + (randomNumber() % (max - min)) let dec = randomNumber() const divider = Math.pow(10, dec.toString().length) dec = dec / divider const returnValue = num + dec return returnValue } function randomIntInRange(intRange) { const max = intRange.to const min = intRange.from return min + (randomNumber() % max) } const p = 5651; const q = 5623; const M = p * q; function randomNumber() { counter = (counter * counter) % M return counter; }