Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

wip: add support for lazily replacing variables #1388

Draft
wants to merge 1 commit into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions lib/collection/property.js
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,26 @@ _.assign(Property, /** @lends Property */ {
return Substitutor.box(variables, Substitutor.DEFAULT_VARS).parse(str).toString();
},

/**
* Similar to `replaceSubstitutions` but runs asynchronously
* and supports async value functions
*
* @param {String} str -
* @param {VariableList|Object|Array.<VariableList|Object>} variables -
* @returns {String}
*/
// @todo: improve algorithm via variable replacement caching
replaceSubstitutionsAsync: async function (str, variables) {
// if there is nothing to replace, we move on
if (!(str && _.isString(str))) { return str; }

// if variables object is not an instance of substitutor then ensure that it is an array so that it becomes
// compatible with the constructor arguments for a substitutor
!Substitutor.isInstance(variables) && !_.isArray(variables) && (variables = _.tail(arguments));

return (await Substitutor.box(variables, Substitutor.DEFAULT_VARS).parseAsync(str)).toString();
},

/**
* This function accepts an object followed by a number of variable sources as arguments. One or more variable
* sources can be provided and it will use the one that has the value in left-to-right order.
Expand Down Expand Up @@ -319,6 +339,49 @@ _.assign(Property, /** @lends Property */ {
return _.mergeWith({}, obj, customizer);
},

/**
* Similar to `replaceSubstitutionsIn` but runs asynchronously
* and supports async value functions
*
* @param {Object} obj -
* @param {Array.<VariableList|Object>} variables -
* @returns {Object}
*/
replaceSubstitutionsInAsync: async function (obj, variables) {
// if there is nothing to replace, we move on
if (!(obj && _.isObject(obj))) {
return obj;
}

// convert the variables to a substitutor object (will not reconvert if already substitutor)
variables = Substitutor.box(variables, Substitutor.DEFAULT_VARS);

const promises = [];
Pranav2612000 marked this conversation as resolved.
Show resolved Hide resolved
var customizer = function (objectValue, sourceValue, key) {
objectValue = objectValue || {};
if (!_.isString(sourceValue)) {
_.forOwn(sourceValue, function (value, key) {
sourceValue[key] = customizer(objectValue[key], value);
});

return sourceValue;
}

const result = this.replaceSubstitutionsAsync(sourceValue, variables);

promises.push({ key: key, promise: result });

return result;
}.bind(this),
res = _.mergeWith({}, obj, customizer);

await Promise.all(promises.map(async ({ key, promise }) => {
res[key] = await promise;
}));

return res;
},

/**
* This function recursively traverses a variable and detects all instances of variable replacements
* within the string of the object
Expand Down
71 changes: 71 additions & 0 deletions lib/superstring/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,46 @@ _.assign(SuperString.prototype, /** @lends SuperString.prototype */ {
return this;
},

async replaceAsync (regex, fn) {
var replacements = 0; // maintain a count of tokens replaced

// to ensure we do not perform needless operations in the replacement, we use multiple replacement functions
// after validating the parameters
const replacerFn = _.isFunction(fn) ?
function () {
replacements += 1;

return fn.apply(this, arguments);
} :
// this case is returned when replacer is not a function (ensures we do not need to check it)
/* istanbul ignore next */
function () {
replacements += 1;

return fn;
};

let index = 0,
match;

while ((match = regex.exec(this.value.slice(index)))) {
try {
// eslint-disable-next-line no-await-in-loop
let value = await replacerFn(...match);

index += match.index;
this.value = this.value.slice(0, index) + value + this.value.slice(index + match[0].length);
index += match[0].length;
}
catch (_err) { /* empty */ }
}
Comment on lines +86 to +99
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

custom string replace logic instead of using String.replace ( https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace ) as it does not support async replacer fn


this.replacements = replacements; // store the last replacements
replacements && (this.substitutions += 1); // if any replacement is done, count that some substitution was made

return this;
},

/**
* @returns {String}
*/
Expand Down Expand Up @@ -153,6 +193,34 @@ _.assign(Substitutor.prototype, /** @lends Substitutor.prototype */ {
// }

return value;
},

/**
* @param {SuperString} value -
* @returns {String}
*/
async parseAsync (value) {
// convert the value into a SuperString so that it can return tracking results during replacements
value = new SuperString(value);

// get an instance of a replacer function that would be used to replace ejs like variable replacement
// tokens
var replacer = Substitutor.replacer(this);

// replace the value once and keep on doing it until all tokens are replaced or we have reached a limit of
// replacements
do {
// eslint-disable-next-line no-await-in-loop
value = await value.replaceAsync(Substitutor.REGEX_EXTRACT_VARS, replacer);
} while (value.replacements && (value.substitutions < Substitutor.VARS_SUBREPLACE_LIMIT));

// @todo: uncomment this code, and try to raise a warning in some way.
// do a final check that if recursion limits are reached then replace with blank string
// if (value.substitutions >= Substitutor.VARS_SUBREPLACE_LIMIT) {
// value = value.replace(Substitutor.REGEX_EXTRACT_VARS, E);
// }

return value.toString();
}
});

