mcdev
Version:
Accenture Salesforce Marketing Cloud DevTools
433 lines (396 loc) • 18.5 kB
JavaScript
;
import MetadataType from './MetadataType.js';
import { Util } from '../util/util.js';
import DataExtension from './DataExtension.js';
import { confirm } from '@inquirer/prompts';
/**
* @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').DataExtensionFieldMap} DataExtensionFieldMap
* @typedef {import('../../types/mcdev.d.js').DataExtensionFieldItem} DataExtensionFieldItem
*/
/**
* DataExtensionField MetadataType
*
* @augments MetadataType
*/
class DataExtensionField extends MetadataType {
static fixShared_fields;
/**
* Retrieves all records and saves it to disk
*
* @param {string} retrieveDir Directory where retrieved metadata directory will be saved
* @param {string[]} [additionalFields] Returns specified fields even if their retrieve definition is not set to true
* @returns {Promise.<{metadata: DataExtensionFieldMap, type: string}>} Promise of items
*/
static async retrieve(retrieveDir, additionalFields) {
return super.retrieveSOAP(retrieveDir, null, null, additionalFields);
}
/**
* Retrieves all records and saves it to disk
*
* @returns {Promise.<MetadataTypeMapObj>} Promise of items
*/
static async retrieveForCache() {
const cachedDEs = cache.getCache().dataExtension;
if (cachedDEs) {
await DataExtension.attachFields(cachedDEs);
}
return;
}
/**
* Retrieves all records for caching
*
* @param {SoapRequestParams} [requestParams] required for the specific request (filter for example)
* @param {string[]} [additionalFields] Returns specified fields even if their retrieve definition is not set to true
* @returns {Promise.<{metadata: DataExtensionFieldMap, type: string}>} Promise of items
*/
static async retrieveForCacheDE(requestParams, additionalFields) {
let response;
response = await super.retrieveSOAP(null, requestParams, null, additionalFields);
if (!response) {
// try again but without filters as a workaround for the "String or binary data would be truncated." issue
response = await super.retrieveSOAP(null, {}, null, additionalFields);
}
return response;
}
/**
* helper for DataExtension.retrieveFieldsForSingleDe that sorts the fields into an array
*
* @param {DataExtensionFieldMap} fieldsObj customerKey-based list of fields for one dataExtension
* @returns {DataExtensionFieldItem[]} sorted array of field objects
*/
static convertToSortedArray(fieldsObj) {
return (
Object.keys(fieldsObj)
.map((field) => fieldsObj[field])
// the API returns the fields not sorted
.sort(this.sortDeFields)
);
}
/**
* sorting method to ensure `Ordinal` is respected
*
* @param {DataExtensionFieldItem} a -
* @param {DataExtensionFieldItem} b -
* @returns {number} sorting based on Ordinal
*/
static sortDeFields(a, b) {
return a.Ordinal - b.Ordinal;
}
/**
* manages post retrieve steps; only used by DataExtension class
*
* @param {DataExtensionFieldItem} metadata a single item
* @returns {DataExtensionFieldItem} metadata
*/
static postRetrieveTasksDE(metadata) {
// remove fields according to definition
this.keepRetrieveFields(metadata);
// remove fields that we do not need after association to a DE
delete metadata.CustomerKey;
delete metadata.DataExtension;
delete metadata.Ordinal;
if (metadata.FieldType !== 'Decimal') {
// remove scale - it's only used for "Decimal" to define the digits behind the decimal
delete metadata.Scale;
}
return metadata;
}
/**
* Mofifies passed deployColumns for update by mapping ObjectID to their target column's values.
* Removes FieldType field if its the same in deploy and target column, because it results in an error even if its of the same type
*
* @param {DataExtensionFieldItem[]} deployColumns Columns of data extension that will be deployed
* @param {string} deKey external/customer key of Data Extension
* @returns {Promise.<DataExtensionFieldMap>} existing fields by their original name to allow re-adding FieldType after update
*/
static async prepareDeployColumnsOnUpdate(deployColumns, deKey) {
// create list of DE keys that had changes to their fields to be able to use it as a filter in the --fixShared logic
this.fixShared_fields ||= {};
this.fixShared_fields[deKey] ||= {};
// get row count to know which field restrictions apply
let hasData = false;
try {
const rowset = await this.client.rest.get(
`/data/v1/customobjectdata/key/${deKey}/rowset?$page=1&$pagesize=1`
);
const rowCount = rowset.count;
hasData = rowCount > 0;
Util.logger.debug(`dataExtension ${deKey} row count: ${rowCount}`);
} catch (ex) {
Util.logger.debug(`Could not retrieve rowcount for ${deKey}: ${ex.message}`);
}
// retrieve existing fields to enable updating them
const response = await this.retrieveForCacheDE(
{
filter: {
leftOperand: 'DataExtension.CustomerKey',
operator: 'equals',
rightOperand: deKey,
},
},
['Name', 'ObjectID']
);
const fieldsObj = response.metadata;
// ensure fields can be updated properly by their adding ObjectId based on Name-matching
/** @type {DataExtensionFieldMap} */
const existingFieldByName = {};
for (const key of Object.keys(fieldsObj)) {
// make sure we stringify the name in case it looked numeric and then lowercase it for easy comparison as the server is comparing field names case-insensitive
existingFieldByName[(fieldsObj[key].Name + '').toLowerCase()] = fieldsObj[key];
}
for (let i = deployColumns.length - 1; i >= 0; i--) {
const item = deployColumns[i];
// make sure we stringify the name in case it looked numeric and then lowercase it for easy comparison as the server is comparing field names case-insensitive
const itemOld = existingFieldByName[(item.Name + '').toLowerCase()];
if (itemOld) {
// field is getting updated ---
// Updating to a new FieldType will result in an error; warn & afterwards remove it
if (itemOld.FieldType !== item.FieldType) {
// applicable: with or without data but simply ignored by API
Util.logger.error(
` - The Field Type of an existing field cannot be changed. [${deKey}].[${item.Name}]: '${itemOld.FieldType}'`
);
}
if (item.FieldType !== 'Decimal') {
// remove scale - it's only used for "Decimal" to define the digits behind the decimal
delete item.Scale;
}
delete item.FieldType;
if (itemOld.MaxLength > item.MaxLength) {
// applicable: with or without data (Code 310007)
Util.logger.error(
` - The length of an existing field cannot be decreased. [${deKey}].[${item.Name}]: '${itemOld.MaxLength}'`
);
}
if (Util.isFalse(itemOld.IsRequired) && Util.isTrue(item.IsRequired)) {
// applicable: with or without data (Code 310007)
Util.logger.error(
` - A field cannot be changed to be required on update after it was created to allow nulls. [${deKey}].[${item.Name}]`
);
}
// enable renaming
if (item.Name_new) {
Util.logger.info(
` - Found Name_new='${item.Name_new}' for field ${deKey}.${item.Name} - trying to rename.`
);
item.Name = item.Name_new;
delete item.Name_new;
}
// check if any changes were found
let changeFound = false;
for (const key of Object.keys(item)) {
if (item[key] !== itemOld[key]) {
changeFound = true;
}
}
// share fields with fixShared logic
this.fixShared_fields[deKey][item.Name] = structuredClone(item);
this.fixShared_fields[deKey][item.Name].FieldType = itemOld.FieldType;
if (!changeFound) {
deployColumns.splice(i, 1);
Util.logger.verbose(`no change - removed field [${deKey}].[${item.Name}]`);
continue;
}
// set the ObjectId for clear identification during update
item.ObjectID = itemOld.ObjectID;
} else {
// field is getting added ---
if (hasData && Util.isTrue(item.IsRequired) && item.DefaultValue === '') {
// applicable: with data only
if (Util.isFalse(item.IsPrimaryKey)) {
Util.logger.warn(
` - Adding new fields to an existing table requires that these fields are either not-required (nullable) or have a default value set. Changing [${deKey}].[${item.Name}] to be not-required`
);
item.IsRequired = false;
} else {
Util.logger.error(
`- You cannot add a new primary key field to an existing table that has data. [${deKey}].[${item.Name}]`
);
}
}
if (item.Name_new) {
Util.logger.info(
` - Found Name_new='${item.Name_new}' for field ${deKey}.${item.Name} but could not find a corresponding DE field on the server - adding new field instead of updating.`
);
delete item.Name_new;
}
// Field doesn't exist in target, therefore Remove ObjectID if present
delete item.ObjectID;
this.fixShared_fields[deKey][item.Name] = structuredClone(item);
}
if (Util.isTrue(item.IsPrimaryKey) && Util.isFalse(item.IsRequired)) {
// applicable: with or without data
Util.logger.warn(
`- Primary Key field [${deKey}].[${item.Name}] cannot be not-required (nullable). Changing field to be required!`
);
item.IsRequired = true;
}
// filter bad manual changes to the json
if (!Util.isTrue(item.IsRequired) && !Util.isFalse(item.IsRequired)) {
Util.logger.error(
`- Invalid value for 'IsRequired' of [${deKey}].[${item.Name}]. Found '${item.IsRequired}' instead of 'true'/'false'.`
);
}
if (!Util.isTrue(item.IsPrimaryKey) && !Util.isFalse(item.IsPrimaryKey)) {
Util.logger.error(
`- Invalid value for 'IsPrimaryKey' of [${deKey}].[${item.Name}]. Found '${item.IsPrimaryKey}' instead of 'true'/'false'.`
);
}
}
Util.logger.info(
Util.getGrayMsg(
` - Found ${deployColumns.length} added/updated Fields for ${deKey}${
deployColumns.length ? ': ' : ''
}` + deployColumns.map((item) => item.Name).join(', ')
)
);
return existingFieldByName;
}
/**
* Delete a metadata item from the specified business unit
*
* @param {string} customerKey Identifier of data extension
* @returns {Promise.<boolean>} deletion success status
*/
static async deleteByKey(customerKey) {
return this.deleteByKeySOAP(customerKey);
}
/**
* Delete a data extension from the specified business unit
*
* @param {string} customerKey Identifier of metadata
* @param {string} [fieldId] for programmatic deletes only one can pass in the ID directly
* @returns {Promise.<boolean>} deletion success flag
*/
static async deleteByKeySOAP(customerKey, fieldId) {
const [deKey, fieldName] = customerKey.split('.');
customerKey = `[${deKey}].[${fieldName}]`;
let fieldObjectID = fieldId;
let deletionQueue;
// get the object id
if (!fieldObjectID) {
if (deKey === '*') {
const response = await this.retrieveForCacheDE(
{
filter: {
leftOperand: 'Name',
operator: 'equals',
rightOperand: fieldName,
},
},
['Name', 'ObjectID', 'DataExtension.CustomerKey']
);
deletionQueue = Object.values(response.metadata).map((item) => ({
fieldObjectID: item.ObjectID,
fieldName: fieldName,
deKey: item.DataExtension.CustomerKey,
}));
Util.logger.info(
` - Found ${deletionQueue.length} Data Extensions with field ${fieldName} in your BU:\n - ${deletionQueue
.map((item) => item.deKey)
.sort()
.join('\n - ')}`
);
if (deletionQueue.length > 0 && !Util.skipInteraction) {
const massDelete = await confirm({
message: `Do you really want to delete that field from all of the above Data Extensions?`,
default: false,
});
if (!massDelete) {
Util.logger.info(
` ☇ skipping deletion of ${fieldName} based on user-choice.`
);
return false;
}
}
} else {
const response = await this.retrieveForCacheDE(
{
filter: {
leftOperand: 'CustomerKey',
operator: 'equals',
rightOperand: customerKey,
},
},
['Name', 'ObjectID']
);
fieldObjectID = response.metadata[customerKey]?.ObjectID;
}
}
if (!fieldObjectID && !deletionQueue) {
Util.logger.error(`Could not find ${customerKey} on your BU`);
return false;
} else if (!deletionQueue) {
deletionQueue = [
{
fieldObjectID: fieldObjectID,
fieldName: fieldName,
deKey: deKey,
},
];
}
let success = true;
const fieldDeletedFromDEKeys = [];
for (const item of deletionQueue) {
// normal code
const keyObj = {
CustomerKey: item.deKey,
Fields: {
Field: {
ObjectID: item.fieldObjectID,
},
},
};
try {
// ! we really do need to delete from DataExtension not DataExtensionField here!
await this.client.soap.delete('DataExtension', keyObj, null);
if (!fieldId) {
Util.logger.info(
` - deleted ${this.definition.type}: ${item.deKey}.${item.fieldName}`
);
}
fieldDeletedFromDEKeys.push(item.deKey);
// return true;
} catch (ex) {
const errorMsg = ex?.results?.length
? `${ex.results[0].StatusMessage} (Code ${ex.results[0].ErrorCode})`
: ex?.json?.Results?.length
? `${ex.json.Results[0].StatusMessage} (Code ${ex.json.Results[0].ErrorCode})`
: ex.message;
Util.logger.error(
`- error deleting ${this.definition.type} ${item.deKey}.${item.fieldName}: ${errorMsg}`
);
success = false;
}
}
const uniqueDEKeys = [...new Set(fieldDeletedFromDEKeys)];
if (uniqueDEKeys.length > 0) {
Util.logger.info(
Util.getGrayMsg(
`To refresh your local files, run mcdev r ${this.buObject.credential}/${this.buObject.businessUnit} -m ${uniqueDEKeys
.map((key) => 'dataExtension:"' + key + '"')
.sort()
.join(' ')}`
)
);
}
return success;
}
}
// Assign definition to static attributes
import MetadataTypeDefinitions from '../MetadataTypeDefinitions.js';
import cache from '../util/cache.js';
DataExtensionField.definition = MetadataTypeDefinitions.dataExtensionField;
export default DataExtensionField;