Skip to content

Commit

Permalink
feat: add support for async value functions
Browse files Browse the repository at this point in the history
  • Loading branch information
Pranav Joglekar committed Jan 15, 2025
1 parent 2219c42 commit 8873d7f
Show file tree
Hide file tree
Showing 4 changed files with 220 additions and 0 deletions.
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 = [];
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
72 changes: 72 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 */ }
}

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 @@ -224,7 +292,11 @@ _.assign(Substitutor, /** @lends Substitutor */ {
return function (match, token) {
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

0 comments on commit 8873d7f

Please sign in to comment.