Expand Down Expand Up @@ -225,6 +293,9 @@ _.assign(Substitutor, /** @lends Substitutor */ {
var r = substitutor.find(token);

r && _.isFunction(r) && (r = r());
if (r && _.isFunction(r.value)) {
return r.get();
}
r && _.isFunction(r.toString) && (r = r.toString());

return Substitutor.NATIVETYPES[(typeof r)] ? r : match;
Expand Down
76 changes: 76 additions & 0 deletions test/unit/property.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,59 @@ describe('Property', function () {
// resolves all independent unique variables as well as poly-chained {{0}} & {{1}}
expect(Property.replaceSubstitutions(str, variables)).to.eql('{{xyz}}');
});

it('should correctly resolve variables with values as sync fn', function () {
const str = '{{world}}',
variables = new VariableList(null, [
{
key: 'world',
value: () => {
return 'hello';
}
}
]);

expect(Property.replaceSubstitutions(str, variables)).to.eql('hello');
});
});

describe('.replaceSubstitutionsAsync', function () {
it('should correctly resolve variables with values as async fn', async function () {
const str = '{{world}}',
variables = new VariableList(null, [
{
key: 'world',
type: 'function',
value: async () => {
const x = await new Promise((resolve) => {
resolve('hello');
});

return x;
}
}
]);

expect(await Property.replaceSubstitutionsAsync(str, variables)).to.eql('hello');
});

it('should show variables as unresolved with values as async fn with error', async function () {
const str = '{{world}}',
variables = new VariableList(null, [
{
key: 'world',
type: 'function',
value: async () => {
await new Promise((resolve) => {
resolve('hello');
});
throw new Error('fail');
}
}
]);

expect(await Property.replaceSubstitutionsAsync(str, variables)).to.eql('{{world}}');
});
});

describe('.replaceSubstitutionsIn', function () {
Expand All @@ -442,6 +495,29 @@ describe('Property', function () {
});
});

describe('.replaceSubstitutionsInAsync', function () {
it('should replace with async variables', async function () {
const obj = { foo: '{{var}}' },
variables = new VariableList(null, [
{
key: 'var',
type: 'any',
value: async () => {
const res = await new Promise((resolve) => {
resolve('bar');
});

return res;
}
}
]),
res = await Property.replaceSubstitutionsInAsync(obj, [variables]);

expect(res).to.eql({ foo: 'bar' });
expect(obj).to.eql({ foo: '{{var}}' });
});
});

describe('variable resolution', function () {
it('must resolve variables accurately', function () {
var unresolvedRequest = {
Expand Down
9 changes: 9 additions & 0 deletions test/unit/variable-scope.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,15 @@ describe('VariableScope', function () {
expect(scope.get('var-2')).to.equal('var-2-value');
});

it('should get the specified variable with value as a fn', function () {
var scope = new VariableScope([
{ key: 'var-1', value: () => { return 'var-1-value'; } },
{ key: 'var-2', value: () => { return 'var-2-value'; } }
]);

expect(scope.get('var-2')).to.equal('var-2-value');
});

it('should get last enabled from multi value list', function () {
var scope = new VariableScope([
{ key: 'var-2', value: 'var-2-value' },
Expand Down
Loading