From d99615cf7278cf7ef7c73c3e61bae0b8972d49e5 Mon Sep 17 00:00:00 2001 From: Will Vedder Date: Thu, 9 Feb 2023 17:11:14 -0500 Subject: [PATCH 01/14] Adding shouldFieldBePreserved function --- src/keywordPreservation.ts | 14 +++++++++++ src/tools/utils.ts | 17 +++++++++---- test/keywordPreservation.test.ts | 41 ++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 src/keywordPreservation.ts create mode 100644 test/keywordPreservation.test.ts diff --git a/src/keywordPreservation.ts b/src/keywordPreservation.ts new file mode 100644 index 000000000..cfa505fff --- /dev/null +++ b/src/keywordPreservation.ts @@ -0,0 +1,14 @@ +import { KeywordMappings } from './types'; +import { keywordReplaceArrayRegExp, keywordReplaceStringRegExp } from './tools/utils'; + +export const shouldFieldBePreserved = ( + string: string, + keywordMappings: KeywordMappings +): boolean => { + return !Object.keys(keywordMappings).every((keyword) => { + const hasArrayMarker = keywordReplaceArrayRegExp(keyword).test(string); + const hasStringMarker = keywordReplaceStringRegExp(keyword).test(string); + + return !hasArrayMarker && !hasStringMarker; + }); +}; diff --git a/src/tools/utils.ts b/src/tools/utils.ts index 0c762467f..8a1e4bfcd 100644 --- a/src/tools/utils.ts +++ b/src/tools/utils.ts @@ -6,13 +6,22 @@ import log from '../logger'; import { Asset, Assets, CalculatedChanges, KeywordMappings } from '../types'; import constants from './constants'; +export const keywordReplaceArrayRegExp = (key) => { + const pattern = `@@${key}@@`; + const patternWithQuotes = `"${pattern}"`; + + return new RegExp(`${patternWithQuotes}|${pattern}`, 'g'); +}; + +export const keywordReplaceStringRegExp = (key) => { + return new RegExp(`##${key}##`, 'g'); +}; + export function keywordArrayReplace(input: string, mappings: KeywordMappings): string { Object.keys(mappings).forEach(function (key) { // Matching against two sets of patterns because a developer may provide their array replacement keyword with or without wrapping quotes. It is not obvious to the developer which to do depending if they're operating in YAML or JSON. - const pattern = `@@${key}@@`; - const patternWithQuotes = `"${pattern}"`; + const regex = keywordReplaceArrayRegExp(key); - const regex = new RegExp(`${patternWithQuotes}|${pattern}`, 'g'); input = input.replace(regex, JSON.stringify(mappings[key])); }); return input; @@ -20,7 +29,7 @@ export function keywordArrayReplace(input: string, mappings: KeywordMappings): s export function keywordStringReplace(input: string, mappings: KeywordMappings): string { Object.keys(mappings).forEach(function (key) { - const regex = new RegExp(`##${key}##`, 'g'); + const regex = keywordReplaceStringRegExp(key); // @ts-ignore TODO: come back and distinguish strings vs array replacement. input = input.replace(regex, mappings[key]); }); diff --git a/test/keywordPreservation.test.ts b/test/keywordPreservation.test.ts new file mode 100644 index 000000000..d582d0f0f --- /dev/null +++ b/test/keywordPreservation.test.ts @@ -0,0 +1,41 @@ +import { expect } from 'chai'; +import { shouldFieldBePreserved } from '../src/keywordPreservation'; + +describe('#Keyword Preservation', () => { + describe('shouldFieldBePreserved', () => { + it('should return false when field does not contain keyword markers', () => { + const keywordMappings = { + BAR: 'bar', + }; + expect(shouldFieldBePreserved('', keywordMappings)).to.be.false; + expect(shouldFieldBePreserved('this is a field without a keyword marker', keywordMappings)).to + .be.false; + expect(shouldFieldBePreserved('this field has an invalid @keyword@ marker', keywordMappings)) + .to.be.false; + }); + + it('should return false when field contain keyword markers but are absent in keyword mappings', () => { + const keywordMappings = { + BAR: 'bar', + }; + expect(shouldFieldBePreserved('##FOO##', keywordMappings)).to.be.false; + expect(shouldFieldBePreserved('@@FOO@@', keywordMappings)).to.be.false; + expect(shouldFieldBePreserved('this field has a @@FOO@@ marker', keywordMappings)).to.be + .false; + }); + + it('should return true when field contain keyword markers that exist in keyword mappings', () => { + const keywordMappings = { + FOO: 'foo keyword', + BAR: 'bar keyword', + ARRAY: ['foo', 'bar'], + }; + expect(shouldFieldBePreserved('##FOO##', keywordMappings)).to.be.true; + expect(shouldFieldBePreserved('@@FOO@@', keywordMappings)).to.be.true; + expect(shouldFieldBePreserved('this field has a ##FOO## marker', keywordMappings)).to.be.true; + expect( + shouldFieldBePreserved('this field has both a ##FOO## and ##BAR## marker', keywordMappings) + ).to.be.true; + }); + }); +}); From 95ff15cad097a135b23bbba1f85b8f4619dc17b0 Mon Sep 17 00:00:00 2001 From: Will Vedder Date: Fri, 10 Feb 2023 14:55:30 -0500 Subject: [PATCH 02/14] Adding getPreservableFieldsFromAssets function with test --- src/keywordPreservation.ts | 30 ++++++++++++++++++++++++ test/keywordPreservation.test.ts | 40 +++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/keywordPreservation.ts b/src/keywordPreservation.ts index cfa505fff..709851467 100644 --- a/src/keywordPreservation.ts +++ b/src/keywordPreservation.ts @@ -12,3 +12,33 @@ export const shouldFieldBePreserved = ( return !hasArrayMarker && !hasStringMarker; }); }; + +export const getPreservableFieldsFromAssets = ( + asset: any, + keywordMappings: KeywordMappings +): string[] => { + if (typeof asset === 'string') { + if (shouldFieldBePreserved(asset, keywordMappings)) { + return [asset]; + } + return []; + } + if (Array.isArray(asset)) { + return asset + .map((arrayItem) => { + return getPreservableFieldsFromAssets(arrayItem, keywordMappings); + }) + .flat(); + } + if (typeof asset === 'object') { + return Object.keys(asset) + .map((key: string): string[] => { + const value = asset[key]; + + if (value === undefined || value === null) return []; + return getPreservableFieldsFromAssets(value, keywordMappings); + }) + .flat(); + } + return []; +}; diff --git a/test/keywordPreservation.test.ts b/test/keywordPreservation.test.ts index d582d0f0f..f109f3122 100644 --- a/test/keywordPreservation.test.ts +++ b/test/keywordPreservation.test.ts @@ -1,5 +1,5 @@ import { expect } from 'chai'; -import { shouldFieldBePreserved } from '../src/keywordPreservation'; +import { shouldFieldBePreserved, getPreservableFieldsFromAssets } from '../src/keywordPreservation'; describe('#Keyword Preservation', () => { describe('shouldFieldBePreserved', () => { @@ -38,4 +38,42 @@ describe('#Keyword Preservation', () => { ).to.be.true; }); }); + + describe('getPreservableFieldsFromAssets', () => { + it('should retrieve all preservable fields from assets tree', () => { + const fieldsToPreserve = getPreservableFieldsFromAssets( + { + object: { + friendly_name: 'Friendly name ##KEYWORD##', + notInKeywordMapping: '##NOT_IN_KEYWORD_MAPPING##', + number: 5, + boolean: true, + nested: { + nestedProperty: 'Nested property ##KEYWORD##', + }, + }, + array: [ + { + nestedArray: ['Nested array value 1 ##KEYWORD##', 'Nested array value 2 ##KEYWORD##'], + notInKeywordMapping: '##NOT_IN_KEYWORD_MAPPING##', + nested: { + nestedProperty: 'Another nested array property ##KEYWORD##', + }, + }, + ], + }, + { + KEYWORD: 'Travel0', + } + ); + + expect(fieldsToPreserve).to.have.members([ + 'Friendly name ##KEYWORD##', + 'Nested property ##KEYWORD##', + 'Nested array value 1 ##KEYWORD##', + 'Nested array value 2 ##KEYWORD##', + 'Another nested array property ##KEYWORD##', + ]); + }); + }); }); From 461b0c12906d49765585c39b1009c67391d0e3c1 Mon Sep 17 00:00:00 2001 From: Will Vedder Date: Fri, 10 Feb 2023 15:51:49 -0500 Subject: [PATCH 03/14] Adding array keyword replace case --- test/keywordPreservation.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/keywordPreservation.test.ts b/test/keywordPreservation.test.ts index f109f3122..9a8a182a2 100644 --- a/test/keywordPreservation.test.ts +++ b/test/keywordPreservation.test.ts @@ -61,9 +61,11 @@ describe('#Keyword Preservation', () => { }, }, ], + arrayReplace: '@@ARRAY_REPLACE_KEYWORD@@', }, { KEYWORD: 'Travel0', + ARRAY_REPLACE_KEYWORD: ['this value', 'that value'], } ); @@ -73,6 +75,7 @@ describe('#Keyword Preservation', () => { 'Nested array value 1 ##KEYWORD##', 'Nested array value 2 ##KEYWORD##', 'Another nested array property ##KEYWORD##', + '@@ARRAY_REPLACE_KEYWORD@@', ]); }); }); From 23595d3fe6d26f01ffd80b463e202ba73dd52b44 Mon Sep 17 00:00:00 2001 From: Will Vedder Date: Fri, 10 Feb 2023 16:01:47 -0500 Subject: [PATCH 04/14] Adding undefined and null check --- test/keywordPreservation.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/keywordPreservation.test.ts b/test/keywordPreservation.test.ts index 9a8a182a2..85c21da62 100644 --- a/test/keywordPreservation.test.ts +++ b/test/keywordPreservation.test.ts @@ -62,6 +62,8 @@ describe('#Keyword Preservation', () => { }, ], arrayReplace: '@@ARRAY_REPLACE_KEYWORD@@', + nullField: null, + undefinedField: undefined, }, { KEYWORD: 'Travel0', From e897f0bc4d9cbbd1bd7ea6a89a4dba0fad63ea9e Mon Sep 17 00:00:00 2001 From: Will Vedder Date: Mon, 13 Feb 2023 12:52:11 -0500 Subject: [PATCH 05/14] Adding address notation to traversal --- src/keywordPreservation.ts | 11 ++++++++--- test/keywordPreservation.test.ts | 25 ++++++++++++++++++------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/keywordPreservation.ts b/src/keywordPreservation.ts index 709851467..5145e8924 100644 --- a/src/keywordPreservation.ts +++ b/src/keywordPreservation.ts @@ -15,18 +15,23 @@ export const shouldFieldBePreserved = ( export const getPreservableFieldsFromAssets = ( asset: any, + address: string, keywordMappings: KeywordMappings ): string[] => { if (typeof asset === 'string') { if (shouldFieldBePreserved(asset, keywordMappings)) { - return [asset]; + return [address]; } return []; } if (Array.isArray(asset)) { return asset .map((arrayItem) => { - return getPreservableFieldsFromAssets(arrayItem, keywordMappings); + return getPreservableFieldsFromAssets( + arrayItem, + `${address}.[name=${arrayItem.name}]`, + keywordMappings + ); }) .flat(); } @@ -36,7 +41,7 @@ export const getPreservableFieldsFromAssets = ( const value = asset[key]; if (value === undefined || value === null) return []; - return getPreservableFieldsFromAssets(value, keywordMappings); + return getPreservableFieldsFromAssets(value, `${address}.${key}`, keywordMappings); }) .flat(); } diff --git a/test/keywordPreservation.test.ts b/test/keywordPreservation.test.ts index 85c21da62..187d529c2 100644 --- a/test/keywordPreservation.test.ts +++ b/test/keywordPreservation.test.ts @@ -54,7 +54,17 @@ describe('#Keyword Preservation', () => { }, array: [ { - nestedArray: ['Nested array value 1 ##KEYWORD##', 'Nested array value 2 ##KEYWORD##'], + name: 'array-item-1', + nestedArray: [ + { + name: 'nested-array-item-1', + value: 'Nested array value 1 ##KEYWORD##', + }, + { + name: 'nested-array-item-2', + value: 'Nested array value 2 ##KEYWORD##', + }, + ], notInKeywordMapping: '##NOT_IN_KEYWORD_MAPPING##', nested: { nestedProperty: 'Another nested array property ##KEYWORD##', @@ -65,6 +75,7 @@ describe('#Keyword Preservation', () => { nullField: null, undefinedField: undefined, }, + '', { KEYWORD: 'Travel0', ARRAY_REPLACE_KEYWORD: ['this value', 'that value'], @@ -72,12 +83,12 @@ describe('#Keyword Preservation', () => { ); expect(fieldsToPreserve).to.have.members([ - 'Friendly name ##KEYWORD##', - 'Nested property ##KEYWORD##', - 'Nested array value 1 ##KEYWORD##', - 'Nested array value 2 ##KEYWORD##', - 'Another nested array property ##KEYWORD##', - '@@ARRAY_REPLACE_KEYWORD@@', + '.object.friendly_name', + '.object.nested.nestedProperty', + '.array.[name=array-item-1].nestedArray.[name=nested-array-item-1].value', + '.array.[name=array-item-1].nestedArray.[name=nested-array-item-2].value', + '.array.[name=array-item-1].nested.nestedProperty', + '.arrayReplace', ]); }); }); From 4807af07b7785510156fdf7516e464480614ad1a Mon Sep 17 00:00:00 2001 From: Will Vedder Date: Mon, 13 Feb 2023 15:51:15 -0500 Subject: [PATCH 06/14] Adding address notation lookup --- src/keywordPreservation.ts | 29 +++++++++++++++++++ test/keywordPreservation.test.ts | 48 +++++++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/keywordPreservation.ts b/src/keywordPreservation.ts index 5145e8924..d71cd3c47 100644 --- a/src/keywordPreservation.ts +++ b/src/keywordPreservation.ts @@ -1,5 +1,7 @@ +import { get as getByDotNotation } from 'dot-prop'; import { KeywordMappings } from './types'; import { keywordReplaceArrayRegExp, keywordReplaceStringRegExp } from './tools/utils'; +import { add } from 'lodash'; export const shouldFieldBePreserved = ( string: string, @@ -47,3 +49,30 @@ export const getPreservableFieldsFromAssets = ( } return []; }; + +export const getAssetsValueByAddress = (address: string, assets: any): any => { + const isTrivialAddress = address.indexOf('[') === -1; + if (isTrivialAddress) { + return getByDotNotation(assets, address); + } + + const directions = address.split('.'); + + if (directions.length === 0) return assets; + + if (directions[0].charAt(0) === '[') { + const identifier = directions[0].substring(1, directions[0].length - 1).split('=')[0]; + const identifierValue = directions[0].substring(1, directions[0].length - 1).split('=')[1]; + + const target = assets.find((item: any) => { + return item[identifier] === identifierValue; + }); + + return getAssetsValueByAddress(directions.slice(1).join('.'), target); + } + + return getAssetsValueByAddress( + directions.slice(1).join('.'), + getByDotNotation(assets, directions[0]) + ); +}; diff --git a/test/keywordPreservation.test.ts b/test/keywordPreservation.test.ts index 187d529c2..b0a6cd43a 100644 --- a/test/keywordPreservation.test.ts +++ b/test/keywordPreservation.test.ts @@ -1,5 +1,9 @@ import { expect } from 'chai'; -import { shouldFieldBePreserved, getPreservableFieldsFromAssets } from '../src/keywordPreservation'; +import { + shouldFieldBePreserved, + getPreservableFieldsFromAssets, + getAssetsValueByAddress, +} from '../src/keywordPreservation'; describe('#Keyword Preservation', () => { describe('shouldFieldBePreserved', () => { @@ -93,3 +97,45 @@ describe('#Keyword Preservation', () => { }); }); }); + +describe('getAssetsValueByAddress', () => { + it('should find address with proprietary notation', () => { + const mockAssetTree = { + tenant: { + display_name: 'This is my tenant display name', + }, + clients: [ + { + name: 'client-1', + display_name: 'Some Display Name', + }, + { + name: 'client-2', + display_name: 'This is the target value', + }, + { + name: 'client-3', + connections: [ + { + connection_name: 'connection-1', + display_name: 'My connection display name', + }, + ], + }, + ], + }; + + expect(getAssetsValueByAddress('tenant.display_name', mockAssetTree)).to.equal( + 'This is my tenant display name' + ); + expect(getAssetsValueByAddress('clients.[name=client-2].display_name', mockAssetTree)).to.equal( + 'This is the target value' + ); + expect( + getAssetsValueByAddress( + 'clients.[name=client-3].connections.[connection_name=connection-1].display_name', + mockAssetTree + ) + ).to.equal('My connection display name'); + }); +}); From e76e92f4359c7029ba42dc8948edf28075a9d663 Mon Sep 17 00:00:00 2001 From: Will Vedder Date: Mon, 13 Feb 2023 17:42:42 -0500 Subject: [PATCH 07/14] Adding stronger tests, cleaning up code --- src/keywordPreservation.ts | 29 +++++++++++++++++++++++++---- test/keywordPreservation.test.ts | 23 ++++++++++++++++------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/keywordPreservation.ts b/src/keywordPreservation.ts index d71cd3c47..e1d02da3a 100644 --- a/src/keywordPreservation.ts +++ b/src/keywordPreservation.ts @@ -26,12 +26,15 @@ export const getPreservableFieldsFromAssets = ( } return []; } + + const shouldRenderDot = address !== ''; + if (Array.isArray(asset)) { return asset .map((arrayItem) => { return getPreservableFieldsFromAssets( arrayItem, - `${address}.[name=${arrayItem.name}]`, + `${address}${shouldRenderDot ? '.' : ''}[name=${arrayItem.name}]`, keywordMappings ); }) @@ -43,27 +46,45 @@ export const getPreservableFieldsFromAssets = ( const value = asset[key]; if (value === undefined || value === null) return []; - return getPreservableFieldsFromAssets(value, `${address}.${key}`, keywordMappings); + + return getPreservableFieldsFromAssets( + value, + `${address}${shouldRenderDot ? '.' : ''}${key}`, + keywordMappings + ); }) .flat(); } return []; }; +// getAssetsValueByAddress returns a value for an arbitrary data structure when +// provided an "address" of that value. This address is similar to JS object notation +// with the exception of identifying array items by a unique property instead of order. +// Example: +// Object: `{ actions: [ { name: "action-1", code: "..."}] }` +// Address: `.actions[name=action-1].code` export const getAssetsValueByAddress = (address: string, assets: any): any => { + //Look ahead and see if the address path only contains dots (ex: `tenant.friendly_name`) + //if so the address is trivial and can use the dot-prop package to return the value + const isTrivialAddress = address.indexOf('[') === -1; if (isTrivialAddress) { return getByDotNotation(assets, address); } + // It is easier to handle an address piece-by-piece by + // splitting on the period into separate "directions" const directions = address.split('.'); - if (directions.length === 0) return assets; - + // If the the next directions are the proprietary array syntax (ex: `[name=foo]`) + // then perform lookup against unique array-item property if (directions[0].charAt(0) === '[') { const identifier = directions[0].substring(1, directions[0].length - 1).split('=')[0]; const identifierValue = directions[0].substring(1, directions[0].length - 1).split('=')[1]; + if (assets === undefined) return undefined; + const target = assets.find((item: any) => { return item[identifier] === identifierValue; }); diff --git a/test/keywordPreservation.test.ts b/test/keywordPreservation.test.ts index b0a6cd43a..9e220a2d2 100644 --- a/test/keywordPreservation.test.ts +++ b/test/keywordPreservation.test.ts @@ -87,19 +87,19 @@ describe('#Keyword Preservation', () => { ); expect(fieldsToPreserve).to.have.members([ - '.object.friendly_name', - '.object.nested.nestedProperty', - '.array.[name=array-item-1].nestedArray.[name=nested-array-item-1].value', - '.array.[name=array-item-1].nestedArray.[name=nested-array-item-2].value', - '.array.[name=array-item-1].nested.nestedProperty', - '.arrayReplace', + 'object.friendly_name', + 'object.nested.nestedProperty', + 'array.[name=array-item-1].nestedArray.[name=nested-array-item-1].value', + 'array.[name=array-item-1].nestedArray.[name=nested-array-item-2].value', + 'array.[name=array-item-1].nested.nestedProperty', + 'arrayReplace', ]); }); }); }); describe('getAssetsValueByAddress', () => { - it('should find address with proprietary notation', () => { + it('should find the value of the addressed property', () => { const mockAssetTree = { tenant: { display_name: 'This is my tenant display name', @@ -137,5 +137,14 @@ describe('getAssetsValueByAddress', () => { mockAssetTree ) ).to.equal('My connection display name'); + expect(getAssetsValueByAddress('this.address.should.not.exist', mockAssetTree)).to.equal( + undefined + ); + expect(getAssetsValueByAddress('this.address.[should=not].exist', mockAssetTree)).to.equal( + undefined + ); + expect(getAssetsValueByAddress('this.address.should.[not=exist]', mockAssetTree)).to.equal( + undefined + ); }); }); From a76e9a60baacc913e0b942ff1c3e1c98fff9db8d Mon Sep 17 00:00:00 2001 From: Will Vedder Date: Mon, 13 Feb 2023 17:49:23 -0500 Subject: [PATCH 08/14] Adding more context --- src/keywordPreservation.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/keywordPreservation.ts b/src/keywordPreservation.ts index e1d02da3a..c0abe81f8 100644 --- a/src/keywordPreservation.ts +++ b/src/keywordPreservation.ts @@ -32,6 +32,12 @@ export const getPreservableFieldsFromAssets = ( if (Array.isArray(asset)) { return asset .map((arrayItem) => { + // Using the `name` field as the primary unique identifier for array items + // TODO: expand the available identifier fields to encompass objects that lack name + const hasIdentifier = arrayItem.name !== undefined; + + if (!hasIdentifier) return []; + return getPreservableFieldsFromAssets( arrayItem, `${address}${shouldRenderDot ? '.' : ''}[name=${arrayItem.name}]`, From b3a2bca4aa005da520bc90a4643862ab26bafd60 Mon Sep 17 00:00:00 2001 From: Will Vedder Date: Tue, 14 Feb 2023 06:53:12 -0500 Subject: [PATCH 09/14] Removing unused package --- src/keywordPreservation.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/keywordPreservation.ts b/src/keywordPreservation.ts index c0abe81f8..43ae29f39 100644 --- a/src/keywordPreservation.ts +++ b/src/keywordPreservation.ts @@ -1,7 +1,6 @@ import { get as getByDotNotation } from 'dot-prop'; import { KeywordMappings } from './types'; import { keywordReplaceArrayRegExp, keywordReplaceStringRegExp } from './tools/utils'; -import { add } from 'lodash'; export const shouldFieldBePreserved = ( string: string, From 579c5d4023918f4e14213571a01309d9db0de0c1 Mon Sep 17 00:00:00 2001 From: Will Vedder Date: Tue, 14 Feb 2023 15:27:01 -0500 Subject: [PATCH 10/14] Adding more tests --- src/keywordPreservation.ts | 41 +++++++++++++++++++++ test/keywordPreservation.test.ts | 63 ++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/src/keywordPreservation.ts b/src/keywordPreservation.ts index 43ae29f39..b02b7cffc 100644 --- a/src/keywordPreservation.ts +++ b/src/keywordPreservation.ts @@ -102,3 +102,44 @@ export const getAssetsValueByAddress = (address: string, assets: any): any => { getByDotNotation(assets, directions[0]) ); }; + +// convertAddressToDotNotation will convert the proprietary address into conventional +// JS object notation. Performing this conversion simplifies the process +// of updating a specific property for a given asset tree using the dot-prop library +export const convertAddressToDotNotation = ( + assets: any, + address: string, + finalAddressTrail = '' +): string => { + const directions = address.split('.'); + + if (directions[0] === '') return finalAddressTrail; + + if (directions[0].charAt(0) === '[') { + const identifier = directions[0].substring(1, directions[0].length - 1).split('=')[0]; + const identifierValue = directions[0].substring(1, directions[0].length - 1).split('=')[1]; + + let targetIndex = -1; + + assets.forEach((item: any, index: number) => { + if (item[identifier] === identifierValue) { + targetIndex = index; + } + }); + + if (targetIndex === -1) + throw new Error(`Cannot find ${directions[0]} in ${JSON.stringify(assets)}`); + + return convertAddressToDotNotation( + assets[targetIndex], + directions.slice(1).join('.'), + `${finalAddressTrail}.${targetIndex}` + ); + } + + return convertAddressToDotNotation( + getByDotNotation(assets, directions[0]), + directions.slice(1).join('.'), + finalAddressTrail === '' ? directions[0] : `${finalAddressTrail}.${directions[0]}` + ); +}; diff --git a/test/keywordPreservation.test.ts b/test/keywordPreservation.test.ts index 9e220a2d2..3e68eb079 100644 --- a/test/keywordPreservation.test.ts +++ b/test/keywordPreservation.test.ts @@ -1,8 +1,10 @@ import { expect } from 'chai'; +import { get as getDotNotation } from 'dot-prop'; import { shouldFieldBePreserved, getPreservableFieldsFromAssets, getAssetsValueByAddress, + convertAddressToDotNotation, } from '../src/keywordPreservation'; describe('#Keyword Preservation', () => { @@ -148,3 +150,64 @@ describe('getAssetsValueByAddress', () => { ); }); }); + +describe('convertAddressToDotNotation', () => { + const mockAssets = { + tenant: { + friendly_name: 'Friendly Tenant Name', + }, + actions: [ + { + name: 'action-1', + code: "window.alert('Foo')", + }, + { + name: 'action-2', + nestedProperty: { + array: [ + { + name: 'foo', + }, + { + name: 'bar', + arrayProperty: 'baz', + }, + ], + }, + }, + ], + }; + + it('should convert proprietary address to conventional JS object notation (aka "dot notation")', () => { + expect(convertAddressToDotNotation(mockAssets, 'tenant.friendly_name')).to.equal( + 'tenant.friendly_name' + ); + expect(getDotNotation(mockAssets, 'tenant.friendly_name')).to.equal( + mockAssets.tenant.friendly_name + ); + + expect(convertAddressToDotNotation(mockAssets, 'actions.[name=action-1].code')).to.equal( + 'actions.0.code' + ); + expect(getDotNotation(mockAssets, 'actions.0.code')).to.equal(mockAssets.actions[0].code); + + expect( + convertAddressToDotNotation( + mockAssets, + 'actions.[name=action-2].nestedProperty.array.[name=bar].arrayProperty' + ) + ).to.equal('actions.1.nestedProperty.array.1.arrayProperty'); + + expect(getDotNotation(mockAssets, 'actions.1.nestedProperty.array.1.arrayProperty')).to.equal( + mockAssets.actions[1].nestedProperty?.array[1].arrayProperty + ); + }); + + it('should throw if provided address is invalid', () => { + expect(() => + convertAddressToDotNotation(mockAssets, 'actions.[name=this-action-does-not-exist].code') + ).to.throw( + `Cannot find [name=this-action-does-not-exist] in [{"name":"action-1","code":"window.alert('Foo')"},{"name":"action-2","nestedProperty":{"array":[{"name":"foo"},{"name":"bar","arrayProperty":"baz"}]}}]` + ); + }); +}); From 48a2aa28d2e2f04a64d228d435b5e4aa40048512 Mon Sep 17 00:00:00 2001 From: Will Vedder Date: Tue, 14 Feb 2023 16:00:33 -0500 Subject: [PATCH 11/14] Adding more tests --- src/keywordPreservation.ts | 19 ++++++++- test/keywordPreservation.test.ts | 66 ++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/src/keywordPreservation.ts b/src/keywordPreservation.ts index b02b7cffc..a98d73d10 100644 --- a/src/keywordPreservation.ts +++ b/src/keywordPreservation.ts @@ -1,4 +1,4 @@ -import { get as getByDotNotation } from 'dot-prop'; +import { get as getByDotNotation, set as setByDotNotation } from 'dot-prop'; import { KeywordMappings } from './types'; import { keywordReplaceArrayRegExp, keywordReplaceStringRegExp } from './tools/utils'; @@ -143,3 +143,20 @@ export const convertAddressToDotNotation = ( finalAddressTrail === '' ? directions[0] : `${finalAddressTrail}.${directions[0]}` ); }; + +export const updateAssetsByAddress = ( + assets: object, + address: string, + newValue: string +): object => { + const dotNotationAddress = convertAddressToDotNotation(assets, address); + + const doesPropertyExist = getByDotNotation(assets, dotNotationAddress) !== undefined; + + if (!doesPropertyExist) { + throw new Error(`cannot update assets by address: ${address} because it does not exist.`); + } + + setByDotNotation(assets, dotNotationAddress, newValue); + return assets; +}; diff --git a/test/keywordPreservation.test.ts b/test/keywordPreservation.test.ts index 3e68eb079..dcf4ce945 100644 --- a/test/keywordPreservation.test.ts +++ b/test/keywordPreservation.test.ts @@ -5,6 +5,7 @@ import { getPreservableFieldsFromAssets, getAssetsValueByAddress, convertAddressToDotNotation, + updateAssetsByAddress, } from '../src/keywordPreservation'; describe('#Keyword Preservation', () => { @@ -211,3 +212,68 @@ describe('convertAddressToDotNotation', () => { ); }); }); + +describe('updateAssetsByAddress', () => { + const mockAssetTree = { + tenant: { + display_name: 'This is my tenant display name', + }, + clients: [ + { + name: 'client-1', + display_name: 'Some Display Name', + }, + { + name: 'client-2', + display_name: 'This is the target value', + }, + { + name: 'client-3', + connections: [ + { + connection_name: 'connection-1', + display_name: 'My connection display name', + }, + ], + }, + ], + }; + it('should update an specific asset field for a provided address', () => { + expect( + updateAssetsByAddress( + mockAssetTree, + 'clients.[name=client-3].connections.[connection_name=connection-1].display_name', + 'New connection display name' + ) + ).to.deep.equal( + (() => { + const newAssets = mockAssetTree; + //@ts-ignore because we know this value is defined + newAssets.clients[2].connections[0].display_name = 'New connection display name'; + return newAssets; + })() + ); + + expect( + updateAssetsByAddress(mockAssetTree, 'tenant.display_name', 'This is the new display name') + ).to.deep.equal( + (() => { + const newAssets = mockAssetTree; + newAssets.tenant.display_name = 'This is the new display name'; + return newAssets; + })() + ); + }); + + it('should _____ if invalid address provided', () => { + expect(() => + updateAssetsByAddress(mockAssetTree, 'clients.[name=this-client-does-not-exist]', '_') + ).to.throw(); + + expect(() => + updateAssetsByAddress(mockAssetTree, 'tenant.this_property_does_not_exist', '_') + ).to.throw( + 'cannot update assets by address: tenant.this_property_does_not_exist because it does not exist.' + ); + }); +}); From 92ec8ebe60c5835d96fa1bf672eeca3df2d35475 Mon Sep 17 00:00:00 2001 From: Will Vedder Date: Tue, 14 Feb 2023 16:24:29 -0500 Subject: [PATCH 12/14] Fixing test --- test/keywordPreservation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/keywordPreservation.test.ts b/test/keywordPreservation.test.ts index dcf4ce945..eb1e169f2 100644 --- a/test/keywordPreservation.test.ts +++ b/test/keywordPreservation.test.ts @@ -265,7 +265,7 @@ describe('updateAssetsByAddress', () => { ); }); - it('should _____ if invalid address provided', () => { + it('should throw errors if invalid addresses provided', () => { expect(() => updateAssetsByAddress(mockAssetTree, 'clients.[name=this-client-does-not-exist]', '_') ).to.throw(); From 7199bd1a0968b41c0cfee9c306826280077be5ea Mon Sep 17 00:00:00 2001 From: Will Vedder Date: Tue, 14 Feb 2023 17:27:44 -0500 Subject: [PATCH 13/14] Adding preserve keywords function --- src/keywordPreservation.ts | 58 +++++++++++++++++++++++---- test/keywordPreservation.test.ts | 68 +++++++++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 8 deletions(-) diff --git a/src/keywordPreservation.ts b/src/keywordPreservation.ts index a98d73d10..95b1a0bde 100644 --- a/src/keywordPreservation.ts +++ b/src/keywordPreservation.ts @@ -1,6 +1,13 @@ import { get as getByDotNotation, set as setByDotNotation } from 'dot-prop'; +import { keywordReplace } from './tools/utils'; import { KeywordMappings } from './types'; import { keywordReplaceArrayRegExp, keywordReplaceStringRegExp } from './tools/utils'; +import { cloneDeep } from 'lodash'; + +/* + RFC for Keyword Preservation: https://github.com/auth0/auth0-deploy-cli/issues/688 + Original Github Issue: https://github.com/auth0/auth0-deploy-cli/issues/328 +*/ export const shouldFieldBePreserved = ( string: string, @@ -15,9 +22,9 @@ export const shouldFieldBePreserved = ( }; export const getPreservableFieldsFromAssets = ( - asset: any, - address: string, - keywordMappings: KeywordMappings + asset: object, + keywordMappings: KeywordMappings, + address = '' ): string[] => { if (typeof asset === 'string') { if (shouldFieldBePreserved(asset, keywordMappings)) { @@ -39,8 +46,8 @@ export const getPreservableFieldsFromAssets = ( return getPreservableFieldsFromAssets( arrayItem, - `${address}${shouldRenderDot ? '.' : ''}[name=${arrayItem.name}]`, - keywordMappings + keywordMappings, + `${address}${shouldRenderDot ? '.' : ''}[name=${arrayItem.name}]` ); }) .flat(); @@ -54,8 +61,8 @@ export const getPreservableFieldsFromAssets = ( return getPreservableFieldsFromAssets( value, - `${address}${shouldRenderDot ? '.' : ''}${key}`, - keywordMappings + keywordMappings, + `${address}${shouldRenderDot ? '.' : ''}${key}` ); }) .flat(); @@ -160,3 +167,40 @@ export const updateAssetsByAddress = ( setByDotNotation(assets, dotNotationAddress, newValue); return assets; }; + +// preserveKeywords is the function that ultimately gets executed during export +// to attempt to preserve keywords (ex: ##KEYWORD##) in local configuration files +// from getting overwritten by remote values during export. +export const preserveKeywords = ( + localAssets: object, + remoteAssets: object, + keywordMappings: KeywordMappings +): object => { + const addresses = getPreservableFieldsFromAssets(localAssets, keywordMappings, ''); + + let updatedRemoteAssets = cloneDeep(remoteAssets); + + addresses.forEach((address) => { + const localValue = getAssetsValueByAddress(address, localAssets); + const remoteValue = getAssetsValueByAddress(address, remoteAssets); + + const localValueWithReplacement = keywordReplace(localValue, keywordMappings); + + const localAndRemoteValuesAreEqual = (() => { + if (typeof remoteValue === 'string') { + return localValueWithReplacement === remoteValue; + } + //TODO: Account for non-string replacements via @@ syntax + })(); + + if (!localAndRemoteValuesAreEqual) { + console.warn( + `WARNING! The remote value with address of ${address} has value of "${remoteValue}" but will be preserved with "${localValueWithReplacement}" due to keyword preservation.` + ); + } + + updatedRemoteAssets = updateAssetsByAddress(updatedRemoteAssets, address, localValue); + }); + + return updatedRemoteAssets; +}; diff --git a/test/keywordPreservation.test.ts b/test/keywordPreservation.test.ts index eb1e169f2..79583b34a 100644 --- a/test/keywordPreservation.test.ts +++ b/test/keywordPreservation.test.ts @@ -6,7 +6,9 @@ import { getAssetsValueByAddress, convertAddressToDotNotation, updateAssetsByAddress, + preserveKeywords, } from '../src/keywordPreservation'; +import { cloneDeep } from 'lodash'; describe('#Keyword Preservation', () => { describe('shouldFieldBePreserved', () => { @@ -82,7 +84,6 @@ describe('#Keyword Preservation', () => { nullField: null, undefinedField: undefined, }, - '', { KEYWORD: 'Travel0', ARRAY_REPLACE_KEYWORD: ['this value', 'that value'], @@ -277,3 +278,68 @@ describe('updateAssetsByAddress', () => { ); }); }); + +describe('preserveKeywords', () => { + const mockLocalAssets = { + tenant: { + display_name: 'The ##COMPANY_NAME## Tenant', + allowed_logout_urls: '@@ALLOWED_LOGOUT_URLS@@', + }, + actions: [ + { + name: 'action-1', + display_name: '##ENV## Action 1', + }, + { + name: 'action-2', + display_name: "This action won't exist on remote, will be deleted", + }, + ], + }; + + const mockRemoteAssets = { + tenant: { + display_name: 'The Travel0 Tenant', + allowed_logout_urls: ['localhost:3000/logout', 'https://travel0.com/logout'], + }, + prompts: { + universal_login_enabled: true, + customText: {}, + }, + pages: undefined, //TODO: test these cases more thoroughly + rules: null, //TODO: test these cases more thoroughly + actions: [ + { + name: 'action-1', + display_name: 'Production Action 1', + }, + { + name: 'action-3', + display_name: 'This action exists on remote but not local', + }, + ], + }; + + it('should preserve keywords when they correlate to keyword mappings', () => { + const preservedAssets = preserveKeywords(mockLocalAssets, mockRemoteAssets, { + COMPANY_NAME: 'Travel0', + ALLOWED_LOGOUT_URLS: ['localhost:3000/logout', 'https://travel0.com/logout'], + ENV: 'Production', + }); + + expect(preservedAssets).to.deep.equal( + (() => { + const expected = cloneDeep(mockRemoteAssets); + //@ts-ignore + expected.tenant = mockLocalAssets.tenant; + expected.actions[0].display_name = '##ENV## Action 1'; + return expected; + })() + ); + }); + + it('should not preserve keywords when no keyword mappings', () => { + const preservedAssets = preserveKeywords(mockLocalAssets, mockRemoteAssets, {}); + expect(preservedAssets).to.deep.equal(mockRemoteAssets); + }); +}); From 078171c5a7e5567682e69354300f94b6db24d3e0 Mon Sep 17 00:00:00 2001 From: Will Vedder Date: Tue, 21 Feb 2023 13:08:22 -0500 Subject: [PATCH 14/14] Fixing test --- test/keywordPreservation.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/keywordPreservation.test.ts b/test/keywordPreservation.test.ts index 79583b34a..63b3991e8 100644 --- a/test/keywordPreservation.test.ts +++ b/test/keywordPreservation.test.ts @@ -285,6 +285,8 @@ describe('preserveKeywords', () => { display_name: 'The ##COMPANY_NAME## Tenant', allowed_logout_urls: '@@ALLOWED_LOGOUT_URLS@@', }, + roles: null, + hooks: undefined, actions: [ { name: 'action-1',