diff --git a/CHANGELOG.md b/CHANGELOG.md index e55f07b..d791709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,10 @@ ## next - Reduced parser size by 15Kb +- Changed `setup()` function to take `options` parameter instead of custom methods dictionary, i.e. `setup(methods)` → `setup({ methods })` +- Added `assertions` option for `jora()` and `setup()` functions to specify additional assertion functions, i.e. `jora(..., { assetions })` and `setup({ assertions })` +- Forbidden to override built-in methods and assertions, now `setup()` and `query()` functions throws when a custom method or assertion has the same name as built-in one - Extended query result object in stat mode to provide a result value of the query execution as `value` property (i.e. `jora(query, { stat: true })().value`) -- Added `assertions` option for `jora()` and second argument for `setup()` method to specify additional assertion functions - Renamed `SortingFunction` AST node type into `CompareFunction` - Renamed `Unary` AST node type into `Prefix` - Added `Assertion` and `Postfix` AST node types diff --git a/docs/discovery/prepare.js b/docs/discovery/prepare.js index bbe5666..d2cb941 100644 --- a/docs/discovery/prepare.js +++ b/docs/discovery/prepare.js @@ -49,7 +49,6 @@ module.exports = function(data, { addQueryHelpers, defineObjectMarker }) { } addQueryHelpers({ - replace: jora.methods.replace, parseExample, slug(current) { return current ? slugger.slug(current, { dryrun: true }) : ''; diff --git a/src/index.js b/src/index.js index 830fdfd..aa89c27 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,5 @@ import { version } from './version.js'; +import { hasOwn } from './utils/misc.js'; import parser from './lang/parse.js'; import suggest from './lang/suggest.js'; import walk from './lang/walk.js'; @@ -14,6 +15,52 @@ const cacheStrictStat = new Map(); const cacheTollerant = new Map(); const cacheTollerantStat = new Map(); +function defineDictFunction(dict, name, fn, queryMethods, queryAssertions) { + if (typeof fn === 'string') { + Object.defineProperty(dict, name, { + configurable: true, + get() { + const compiledFn = compileFunction(fn)(buildin, queryMethods, queryAssertions); + const value = current => compiledFn(current, null); + Object.defineProperty(dict, name, { value }); + return value; + } + }); + } else { + dict[name] = fn; + } +} + +function buildQueryMethodsAndAssertions(customMethods, customAssertions) { + if (!customMethods && !customAssertions) { + return { + queryMethods: methods, + queryAssertions: assertions + }; + } + + const queryMethods = { ...methods }; + const queryAssertions = { ...assertions }; + + for (const [name, fn] of Object.entries(customMethods || {})) { + if (hasOwn(methods, name)) { + throw new Error(`Builtin method "${name}" can\'t be overridden`); + } + + defineDictFunction(queryMethods, name, fn, queryMethods, queryAssertions); + } + + for (const [name, fn] of Object.entries(customAssertions || {})) { + if (hasOwn(assertions, name)) { + throw new Error(`Builtin assertion "${name}" can\'t be overridden`); + } + + defineDictFunction(queryAssertions, name, fn, queryMethods, queryAssertions); + } + + return { queryMethods, queryAssertions }; +} + function defaultDebugHandler(sectionName, value) { console.log(`[${sectionName}]`); if (typeof value === 'string') { @@ -89,8 +136,8 @@ function createQuery(source, options) { const statMode = Boolean(options.stat); const tolerantMode = Boolean(options.tolerant); - const localMethods = options.methods ? { ...methods, ...options.methods } : methods; - const localAssetions = options.assertions ? { ...assertions, ...options.assertions } : assertions; + const localMethods = 0 && options.methods ? { ...methods, ...options.methods } : methods; + const localAssetions = 0 && options.assertions ? { ...assertions, ...options.assertions } : assertions; const cache = statMode ? (tolerantMode ? cacheTollerantStat : cacheStrictStat) : (tolerantMode ? cacheTollerant : cacheStrict); @@ -112,45 +159,14 @@ function createQuery(source, options) { : fn; } -function setup(customMethods, customAssertions) { +function setup(options) { const cacheStrict = new Map(); const cacheStrictStat = new Map(); const cacheTollerant = new Map(); const cacheTollerantStat = new Map(); - const localMethods = { ...methods }; - const localAssetions = { ...assertions }; - - for (const [name, fn] of Object.entries(customMethods || {})) { - if (typeof fn === 'string') { - Object.defineProperty(localMethods, name, { - configurable: true, - get() { - const compiledFn = compileFunction(fn)(buildin, localMethods, localAssetions); - const value = current => compiledFn(current, null); - Object.defineProperty(localMethods, name, { value }); - return value; - } - }); - } else { - localMethods[name] = fn; - } - } - - for (const [name, fn] of Object.entries(customAssertions || {})) { - if (typeof fn === 'string') { - Object.defineProperty(localAssetions, name, { - configurable: true, - get() { - const compiledFn = compileFunction(fn)(buildin, localMethods, localAssetions); - const value = current => compiledFn(current, null); - Object.defineProperty(localAssetions, name, { value }); - return value; - } - }); - } else { - localAssetions[name] = fn; - } - } + const { methods: customMethods, assertions: customAssertions } = options || {}; + const { queryMethods, queryAssertions } = + buildQueryMethodsAndAssertions(customMethods, customAssertions); return function query(source, options) { options = options || {}; @@ -167,7 +183,16 @@ function setup(customMethods, customAssertions) { if (cache.has(source) && !options.debug) { fn = cache.get(source); } else { - const perform = compileFunction(source, statMode, tolerantMode, options.debug)(buildin, localMethods, localAssetions); + const perform = compileFunction( + source, + statMode, + tolerantMode, + options.debug + )( + buildin, + queryMethods, + queryAssertions + ); fn = statMode ? Object.assign((data, context) => createStatApi(source, perform(data, context)), { query: perform }) : perform; diff --git a/src/lang/compile.js b/src/lang/compile.js index 894fb28..63c4bdf 100644 --- a/src/lang/compile.js +++ b/src/lang/compile.js @@ -231,7 +231,10 @@ export default function compile(ast, tolerant = false, suggestions = null) { if (!hasOwn(providedMethods, method)) { return () => { throw Object.assign( - new Error(`Method "${method}" is not defined`), + new Error( + `Method "${method}" is not defined. If that's a custom method ` + + 'make sure you added it with "methods" section in options' + ), { details: { loc: { range } } } ); }; diff --git a/test/custom-methods.js b/test/custom-methods.js index 05410a2..653e933 100644 --- a/test/custom-methods.js +++ b/test/custom-methods.js @@ -1,118 +1,149 @@ import assert from 'assert'; import jora from 'jora'; -import data from './helpers/fixture.js'; -describe('query/method extensions', () => { - function createExtraFn() { - const calls = []; - return { - calls, - query: jora.setup({ - log() { - calls.push([...arguments]); +describe.skip('query extensions', () => { + describe('custom methods', () => { + it('method as function', () => { + assert.strictEqual( + jora('test()', { + methods: { test: () => 42 } + })(), + 42 + ); + assert.strictEqual( + jora('40.withArgs(2)', { + methods: { + withArgs: (current, arg1) => current + arg1 + } + })(), + 42 + ); + }); + + it('method as string', () => { + const customQuery = setup({ + methods: { + test: '40 + 2', + withThis: '$ + $' } - }) - }; - } - - it('should be called', () => { - const extra = createExtraFn(); - - extra.query('log()')(data); - assert.deepEqual( - extra.calls, - [[data]] - ); - }); - - it('should be called when started with dot', () => { - const extra = createExtraFn(); - - extra.query('.log()')(data); - assert.deepEqual( - extra.calls, - [[data]] - ); - }); - - it('should be called with precending query', () => { - const extra = createExtraFn(); - - extra.query('filename.log()')(data); - assert.deepEqual( - extra.calls, - [[data.map(item => item.filename)]] - ); - }); - - it('should be called for each item when using in parentheses', () => { - const extra = createExtraFn(); - - extra.query('filename.(log())')(data); - assert.deepEqual( - extra.calls, - data.map(item => [item.filename]) - ); - }); - - it('should accept params', () => { - const extra = createExtraFn(); - - extra.query('.[filename="1.css"].(log(1, 2, 3))')(data); - assert.deepEqual( - extra.calls, - [[data[0], 1, 2, 3]] - ); - }); - - it('should resolve params to current', () => { - const extra = createExtraFn(); - - extra.query('.log(filename)')(data); - assert.deepEqual( - extra.calls, - [[data, data.map(item => item.filename)]] - ); - }); - - it('should resolve params to current inside a parentheses', () => { - const extra = createExtraFn(); - - extra.query('.(log(filename))')(data); - assert.deepEqual( - extra.calls, - data.map(item => [item, item.filename]) - ); - }); - - it('should not call a method in map when undefined on object path', () => { - const extra = createExtraFn(); - - extra.query('dontexists.(log())')({}); - assert.deepEqual( - extra.calls, - [] - ); - }); - - it('scope for method arguments should be the same as for query root', () => { - const extra = createExtraFn(); - const data = { foo: { bar: 42 }, baz: 43 }; - - extra.query('foo.bar.log($, baz)')(data); - assert.deepEqual( - extra.calls, - [[42, data, 43]] - ); + }); + + assert.strictEqual( + customQuery('test()', { + methods: { + test: '40 + 2' + } + })(), + 42 + ); + assert.strictEqual( + customQuery('21.withThis()', { + methods: { + withThis: '$ + $$' + } + })(), + 42 + ); + }); + + it('should throw on built-in method override', () => { + assert.throws( + () => setup({ methods: { size: () => 42 } }), + /Builtin method "size" can't be overridden/ + ); + }); + + it('should not affect other setups', () => { + const customQuery1 = setup({ + methods: { + test1: () => 1 + } + }); + const customQuery2 = setup({ + methods: { + test2: () => 2 + } + }); + + assert.strictEqual(customQuery1('test1()')(), 1); + assert.strictEqual(customQuery2('test2()')(), 2); + assert.throws(() => customQuery1('test2()')(), /Method "test2" is not defined/); + assert.throws(() => customQuery2('test1()')(), /Method "test1" is not defined/); + assert.throws(() => jora('test1()')(), /Method "test1" is not defined/); + assert.throws(() => jora('test2()')(), /Method "test2" is not defined/); + }); }); - it('scope for method arguments should be the same as for query root (issue #1)', () => { - const extra = createExtraFn(); - - extra.query('#.foo.log(bar)')({ bar: 43 }, { foo: 42 }); - assert.deepEqual( - extra.calls, - [[42, 43]] - ); + describe('custom assertions', () => { + it('assertion as function', () => { + const customQuery = setup({ + assertions: { + custom: $ => $ == 42 + } + }); + + assert.strictEqual( + customQuery('is custom')(), + false + ); + assert.strictEqual( + customQuery('is custom')(41), + false + ); + assert.strictEqual( + customQuery('is custom')(42), + true + ); + }); + + it('assertion as string', () => { + const customQuery = setup({ + assertions: { + custom: '$ = 42' + } + }); + + assert.strictEqual( + customQuery('is custom')(), + false + ); + assert.strictEqual( + customQuery('is custom')(41), + false + ); + assert.strictEqual( + customQuery('is custom')(42), + true + ); + }); + + it('should thow on built-in assertion override', () => { + assert.throws( + () => setup({ assertions: { number: () => 42 } }), + /Builtin assertion "number" can't be overridden/ + ); + }); + + it('should not affect other setups', () => { + const customQuery1 = setup({ + assertions: { + test1: $ => $ === 1 + } + }); + const customQuery2 = setup({ + assertions: { + test2: $ => $ === 2 + } + }); + + assert.strictEqual(customQuery1('is test1')(1), true); + assert.strictEqual(customQuery1('is test1')(2), false); + assert.strictEqual(customQuery2('is test2')(1), false); + assert.strictEqual(customQuery2('is test2')(2), true); + assert.throws(() => customQuery1('is test2')(), /Assertion "test2" is not defined/); + assert.throws(() => customQuery2('is test1')(), /Assertion "test1" is not defined/); + assert.throws(() => jora('is test1')(), /Assertion "test1" is not defined/); + assert.throws(() => jora('is test2')(), /Assertion "test2" is not defined/); + }); }); }); diff --git a/test/lang/method.js b/test/lang/method.js index 601027d..6871554 100644 --- a/test/lang/method.js +++ b/test/lang/method.js @@ -6,11 +6,13 @@ describe('lang/method', () => { let queryWithExtraMethods; beforeEach(() => queryWithExtraMethods = query.setup({ - args(...args) { - return args; - }, - hasArgsInThis() { - return Object.hasOwnProperty.call(this, 'args'); + methods: { + args(...args) { + return args; + }, + hasArgsInThis() { + return Object.hasOwnProperty.call(this, 'args'); + } } })); diff --git a/test/setup.js b/test/setup.js index 6cf99c3..a91bbde 100644 --- a/test/setup.js +++ b/test/setup.js @@ -14,63 +14,142 @@ describe('query/setup', () => { ); }); - it('with custom methods', () => { - const customQuery = setup({ - test: () => 42, - withArgs: (current, arg1) => current + arg1 + describe('custom methods', () => { + it('method as function', () => { + const customQuery = setup({ + methods: { + test: () => 42, + withArgs: (current, arg1) => current + arg1 + } + }); + + assert.strictEqual( + customQuery('test()')(), + 42 + ); + assert.strictEqual( + customQuery('40.withArgs(2)')(), + 42 + ); }); - assert.strictEqual( - customQuery('test()')(), - 42 - ); - assert.strictEqual( - customQuery('40.withArgs(2)')(), - 42 - ); - }); + it('method as string', () => { + const customQuery = setup({ + methods: { + test: '40 + 2', + withThis: '$$' + } + }); + + assert.strictEqual( + customQuery('test()')(), + 42 + ); + assert.strictEqual( + customQuery('40.withThis(2)')(), + 42 + ); + }); - it('with custom methods as string', () => { - const customQuery = setup({ - test: '40 + 2', - withThis: '$ + $' + it('should throw on built-in method override', () => { + assert.throws( + () => setup({ methods: { size: () => 42 } }), + /Builtin method "size" can't be overridden/ + ); }); - assert.strictEqual( - customQuery('test()')(), - 42 - ); - assert.strictEqual( - customQuery('21.withThis()')(), - 42 - ); + it('should not affect other setups', () => { + const customQuery1 = setup({ + methods: { + test1: () => 1 + } + }); + const customQuery2 = setup({ + methods: { + test2: () => 2 + } + }); + + assert.strictEqual(customQuery1('test1()')(), 1); + assert.strictEqual(customQuery2('test2()')(), 2); + assert.throws(() => customQuery1('test2()')(), /Method "test2" is not defined/); + assert.throws(() => customQuery2('test1()')(), /Method "test1" is not defined/); + assert.throws(() => jora('test1()')(), /Method "test1" is not defined/); + assert.throws(() => jora('test2()')(), /Method "test2" is not defined/); + }); }); - it('should override buildin methods', () => { - const customQuery = setup({ - size: () => 42 + describe('custom assertions', () => { + it('assertion as function', () => { + const customQuery = setup({ + assertions: { + custom: $ => $ == 42 + } + }); + + assert.strictEqual( + customQuery('is custom')(), + false + ); + assert.strictEqual( + customQuery('is custom')(41), + false + ); + assert.strictEqual( + customQuery('is custom')(42), + true + ); }); - assert.strictEqual( - customQuery('size()')('foo'), - 42 - ); - }); - - it('should not affect others', () => { - const customQuery1 = setup({ - test1: () => 1 + it('assertion as string', () => { + const customQuery = setup({ + assertions: { + custom: '$ = 42' + } + }); + + assert.strictEqual( + customQuery('is custom')(), + false + ); + assert.strictEqual( + customQuery('is custom')(41), + false + ); + assert.strictEqual( + customQuery('is custom')(42), + true + ); }); - const customQuery2 = setup({ - test2: () => 2 + + it('should thow on built-in assertion override', () => { + assert.throws( + () => setup({ assertions: { number: () => 42 } }), + /Builtin assertion "number" can't be overridden/ + ); }); - assert.strictEqual(customQuery1('test1()')(), 1); - assert.strictEqual(customQuery2('test2()')(), 2); - assert.throws(() => customQuery1('test2()')(), /Method "test2" is not defined/); - assert.throws(() => customQuery2('test1()')(), /Method "test1" is not defined/); - assert.throws(() => jora('test1()')(), /Method "test1" is not defined/); - assert.throws(() => jora('test2()')(), /Method "test2" is not defined/); + it('should not affect other setups', () => { + const customQuery1 = setup({ + assertions: { + test1: $ => $ === 1 + } + }); + const customQuery2 = setup({ + assertions: { + test2: $ => $ === 2 + } + }); + + assert.strictEqual(customQuery1('is test1')(1), true); + assert.strictEqual(customQuery1('is test1')(2), false); + assert.strictEqual(customQuery2('is test2')(1), false); + assert.strictEqual(customQuery2('is test2')(2), true); + assert.throws(() => customQuery1('is test2')(), /Assertion "test2" is not defined/); + assert.throws(() => customQuery2('is test1')(), /Assertion "test1" is not defined/); + assert.throws(() => jora('is test1')(), /Assertion "test1" is not defined/); + assert.throws(() => jora('is test2')(), /Assertion "test2" is not defined/); + }); }); it('should return the same function for a query', () => {