mcdev 3.0.1 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.eslintrc.json +1 -1
  2. package/.github/ISSUE_TEMPLATE/bug.yml +72 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  4. package/.github/ISSUE_TEMPLATE/task.md +10 -0
  5. package/.github/PULL_REQUEST_TEMPLATE.md +11 -0
  6. package/.issuetracker +11 -3
  7. package/.vscode/settings.json +3 -3
  8. package/CHANGELOG.md +103 -1
  9. package/README.md +245 -141
  10. package/boilerplate/config.json +3 -2
  11. package/docs/dist/documentation.md +803 -337
  12. package/lib/Deployer.js +4 -1
  13. package/lib/MetadataTypeDefinitions.js +1 -0
  14. package/lib/MetadataTypeInfo.js +1 -0
  15. package/lib/Retriever.js +32 -17
  16. package/lib/cli.js +295 -0
  17. package/lib/index.js +774 -1019
  18. package/lib/metadataTypes/AccountUser.js +389 -0
  19. package/lib/metadataTypes/Asset.js +20 -12
  20. package/lib/metadataTypes/Automation.js +115 -52
  21. package/lib/metadataTypes/DataExtension.js +159 -113
  22. package/lib/metadataTypes/DataExtensionField.js +134 -4
  23. package/lib/metadataTypes/Folder.js +66 -69
  24. package/lib/metadataTypes/ImportFile.js +4 -6
  25. package/lib/metadataTypes/MetadataType.js +136 -67
  26. package/lib/metadataTypes/Query.js +2 -3
  27. package/lib/metadataTypes/Role.js +13 -8
  28. package/lib/metadataTypes/definitions/AccountUser.definition.js +227 -0
  29. package/lib/metadataTypes/definitions/Asset.definition.js +1 -0
  30. package/lib/metadataTypes/definitions/Campaign.definition.js +1 -1
  31. package/lib/metadataTypes/definitions/DataExtension.definition.js +1 -1
  32. package/lib/metadataTypes/definitions/DataExtensionField.definition.js +1 -1
  33. package/lib/metadataTypes/definitions/Folder.definition.js +1 -1
  34. package/lib/metadataTypes/definitions/ImportFile.definition.js +2 -1
  35. package/lib/metadataTypes/definitions/Script.definition.js +5 -5
  36. package/lib/retrieveChangelog.js +96 -0
  37. package/lib/util/cli.js +4 -6
  38. package/lib/util/init.git.js +2 -1
  39. package/lib/util/util.js +31 -15
  40. package/package.json +19 -23
  41. package/img/README.md/troubleshoot-nodejs-postinstall.jpg +0 -0
  42. package/postinstall.js +0 -41
@@ -3,6 +3,7 @@
3
3
  const jsonToTable = require('json-to-table');
4
4
  const MetadataType = require('./MetadataType');
5
5
  const DataExtensionField = require('./DataExtensionField');
6
+ const Folder = require('./Folder');
6
7
  const Util = require('../util/util');
7
8
  const File = require('../util/file');
8
9
 
