From 6a835c1ac892da1171cfe145d0a90f013bc464c8 Mon Sep 17 00:00:00 2001 From: Nicolas Morel Date: Wed, 27 Sep 2023 17:55:05 +0200 Subject: [PATCH] feat: allow custom expression functions --- API.md | 3 ++- lib/template.js | 24 +++++++++++++++++++++--- test/template.js | 43 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/API.md b/API.md index c9537770..707f35c8 100755 --- a/API.md +++ b/API.md @@ -233,7 +233,8 @@ const schema = custom.object(); // Returns Joi.object().min(1) Generates a dynamic expression using a template string where: - `template` - the template string using the [template syntax](#template-syntax). - `options` - optional settings used when creating internal references. Supports the same options - as [`ref()`](#refkey-options). + as [`ref()`](#refkey-options), in addition to those options: + - `functions` - an object with keys being function names and values being their implementation that will be executed when used in the expression. Using the same name as a built-in function will result in a local override. Note: carefully check your arguments depending on the situation where the expression is used. #### Template syntax diff --git a/lib/template.js b/lib/template.js index 8b99588a..7951fe0f 100755 --- a/lib/template.js +++ b/lib/template.js @@ -37,7 +37,20 @@ module.exports = exports = internals.Template = class { this.rendered = source; this._template = null; - this._settings = Clone(options); + + if (options) { + const { functions, ...opts } = options; + this._settings = Object.keys(opts).length ? Clone(opts) : undefined; + this._functions = functions; + if (this._functions) { + Assert(Object.keys(this._functions).every((key) => typeof key === 'string'), 'Functions keys must be strings'); + Assert(Object.values(this._functions).every((key) => typeof key === 'function'), 'Functions values must be functions'); + } + } + else { + this._settings = undefined; + this._functions = undefined; + } this._parse(); } @@ -122,12 +135,16 @@ module.exports = exports = internals.Template = class { desc.options = this._settings; } + if (this._functions) { + desc.functions = this._functions; + } + return desc; } static build(desc) { - return new internals.Template(desc.template, desc.options); + return new internals.Template(desc.template, desc.options || desc.functions ? { ...desc.options, functions: desc.functions } : undefined); } isDynamic() { @@ -215,7 +232,8 @@ module.exports = exports = internals.Template = class { }; try { - var formula = new Formula.Parser(content, { reference, functions: internals.functions, constants: internals.constants }); + const functions = this._functions ? { ...internals.functions, ...this._functions } : internals.functions; + var formula = new Formula.Parser(content, { reference, functions, constants: internals.constants }); } catch (err) { err.message = `Invalid template variable "${content}" fails due to: ${err.message}`; diff --git a/test/template.js b/test/template.js index 6be5c8df..de60051f 100755 --- a/test/template.js +++ b/test/template.js @@ -176,6 +176,49 @@ describe('Template', () => { describe('functions', () => { + describe('extensions', () => { + + it('allow new functions', () => { + + const schema = Joi.object().rename(/.*/, Joi.x('{ uppercase(#0) }', { + functions: { + uppercase(value) { + + if (typeof value === 'string') { + return value.toUpperCase(); + } + + return value; + } + } + })); + Helper.validate(schema, {}, [ + [{ a: 1, b: true }, true, { A: 1, B: true }], + [{ a: 1, [Symbol.for('b')]: true }, true, { A: 1, [Symbol.for('b')]: true }] + ]); + }); + + it('overrides built-in functions', () => { + + const schema = Joi.object({ + a: Joi.array().length(Joi.x('{length(b)}', { + functions: { + length(value) { + + return value.length - 1; + } + } + })), + b: Joi.string() + }); + + Helper.validate(schema, [ + [{ a: [1], b: 'xx' }, true], + [{ a: [1], b: 'x' }, false, '"a" must contain {length(b)} items'] + ]); + }); + }); + describe('msg()', () => { it('ignores missing options', () => {