UNPKG

mcdev

Version:

Accenture Salesforce Marketing Cloud DevTools

514 lines (475 loc) 18.9 kB
'use strict'; import { Util } from '../util/util.js'; import MetadataType from './MetadataType.js'; import File from '../util/file.js'; import cache from '../util/cache.js'; /** * @typedef {import('../../types/mcdev.d.js').BuObject} BuObject * @typedef {import('../../types/mcdev.d.js').CodeExtract} CodeExtract * @typedef {import('../../types/mcdev.d.js').CodeExtractItem} CodeExtractItem * @typedef {import('../../types/mcdev.d.js').MetadataTypeItem} MetadataTypeItem * @typedef {import('../../types/mcdev.d.js').MetadataTypeItemDiff} MetadataTypeItemDiff * @typedef {import('../../types/mcdev.d.js').MetadataTypeItemObj} MetadataTypeItemObj * @typedef {import('../../types/mcdev.d.js').MetadataTypeMap} MetadataTypeMap * @typedef {import('../../types/mcdev.d.js').MetadataTypeMapObj} MetadataTypeMapObj * @typedef {import('../../types/mcdev.d.js').SoapRequestParams} SoapRequestParams * @typedef {import('../../types/mcdev.d.js').TemplateMap} TemplateMap */ /** * @typedef {import('../../types/mcdev.d.js').QueryItem} QueryItem * @typedef {import('../../types/mcdev.d.js').QueryMap} QueryMap */ /** * Query MetadataType * * @augments MetadataType */ class Query extends MetadataType { /** * Retrieves Metadata of queries * * @param {string} retrieveDir Directory where retrieved metadata directory will be saved * @param {void | string[]} [_] unused parameter * @param {void | string[]} [__] unused parameter * @param {string} [key] customer key of single item to retrieve * @returns {Promise.<{metadata: QueryMap, type: string}>} Promise of metadata */ static async retrieve(retrieveDir, _, __, key) { await File.initPrettier('sql'); let objectId = null; if (key) { objectId = await this._getObjectIdForSingleRetrieve(key); if (!objectId) { // avoid running the rest request below by returning early Util.logger.info( `Downloaded: ${this.definition.type} (0)${Util.getKeysString(key)}` ); return { metadata: {}, type: this.definition.type }; } } return super.retrieveREST( retrieveDir, '/automation/v1/queries/' + (objectId || ''), null, key ); } /** * a function to start query execution via API * * @param {string[]} keyArr customerkey of the metadata * @returns {Promise.<string[]>} Returns list of keys that were executed successfully */ static async execute(keyArr) { const results = []; // works only with objectId let objectId; for (const key of keyArr) { if (key) { objectId = await this._getObjectIdForSingleRetrieve(key); if (!objectId) { Util.logger.info(`Skipping ${key} - did not find an item with such key`); continue; } } results.push( super.executeREST(`/automation/v1/queries/${objectId}/actions/start/`, key) ); } const executedKeyArr = (await Promise.all(results)) .filter((r) => r.response === 'OK') .map((r) => r.key); Util.logger.info(`Executed ${executedKeyArr.length} of ${keyArr.length} items`); return executedKeyArr; } /** * helper to allow us to select single metadata entries via REST * * @private * @param {string} key customer key * @returns {Promise.<string>} objectId or enpty string */ static async _getObjectIdForSingleRetrieve(key) { const response = await this.client.soap.retrieve('QueryDefinition', ['ObjectID'], { filter: { leftOperand: { leftOperand: 'CustomerKey', operator: 'equals', rightOperand: key, }, operator: 'AND', rightOperand: { leftOperand: 'Status', operator: 'equals', rightOperand: 'Active', }, }, }); return response?.Results?.length ? response.Results[0].ObjectID : null; } /** * Retrieves query metadata for caching * * @returns {Promise.<{metadata: QueryMap, type: string}>} Promise of metadata */ static async retrieveForCache() { return super.retrieveREST(null, '/automation/v1/queries/'); } /** * Retrieve a specific Query by Name * * @deprecated Use `retrieve` followed by `build` instead. `retrieveAsTemplate` will be removed in a future version. * @param {string} templateDir Directory where retrieved metadata directory will be saved * @param {string} name name of the metadata file * @param {TemplateMap} templateVariables variables to be replaced in the metadata * @returns {Promise.<{metadata: Query, type: string}>} Promise of metadata */ static async retrieveAsTemplate(templateDir, name, templateVariables) { Util.logDeprecated('retrieveAsTemplate', `'retrieve' followed by 'build'`); await File.initPrettier('sql'); return super.retrieveREST( templateDir, '/automation/v1/queries/?$filter=Name%20eq%20' + encodeURIComponent(name), templateVariables ); } /** * manages post retrieve steps * * @param {QueryItem} metadata a single query * @returns {CodeExtractItem} Array with one metadata object and one query string */ static postRetrieveTasks(metadata) { // folder super.setFolderPath(metadata); // extract SQL const codeArr = [ { subFolder: null, fileName: metadata[this.definition.keyField], fileExt: 'sql', content: metadata.queryText, }, ]; delete metadata.queryText; try { if (metadata.targetId) { // overwrite targetKey via targetId (it's not updated on name/key change of the DE) const targetKey = cache.searchForField( 'dataExtension', metadata.targetId, 'ObjectID', 'CustomerKey' ); if (targetKey !== metadata.targetKey) { Util.logger.debug( ` - ${this.definition.type} ${metadata[this.definition.nameField]} (${ metadata[this.definition.keyField] }): Replacing targetKey value in saved JSON '${ metadata.targetKey }' --> '${targetKey}'. Acquired new value from looking up the DE's ObjectID in targetId.` ); } metadata.r__dataExtension_key = targetKey; } else { // if no targetId is set, at least check if the targetKey points to an existing DE (no override needed) metadata.r__dataExtension_key = cache.searchForField( 'dataExtension', metadata.targetKey, 'CustomerKey', 'CustomerKey' ); } delete metadata.targetKey; } catch (ex) { Util.logger.warn( ` - ${this.definition.type} ${metadata[this.definition.nameField]} (${ metadata[this.definition.keyField] }): ${ex.message}.` ); } delete metadata.targetId; return { json: metadata, codeArr: codeArr, subFolder: null }; } /** * Creates a single query * * @param {QueryItem} query a single query * @returns {Promise} Promise */ static create(query) { const uri = '/automation/v1/queries/'; return super.createREST(query, uri); } /** * Updates a single query * * @param {QueryItem} query a single query * @returns {Promise} Promise */ static update(query) { const uri = '/automation/v1/queries/' + query.queryDefinitionId; return super.updateREST(query, uri); } /** * prepares a Query for deployment * * @param {QueryItem} metadata a single query activity * @param {string} deployDir directory of deploy files * @returns {Promise.<QueryItem>} Promise */ static async preDeployTasks(metadata, deployDir) { // folder super.setFolderId(metadata); // reinject queryText metadata.queryText = await File.readFilteredFilename( deployDir + '/' + this.definition.type, metadata[this.definition.keyField] + '.' + this.definition.type + '-meta', 'sql' ); // dataExtension metadata.targetKey = cache.searchForField( 'dataExtension', metadata.r__dataExtension_key, 'CustomerKey', 'CustomerKey' ); // we've seen queries without this ID set - crucial in case the DE ever gets renamed to ensure the query keeps working metadata.targetId = cache.searchForField( 'dataExtension', metadata.r__dataExtension_key, 'CustomerKey', 'ObjectID' ); // set ID for Append / Overwrite/ Update action metadata.targetUpdateTypeId = this.definition.targetUpdateTypeMapping[metadata.targetUpdateTypeName]; if (!Util.OPTIONS.matchName) { // make sure the name is unique const thisCache = cache.getCache()[this.definition.type]; const relevantNames = Object.keys(thisCache).map((key) => ({ type: null, key: key, name: thisCache[key][this.definition.nameField], })); // if the name is already in the folder for a different key, add a number to the end metadata[this.definition.nameField] = this.findUniqueName( metadata[this.definition.keyField], metadata[this.definition.nameField], relevantNames ); } return metadata; } /** * helper for {@link Query.buildDefinitionForNested} * searches extracted SQL file for template variables and applies the market values * * @param {string} code code from extracted code * @param {TemplateMap} templateVariables variables to be replaced in the metadata * @returns {string} code with markets applied */ static applyTemplateValues(code, templateVariables) { // fix bad formatting applied by SQL Formatter Plus code = code .split(' { { { ') .join('{{{') .split('{ { { ') .join('{{{') .split(' } } } ') .join('}}}') .split(' } } }') .join('}}}'); // replace template variables with their values return super.applyTemplateValues(code, templateVariables); } /** * helper for {@link MetadataType.buildDefinition} * handles extracted code if any are found for complex types * * @param {string} templateDir Directory where metadata templates are stored * @param {string|string[]} targetDir (List of) Directory where built definitions will be saved * @param {QueryItem} metadata main JSON file that was read from file system * @param {TemplateMap} templateVariables variables to be replaced in the metadata * @param {string} templateName name of the template to be built * @returns {Promise.<string[][]>} list of extracted files with path-parts provided as an array */ static buildDefinitionForNested( templateDir, targetDir, metadata, templateVariables, templateName ) { return this._buildForNested( templateDir, targetDir, metadata, templateVariables, templateName, 'definition' ); } /** * helper for {@link MetadataType.buildTemplate} * handles extracted code if any are found for complex types * * @example queries are saved as 1 json and 1 sql file. both files need to be run through templating * @param {string} templateDir Directory where metadata templates are stored * @param {string|string[]} targetDir (List of) Directory where built definitions will be saved * @param {QueryItem} metadata main JSON file that was read from file system * @param {TemplateMap} templateVariables variables to be replaced in the metadata * @param {string} templateName name of the template to be built * @returns {Promise.<string[][]>} list of extracted files with path-parts provided as an array */ static buildTemplateForNested( templateDir, targetDir, metadata, templateVariables, templateName ) { return this._buildForNested( templateDir, targetDir, metadata, templateVariables, templateName, 'template' ); } /** * helper for {@link Query.buildTemplateForNested} / {@link Query.buildDefinitionForNested} * handles extracted code if any are found for complex types * * @private * @param {string} templateDir Directory where metadata templates are stored * @param {string|string[]} targetDir (List of) Directory where built definitions will be saved * @param {QueryItem} metadata main JSON file that was read from file system * @param {TemplateMap} templateVariables variables to be replaced in the metadata * @param {string} templateName name of the template to be built * @param {'definition'|'template'} mode defines what we use this helper for * @returns {Promise.<string[][]>} list of extracted files with path-parts provided as an array */ static async _buildForNested( templateDir, targetDir, metadata, templateVariables, templateName, mode ) { // get SQL from filesystem let code = await File.readFilteredFilename( [templateDir, this.definition.type], templateName + '.' + this.definition.type + '-meta', 'sql' ); try { if (mode === 'definition') { code = this.applyTemplateValues(code, templateVariables); } else if (mode === 'template') { code = this.applyTemplateNames(code, templateVariables); } } catch { throw new Error( `${this.definition.type}:: Error applying template variables on ${ templateName + '.' + this.definition.type }-meta.sql.` ); } // write to file const targetDirArr = Array.isArray(targetDir) ? targetDir : [targetDir]; const nestedFilePaths = []; // keep old name if creating templates, otherwise use new name const fileName = mode === 'definition' ? metadata[this.definition.keyField] : templateName; for (const targetDir of targetDirArr) { File.writeToFile( [targetDir, this.definition.type], fileName + '.' + this.definition.type + '-meta', 'sql', code ); nestedFilePaths.push([ targetDir, this.definition.type, fileName + '.' + this.definition.type + '-meta.sql', ]); } return nestedFilePaths; } /** * should return only the json for all but asset, query and script that are saved as multiple files * additionally, the documentation for dataExtension and automation should be returned * * @param {string[]} keyArr customerkey of the metadata * @returns {Promise.<string[]>} list of all files that need to be committed in a flat array ['path/file1.ext', 'path/file2.ext'] */ static async getFilesToCommit(keyArr) { const path = File.normalizePath([ this.properties.directories.retrieve, this.buObject.credential, this.buObject.businessUnit, this.definition.type, ]); const fileList = keyArr.flatMap((key) => [ File.normalizePath([path, `${key}.${this.definition.type}-meta.json`]), File.normalizePath([path, `${key}.${this.definition.type}-meta.sql`]), ]); return fileList; } /** * Standardizes a check for multiple messages but adds query specific filters to error texts * * @param {object} ex response payload from REST API * @returns {string[]} formatted Error Message */ static getErrorsREST(ex) { const errors = super.getErrorsREST(ex); if (errors?.length > 0) { return errors.map((msg) => msg.split('Error saving the Query field.').join('')); } return errors; } /** * Delete a metadata item from the specified business unit * * @param {string} key Identifier of data extension * @returns {Promise.<boolean>} deletion success flag */ static async deleteByKey(key) { // delete only works with the query's object id const objectId = key ? await this._getObjectIdForSingleRetrieve(key) : null; if (!objectId) { await this.deleteNotFound(key); return false; } return super.deleteByKeyREST('/automation/v1/queries/' + objectId, key); } /** * clean up after deleting a metadata item * * @param {string} customerKey Identifier of metadata item * @returns {Promise.<void>} - */ static async postDeleteTasks(customerKey) { // delete local copy: retrieve/cred/bu/.../...-meta.json // delete local copy: retrieve/cred/bu/.../...-meta.sql await super.postDeleteTasks(customerKey, [`${this.definition.type}-meta.sql`]); } /** * Gets executed after deployment of metadata type * * @param {MetadataTypeMap} upsertResults metadata mapped by their keyField as returned by update/create */ static async postDeployTasks(upsertResults) { if (Util.OPTIONS.execute) { Util.logger.info(`Executing: ${this.definition.type}`); await this.execute(Object.keys(upsertResults)); } } } // Assign definition & cache to static attributes import MetadataTypeDefinitions from '../MetadataTypeDefinitions.js'; Query.definition = MetadataTypeDefinitions.query; export default Query;