@@ -43,7 +44,7 @@ class DataExtension extends MetadataType {
43
44
  */
44
45
  static async upsert(desToDeploy, _, buObject) {
45
46
  Util.logger.info('- Retrieve target metadata for comparison with deploy metadata');
46
- const results = await this.retrieveForCache(buObject);
47
+ const results = await this.retrieveForCache(buObject, null, true);
47
48
  const targetMetadata = results.metadata;
48
49
  Util.logger.info('- Retrieved target metadata');
49
50
  /** @type {Promise[]} */
@@ -60,11 +61,20 @@ class DataExtension extends MetadataType {
60
61
  }
61
62
  if (
62
63
  buObject.eid !== buObject.mid &&
63
- desToDeploy[dataExtension].r__folder_Path.startsWith('shared_')
64
+ desToDeploy[dataExtension].r__folder_Path.startsWith('Shared Items')
64
65
  ) {
65
66
  // this needs to be run before executing preDeployTasks()
66
67
  Util.logger.warn(
67
- `- Cannot Create/Update a Shared Data Extesion from the Child BU - skipping ${desToDeploy[dataExtension].Name}`
68
+ `- Cannot Create/Update a Shared Data Extension from the Child BU - skipping ${desToDeploy[dataExtension].Name}`
69
+ );
70
+ continue;
71
+ }
72
+ if (
73
+ desToDeploy[dataExtension].r__folder_Path.startsWith('Synchronized Data Extensions')
74
+ ) {
75
+ // this needs to be run before executing preDeployTasks()
76
+ Util.logger.warn(
77
+ `- Cannot Create/Update a Synchronized Data Extension. Please use Contact Builder to maintain these - skipping ${desToDeploy[dataExtension].Name}`
68
78
  );
69
79
  continue;
70
80
  }
@@ -80,21 +90,7 @@ class DataExtension extends MetadataType {
80
90
  // skip rest of handling for this DE
81
91
  continue;
82
92
  }
83
- // Error if SendableSubscriberField.Name = '_SubscriberKey' even though it is retrieved like that
84
- // Therefore map it to 'Subscriber Key'. Retrieving afterward still results in '_SubscriberKey'
85
- if (
86
- desToDeploy[dataExtension].SendableSubscriberField &&
87
- desToDeploy[dataExtension].SendableSubscriberField.Name === '_SubscriberKey'
88
- ) {
89
- desToDeploy[dataExtension].SendableSubscriberField.Name = 'Subscriber Key';
90
- }
91
93
  if (targetMetadata[dataExtension]) {
92
- // Update dataExtension + Columns if they already exist; Create them if not
93
- // Modify columns for update call
94
- DataExtension.prepareDeployColumnsOnUpdate(
95
- desToDeploy[dataExtension].Fields,
96
- targetMetadata[dataExtension].Fields
97
- );
98
94
  // data extension already exists in target and needs to be updated
99
95
  deUpdatePromises.push(DataExtension.update(desToDeploy[dataExtension]));
100
96
  } else {
@@ -104,7 +100,7 @@ class DataExtension extends MetadataType {
104
100
  }
105
101
  if (deUpdatePromises.length) {
106
102
  Util.logger.info(
107
- 'Please note that Data Retention Policies can only be set during creation, not during update.'
103
+ '- Please note that Data Retention Policies can only be set during creation, not during update.'
108
104
  );
109
105
  }
110
106
 
@@ -114,6 +110,7 @@ class DataExtension extends MetadataType {
114
110
  const updateResults = (await Promise.allSettled(deUpdatePromises)).filter(
115
111
  this._filterUpsertResults
116
112
  );
113
+
117
114
  const successfulResults = [...createResults, ...updateResults];
118
115
 
119
116
  Util.metadataLogger(
@@ -151,6 +148,10 @@ class DataExtension extends MetadataType {
151
148
  // promise rejects, whole request failed
152
149
  Util.logger.error('- error upserting dataExtension: ' + res.reason);
153
150
  return false;
151
+ } else if (res.value == undefined || Object.keys(res.value).length === 0) {
152
+ // in case of returning empty result handle gracefully
153
+ // TODO: consider if SOAP handler for this should really return empty object
154
+ return false;
154
155
  } else if (res.value.results) {
155
156
  Util.logger.error(
156
157
  '- error upserting dataExtension: ' +
@@ -168,65 +169,24 @@ class DataExtension extends MetadataType {
168
169
  }
169
170
  }
170
171
 
171
- /**
172
- * Mofifies passed deployColumns for update by mapping ObjectID to their target column's values.
173
- * 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
174
- *
175
- * @param {DataExtensionField.DataExtensionFieldItem[]} deployColumns Columns of data extension that will be deployed
176
- * @param {DataExtensionField.DataExtensionFieldItem[]} targetColumns Columns of data extension that currently exists in target
177
- * @returns {void}
178
- */
179
- static prepareDeployColumnsOnUpdate(deployColumns, targetColumns) {
180
- // Map data extension column ObjectIDs to their target, because they are environment specific
181
- for (const column in deployColumns) {
182
- const deployColumn = deployColumns[column];
183
- // Check if column exists in target
184
- if (targetColumns[column]) {
185
- // Map ObjectID to value of target, because DataExtensionField updates are based on ObjectID
186
- deployColumn.ObjectID = targetColumns[column].ObjectID;
187
- // Remove FieldType if it is the same in target, because an error occurs if a FieldTypes gets passed on a field update (even if its the same type)
188
- if (targetColumns[column].FieldType === deployColumn.FieldType) {
189
- delete deployColumn.FieldType;
190
- } else {
191
- // Updating to a new FieldType will result in an error
192
- Util.logger.warn(
193
- 'DataExtension.prepareDeployColumnsOnUpdate:: Cannot update FieldType of field: ' +
194
- deployColumn.CustomerKey
195
- );
196
- }
197
- } else {
198
- // Field doesn't exist in target, therefore Remove ObjectID if present
199
- delete deployColumn.ObjectID;
200
- }
201
- }
202
- }
203
-
204
172
  /**
205
173
  * Create a single dataExtension. Also creates their columns in 'dataExtension.columns'
206
174
  * @param {DataExtensionItem} metadata single metadata entry
207
175
  * @returns {Promise} Promise
208
176
  */
209
- static create(metadata) {
210
- return new Promise((resolve) => {
211
- this.removeNotCreateableFields(metadata);
212
- this._cleanupRetentionPolicyFields(metadata);
213
- const dataExtensionFields = metadata.Fields;
214
- delete metadata.Fields;
215
-
216
- const config = {
217
- props: metadata,
218
- columns: dataExtensionFields,
219
- };
220
- this.client.dataExtension(config).post((error, response) => {
221
- if (error) {
222
- Util.logger.error('- error creating dataExtension: ' + metadata.CustomerKey);
223
- resolve(error);
224
- } else {
225
- Util.logger.info('- created dataExtension: ' + metadata.CustomerKey);
226
- resolve(response);
227
- }
228
- });
229
- });
177
+ static async create(metadata) {
178
+ this._cleanupRetentionPolicyFields(metadata);
179
+
180
+ // convert simple array into object.Array.object format to cope with how the XML body in the SOAP call needs to look like:
181
+ // <Fields>
182
+ // <Field>
183
+ // <CustomerKey>SubscriberKey</CustomerKey>
184
+ // ..
185
+ // </Field>
186
+ // </Fields>
187
+ metadata.Fields = { Field: metadata.Fields };
188
+
189
+ return super.createSOAP(metadata);
230
190
  }
231
191
 
232
192
  /**
@@ -255,35 +215,67 @@ class DataExtension extends MetadataType {
255
215
  * @returns {Promise} Promise
256
216
  */
257
217
  static async update(metadata) {
258
- // retrieve exsiting fields
259
- const fieldsObj = await this._retrieveFields(['Name'], {
260
- filter: {
261
- leftOperand: 'DataExtension.CustomerKey',
262
- operator: 'equals',
263
- rightOperand: metadata.CustomerKey,
264
- },
265
- });
266
- const existingFieldNames = Object.keys(fieldsObj).map((key) => fieldsObj[key].Name);
267
- return new Promise((resolve) => {
268
- this.removeNotUpdateableFields(metadata);
269
- // find fields to add
270
- const newFields = metadata.Fields.filter((a) => !existingFieldNames.includes(a.Name));
271
- delete metadata.Fields;
272
-
273
- const config = {
274
- props: metadata,
275
- columns: newFields,
276
- };
277
- this.client.dataExtension(config).patch((error, response) => {
278
- if (error) {
279
- Util.logger.error('- error updating dataExtension: ' + metadata.CustomerKey);
280
- resolve(error);
281
- } else {
282
- Util.logger.info('- updated dataExtension: ' + metadata.CustomerKey);
283
- resolve(response);
218
+ // Update dataExtension + Columns if they already exist; Create them if not
219
+ // Modify columns for update call
220
+ DataExtensionField.cache = this.metadata;
221
+ DataExtensionField.client = this.client;
222
+ DataExtensionField.properties = this.properties;
223
+ DataExtension.oldFields = DataExtension.oldFields || {};
224
+ DataExtension.oldFields[metadata.CustomerKey] =
225
+ await DataExtensionField.prepareDeployColumnsOnUpdate(
226
+ metadata.Fields,
227
+ metadata.CustomerKey
228
+ );
229
+
230
+ // convert simple array into object.Array.object format to cope with how the XML body in the SOAP call needs to look like:
231
+ // <Fields>
232
+ // <Field>
233
+ // <CustomerKey>SubscriberKey</CustomerKey>
234
+ // ..
235
+ // </Field>
236
+ // </Fields>
237
+
238
+ metadata.Fields = { Field: metadata.Fields };
239
+ return super.updateSOAP(metadata);
240
+ }
241
+ /**
242
+ * Gets executed after deployment of metadata type
243
+ * @param {DataExtensionMap} upsertedMetadata metadata mapped by their keyField
244
+ * @returns {void}
245
+ */
246
+ static postDeployTasks(upsertedMetadata) {
247
+ if (!DataExtension.oldFields) {
248
+ // only run postDeploy if we are in update mode
249
+ return;
250
+ }
251
+ // somewhat of a workardoun but it ensures we get the field list from the server rather than whatever we might have in cache got returned during update/add. This ensures a complete and correctly ordered field list
252
+ for (const key in upsertedMetadata) {
253
+ const item = upsertedMetadata[key];
254
+ const isUpdate =
255
+ this.cache &&
256
+ this.cache.dataExtension &&
257
+ this.cache.dataExtension[item.CustomerKey];
258
+ if (isUpdate) {
259
+ const cachedVersion = this.cache.dataExtension[item.CustomerKey];
260
+ // restore retention values that are typically not returned by the update call
261
+ item.RowBasedRetention = cachedVersion.RowBasedRetention;
262
+ item.ResetRetentionPeriodOnImport = cachedVersion.ResetRetentionPeriodOnImport;
263
+ item.DeleteAtEndOfRetentionPeriod = cachedVersion.DeleteAtEndOfRetentionPeriod;
264
+ item.RetainUntil = cachedVersion.RetainUntil;
265
+
266
+ // ensure we have th
267
+ const existingFields = DataExtension.oldFields[item[this.definition.nameField]];
268
+ if (item.Fields !== '' && existingFields) {
269
+ // TODO should be replaced by a manual sort using existingFields
270
+ // ! this is inefficient because it triggers a new download of the fields during the saveResults() step
271
+ item.Fields.length = 0;
284
272
  }
285
- });
286
- });
273
+ // sort Fields entry to the end of the object for saving in .json
274
+ const fieldsBackup = item.Fields;
275
+ delete item.Fields;
276
+ item.Fields = fieldsBackup;
277
+ }
278
+ }
287
279
  }
288
280
 
289
281
  /**
@@ -291,19 +283,25 @@ class DataExtension extends MetadataType {
291
283
  * @param {string} retrieveDir Directory where retrieved metadata directory will be saved
292
284
  * @param {string[]} [additionalFields] Returns specified fields even if their retrieve definition is not set to true
293
285
  * @param {Util.BuObject} buObject properties for auth
286
+ * @param {void} [_] -
287
+ * @param {boolean} [isDeploy] used to signal that fields shall be retrieve in caching mode
294
288
  * @returns {Promise<{metadata:DataExtensionMap,type:string}>} Promise of item map
295
289
  */
296
- static async retrieve(retrieveDir, additionalFields, buObject) {
290
+ static async retrieve(retrieveDir, additionalFields, buObject, _, isDeploy) {
297
291
  let metadata = await this._retrieveAll(additionalFields);
298
292
  // in case of cache dont get fields
299
- if (metadata && retrieveDir) {
293
+ if (isDeploy || (metadata && retrieveDir)) {
300
294
  // get fields from API
301
- const fieldsObj = await this._retrieveFields(additionalFields);
295
+ const fieldsObj = await this._retrieveFields(null, additionalFields);
302
296
  const fieldKeys = Object.keys(fieldsObj);
303
297
  // add fields to corresponding DE
304
298
  fieldKeys.forEach((key) => {
305
299
  const field = fieldsObj[key];
306
- metadata[field.DataExtension.CustomerKey].Fields.push(field);
300
+ if (metadata[field?.DataExtension?.CustomerKey]) {
301
+ metadata[field.DataExtension.CustomerKey].Fields.push(field);
302
+ } else {
303
+ Util.logger.warn(`Issue retrieving data extension fields. key='${key}'`);
304
+ }
307
305
  });
308
306
 
309
307
  // sort fields by Ordinal value (API returns field unsorted)
@@ -326,9 +324,7 @@ class DataExtension extends MetadataType {
326
324
  clientSecret: this.properties.credentials[buObject.credential].clientSecret,
327
325
  tenant: this.properties.credentials[buObject.credential].tenant,
328
326
  eid: this.properties.credentials[buObject.credential].eid,
329
- mid: this.properties.credentials[buObject.credential].businessUnits[
330
- Util.parentBuName
331
- ],
327
+ mid: this.properties.credentials[buObject.credential].eid,
332
328
  businessUnit: Util.parentBuName,
333
329
  credential: buObject.credential,
334
330
  };
@@ -341,6 +337,16 @@ class DataExtension extends MetadataType {
341
337
  }
342
338
  const metadataParentBu = await this._retrieveAll(additionalFields);
343
339
 
340
+ // get shared folders to match our shared / synched Data Extensions
341
+ Util.logger.info('- Caching dependent Metadata: folder (shared via _ParentBU_)');
342
+ Folder.cache = {};
343
+ Folder.client = this.client;
344
+ Folder.properties = this.properties;
345
+ const result = await Folder.retrieveForCache(buObjectParentBu);
346
+ const parentCache = {
347
+ folder: result.metadata,
348
+ };
349
+
344
350
  // get the types and clean out non-shared ones
345
351
  const folderTypesFromParent = require('../MetadataTypeDefinitions').folder
346
352
  .folderTypesFromParent;
@@ -348,22 +354,30 @@ class DataExtension extends MetadataType {
348
354
  try {
349
355
  // get the data extension type from the folder
350
356
  const folderContentType = Util.getFromCache(
351
- this.cache,
357
+ parentCache,
352
358
  'folder',
353
359
  metadataParentBu[metadataEntry].CategoryID,
354
360
  'ID',
355
361
  'ContentType'
356
362
  );
357
363
  if (!folderTypesFromParent.includes(folderContentType)) {
364
+ Util.logger.verbose(
365
+ `removing ${metadataEntry} because r__folder_ContentType '${folderContentType}' identifies this DE as not being shared`
366
+ );
358
367
  delete metadataParentBu[metadataEntry];
359
368
  }
360
369
  } catch (ex) {
370
+ Util.logger.debug(
371
+ `removing ${metadataEntry} because of error while retrieving r__folder_ContentType: ${ex.message}`
372
+ );
361
373
  delete metadataParentBu[metadataEntry];
362
374
  }
363
375
  }
364
376
 
365
377
  // revert client to current default
366
378
  this.client = clientBackup;
379
+ Folder.client = clientBackup;
380
+ Folder.cache = this.cache;
367
381
 
368
382
  // make sure to overwrite parent bu DEs with local ones
369
383
  metadata = { ...metadataParentBu, ...metadata };
@@ -380,6 +394,15 @@ class DataExtension extends MetadataType {
380
394
  }
381
395
  return { metadata: metadata, type: 'dataExtension' };
382
396
  }
397
+ /**
398
+ * Retrieves dataExtension metadata. Afterwards starts retrieval of dataExtensionColumn metadata retrieval
399
+ * @param {string[]} [additionalFields] Returns specified fields even if their retrieve definition is not set to true
400
+ * @returns {Promise<{metadata:DataExtensionMap,type:string}>} Promise of item map
401
+ */
402
+ static async retrieveChangelog(additionalFields) {
403
+ const metadata = await this._retrieveAll(additionalFields);
404
+ return { metadata: metadata, type: 'dataExtension' };
405
+ }
383
406
  /**
384
407
  * manages post retrieve steps
385
408
  * @param {DataExtensionItem} metadata a single dataExtension
@@ -400,17 +423,25 @@ class DataExtension extends MetadataType {
400
423
  'Ensure that Queries that write into this DE are updated with the new key before deployment.';
401
424
  this.overrideKeyWithName(metadata, warningMsg);
402
425
  }
426
+ // Error during deploy if SendableSubscriberField.Name = '_SubscriberKey' even though it is retrieved like that
427
+ // Therefore map it to 'Subscriber Key'. Retrieving afterward still results in '_SubscriberKey'
428
+ if (
429
+ metadata.SendableSubscriberField &&
430
+ metadata.SendableSubscriberField.Name === '_SubscriberKey'
431
+ ) {
432
+ metadata.SendableSubscriberField.Name = 'Subscriber Key';
433
+ }
403
434
  return this._parseMetadata(JSON.parse(JSON.stringify(metadata)));
404
435
  }
405
436
 
406
437
  /**
407
438
  * Helper to retrieve Data Extension Fields
408
439
  * @private
409
- * @param {string[]} [additionalFields] Returns specified fields even if their retrieve definition is not set to true
410
440
  * @param {Object} [options] options (e.g. continueRequest)
441
+ * @param {string[]} [additionalFields] Returns specified fields even if their retrieve definition is not set to true
411
442
  * @returns {Promise<DataExtensionField.DataExtensionFieldMap>} Promise of items
412
443
  */
413
- static async _retrieveFields(additionalFields, options) {
444
+ static async _retrieveFields(options, additionalFields) {
414
445
  if (!options) {
415
446
  // dont print this during updates or templating which retrieves fields DE-by-DE
416
447
  Util.logger.info('- Caching dependent Metadata: dataExtensionField');
@@ -437,7 +468,7 @@ class DataExtension extends MetadataType {
437
468
  rightOperand: customerKey,
438
469
  },
439
470
  };
440
- const fieldsObj = await this._retrieveFields(null, fieldOptions);
471
+ const fieldsObj = await this._retrieveFields(fieldOptions);
441
472
 
442
473
  DataExtensionField.cache = this.metadata;
443
474
  DataExtensionField.client = this.client;
@@ -514,6 +545,16 @@ class DataExtension extends MetadataType {
514
545
  // contenttype
515
546
  delete metadata.r__folder_ContentType;
516
547
 
548
+ // Error if SendableSubscriberField.Name = '_SubscriberKey' even though it is retrieved like that
549
+ // Therefore map it to 'Subscriber Key'. Retrieving afterward still results in '_SubscriberKey'
550
+ // TODO remove from preDeploy with release of version 4, keep until then to help with migration of old metadata
551
+ if (
552
+ metadata.SendableSubscriberField &&
553
+ metadata.SendableSubscriberField.Name === '_SubscriberKey'
554
+ ) {
555
+ metadata.SendableSubscriberField.Name = 'Subscriber Key';
556
+ }
557
+
517
558
  return metadata;
518
559
  }
519
560
 
@@ -816,10 +857,12 @@ class DataExtension extends MetadataType {
816
857
  /**
817
858
  * Retrieves folder metadata into local filesystem. Also creates a uniquePath attribute for each folder.
818
859
  * @param {Object} buObject properties for auth
860
+ * @param {void} [_] -
861
+ * @param {boolean} [isDeploy] used to signal that fields shall be retrieve in caching mode
819
862
  * @returns {Promise} Promise
820
863
  */
821
- static async retrieveForCache(buObject) {
822
- return this.retrieve(null, ['ObjectID', 'CustomerKey', 'Name'], buObject);
864
+ static async retrieveForCache(buObject, _, isDeploy) {
865
+ return this.retrieve(null, ['ObjectID', 'CustomerKey', 'Name'], buObject, null, isDeploy);
823
866
  }
824
867
  /**
825
868
  * Retrieves dataExtension metadata in template format.
@@ -983,6 +1026,9 @@ class DataExtension extends MetadataType {
983
1026
 
984
1027
  // Assign definition to static attributes
985
1028
  DataExtension.definition = require('../MetadataTypeDefinitions').dataExtension;
1029
+ /**
1030
+ * @type {Util.ET_Client}
1031
+ */
986
1032
  DataExtension.client = undefined;
987
1033
  DataExtension.cache = {};
988
1034
 
@@ -1,17 +1,21 @@
1
1
  'use strict';
2
2
 
3
+ const Util = require('../util/util');
4
+
3
5
  /**
4
6
  * @typedef {Object} DataExtensionFieldItem
5
7
  * @property {string} [ObjectID] id
6
- * @property {string} [CustomerKey] key
8
+ * @property {string} [CustomerKey] key in format [DEkey].[FieldName]
7
9
  * @property {Object} [DataExtension] -
8
10
  * @property {string} DataExtension.CustomerKey key of DE
9
- * @property {string} Name name
10
- * @property {string} DefaultValue -
11
+ * @property {string} Name name of field
12
+ * @property {string} [Name_new] custom attribute that is only used when trying to rename a field from Name to Name_new
13
+ * @property {string} DefaultValue empty string for not set
11
14
  * @property {'true'|'false'} IsRequired -
12
15
  * @property {'true'|'false'} IsPrimaryKey -
13
16
  * @property {string} Ordinal 1, 2, 3, ...
14
- * @property {'Text'|'Date'|'Number'|'Decimal'|'Email'} FieldType -
17
+ * @property {'Text'|'Number'|'Date'|'Boolean'|'Decimal'|'EmailAddress'|'Phone'|'Locale'} FieldType can only be set on create
18
+ * @property {string} Scale the number of places after the decimal that the field can hold; example: "0","1", ...
15
19
  *
16
20
  * @typedef {Object.<string, DataExtensionFieldItem>} DataExtensionFieldMap
17
21
  */
@@ -91,9 +95,135 @@ class DataExtensionField extends MetadataType {
91
95
  delete metadata.CustomerKey;
92
96
  delete metadata.DataExtension;
93
97
  delete metadata.Ordinal;
98
+ if (metadata.FieldType !== 'Decimal') {
99
+ // remove scale - it's only used for "Decimal" to define the digits behind the decimal
100
+ delete metadata.Scale;
101
+ }
94
102
  }
95
103
  return metadata;
96
104
  }
105
+ /**
106
+ * Mofifies passed deployColumns for update by mapping ObjectID to their target column's values.
107
+ * 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
108
+ *
109
+ * @param {DataExtensionFieldItem[]} deployColumns Columns of data extension that will be deployed
110
+ * @param {string} deKey external/customer key of Data Extension
111
+ * @returns {Object<string,DataExtensionFieldItem>} existing fields by their original name to allow re-adding FieldType after update
112
+ */
113
+ static async prepareDeployColumnsOnUpdate(deployColumns, deKey) {
114
+ // retrieve existing fields to enable updating them
115
+ const response = await this.retrieveForCache(
116
+ {
117
+ filter: {
118
+ leftOperand: 'DataExtension.CustomerKey',
119
+ operator: 'equals',
120
+ rightOperand: deKey,
121
+ },
122
+ },
123
+ ['Name', 'ObjectID']
124
+ );
125
+
126
+ const fieldsObj = response.metadata;
127
+
128
+ // ensure fields can be updated properly by their adding ObjectId based on Name-matching
129
+ /** @type {Object<string,DataExtensionFieldItem>} */
130
+ const existingFieldByName = {};
131
+
132
+ Object.keys(fieldsObj).forEach((key) => {
133
+ existingFieldByName[fieldsObj[key].Name] = fieldsObj[key];
134
+ });
135
+ for (let i = deployColumns.length - 1; i >= 0; i--) {
136
+ const item = deployColumns[i];
137
+ const itemOld = existingFieldByName[item.Name];
138
+ if (itemOld) {
139
+ // field is getting updated ---
140
+
141
+ // Updating to a new FieldType will result in an error; warn & afterwards remove it
142
+ if (itemOld.FieldType !== item.FieldType) {
143
+ Util.logger.warn(
144
+ `- The Field Type of an existing field cannot be changed. Keeping the original value for [${deKey}].[${item.Name}]: '${itemOld.FieldType}'`
145
+ );
146
+ item.FieldType = itemOld.FieldType;
147
+ }
148
+ if (item.FieldType !== 'Decimal') {
149
+ // remove scale - it's only used for "Decimal" to define the digits behind the decimal
150
+ delete item.Scale;
151
+ }
152
+ delete item.FieldType;
153
+
154
+ if (itemOld.MaxLength > item.MaxLength) {
155
+ Util.logger.warn(
156
+ `- The length of an existing field cannot be decreased. Keeping the original value for [${deKey}].[${item.Name}]: '${itemOld.MaxLength}'`
157
+ );
158
+ item.MaxLength = itemOld.MaxLength;
159
+ }
160
+ if (Util.isFalse(itemOld.IsRequired) && Util.isTrue(item.IsRequired)) {
161
+ Util.logger.warn(
162
+ `- A field cannot be changed to be required on update after it was created to allow nulls: [${deKey}].[${item.Name}]`
163
+ );
164
+ item.IsRequired = itemOld.IsRequired;
165
+ }
166
+
167
+ // enable renaming
168
+ if (item.Name_new) {
169
+ item.Name = item.Name_new;
170
+ delete item.Name_new;
171
+ Util.logger.warn(
172
+ `Found 'Name_new' value '${item.Name_new}' for ${deKey}.${item.Name} - trying to rename.`
173
+ );
174
+ }
175
+
176
+ // check if any changes were found
177
+ let changeFound = false;
178
+ Object.keys(item).forEach((key) => {
179
+ if (item[key] !== itemOld[key]) {
180
+ changeFound = true;
181
+ }
182
+ });
183
+ if (!changeFound) {
184
+ deployColumns.splice(i, 1);
185
+ Util.logger.verbose(`no change - removed field [${deKey}].[${item.Name}]`);
186
+ continue;
187
+ }
188
+
189
+ // set the ObjectId for clear identification during update
190
+ item.ObjectID = itemOld.ObjectID;
191
+ } else {
192
+ // field is getting added ---
193
+ if (Util.isTrue(item.IsRequired) && item.DefaultValue === '') {
194
+ Util.logger.warn(
195
+ `- 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`
196
+ );
197
+ item.IsRequired = 'false';
198
+ }
199
+ if (item.Name_new) {
200
+ Util.logger.warn(
201
+ `Found 'Name_new' value '${item.Name_new}' for ${deKey}.${item.Name} but could not find a corresponding DE field on the server - adding new field instead of updating.`
202
+ );
203
+ delete item.Name_new;
204
+ }
205
+ // Field doesn't exist in target, therefore Remove ObjectID if present
206
+ delete item.ObjectID;
207
+ }
208
+ if (!Util.isTrue(item.IsRequired) && !Util.isFalse(item.IsRequired)) {
209
+ Util.logger.error(
210
+ `- Invalid value for 'IsRequired' of [${deKey}].[${item.Name}]. Found '${item.IsRequired}' instead of 'true'/'false'. Removing field from deploy!`
211
+ );
212
+ deployColumns.splice(i, 1);
213
+ }
214
+ if (!Util.isTrue(item.IsPrimaryKey) && !Util.isFalse(item.IsPrimaryKey)) {
215
+ Util.logger.error(
216
+ `- Invalid value for 'IsPrimaryKey' of [${deKey}].[${item.Name}]. Found '${item.IsPrimaryKey}' instead of 'true'/'false'. Removing field from deploy!`
217
+ );
218
+ deployColumns.splice(i, 1);
219
+ }
220
+ }
221
+ Util.logger.debug(
222
+ `${deployColumns.length} Fields added/updated for [${deKey}]: ` +
223
+ deployColumns.map((item) => item.Name).join(', ')
224
+ );
225
+ return deployColumns.length ? existingFieldByName : null;
226
+ }
97
227
  }
98
228
 
99
229
  // Assign definition to static attributes