From 1370682a60d4998247da1018e1e6e5a4f7f87d85 Mon Sep 17 00:00:00 2001 From: Thibaut Patel Date: Mon, 24 Jan 2022 18:53:10 +0100 Subject: [PATCH] Added a function to parse a NQL subset refs https://github.com/TryGhost/Team/issues/1277 - This will allow to filter events within `getEventTimeline` - The subset of NQL has the following rules: - Only one level of filters, now parenthesis allowed - Only three filter keys allowed - No `or` allowed outside of the bracket notation (this is allowed: `type:-[email_opened_event,email_failed_event]` but this isn't: `type:1,data.created_at:1`) - The return is an object with a NQL filter by allowed filter key --- ghost/members-api/lib/repositories/event.js | 62 ++++++++++++++- .../test/unit/lib/repositories/event.test.js | 78 +++++++++++++++++++ 2 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 ghost/members-api/test/unit/lib/repositories/event.test.js diff --git a/ghost/members-api/lib/repositories/event.js b/ghost/members-api/lib/repositories/event.js index fe5116cf20..0f6e4a9ee5 100644 --- a/ghost/members-api/lib/repositories/event.js +++ b/ghost/members-api/lib/repositories/event.js @@ -1,3 +1,4 @@ +const errors = require('@tryghost/errors'); const nql = require('@nexes/nql'); module.exports = class EventRepository { @@ -215,6 +216,63 @@ module.exports = class EventRepository { }; } + /** + * Extract a subset of NQL. + * There are only a few properties allowed. + * Parenthesis are forbidden. + * Only ANDs are supported when combining properties. + */ + getNQLSubset(filter) { + if (!filter) { + return {}; + } + + const lex = nql(filter).lex(); + + const allowedFilters = ['type','data.created_at','data.member_id']; + const properties = lex + .filter(x => x.token === 'PROP') + .map(x => x.matched.slice(0, -1)); + if (properties.some(prop => !allowedFilters.includes(prop))) { + throw new errors.IncorrectUsageError({ + message: 'The only allowed filters are `type`, `data.created_at` and `data.member_id`' + }); + } + + if (lex.find(x => x.token === 'LPAREN')) { + throw new errors.IncorrectUsageError({ + message: 'The filter can\'t contain parenthesis.' + }); + } + + const jsonFilter = nql(filter).toJSON(); + const keys = Object.keys(jsonFilter); + + if (keys.length === 1 && keys[0] === '$or') { + throw new errors.IncorrectUsageError({ + message: 'The top level-filters can only combined with ANDs (+) and not ORs (,).' + }); + } + + // The filter is validated, it only contains one level of filters concatenated with `+` + const filters = filter.split('+'); + + /** @type {Object.} */ + let result = {}; + + for (const f of filters) { + // dirty way to parse a property, but it works according to https://github.com/NexesJS/NQL-Lang/blob/0e12d799a3a9c4d8651444e9284ce16c19cbc4f0/src/nql.l#L18 + const key = f.split(':')[0]; + if (!result[key]) { + result[key] = f; + } else { + result[key] += '+' + f; + } + } + + return result; + } + async getEventTimeline(options = {}) { if (!options.limit) { options.limit = 10; @@ -240,10 +298,6 @@ module.exports = class EventRepository { return allEvents.sort((a, b) => { return new Date(b.data.created_at) - new Date(a.data.created_at); }).reduce((memo, event, i) => { - if (options.filter && !nql(options.filter).queryJSON(event)) { - // Filter out event - return memo; - } if (event.type === 'newsletter_event' && event.data.subscribed) { const previousEvent = allEvents[i - 1]; const nextEvent = allEvents[i + 1]; diff --git a/ghost/members-api/test/unit/lib/repositories/event.test.js b/ghost/members-api/test/unit/lib/repositories/event.test.js new file mode 100644 index 0000000000..d1a70e8463 --- /dev/null +++ b/ghost/members-api/test/unit/lib/repositories/event.test.js @@ -0,0 +1,78 @@ +const should = require('should'); +const EventRepository = require('../../../../lib/repositories/event'); +const errors = require('@tryghost/errors'); + +describe('EventRepository', function () { + describe('getNQLSubset', function () { + let eventRepository; + + before(function () { + eventRepository = new EventRepository({ + EmailRecipient: null, + MemberSubscribeEvent: null, + MemberPaymentEvent: null, + MemberStatusEvent: null, + MemberLoginEvent: null, + MemberPaidSubscriptionEvent: null, + labsService: null + }); + }); + + it('throws when processing a filter with parenthesis', function () { + should.throws(() => { + eventRepository.getNQLSubset('(type:1)'); + }, errors.IncorrectUsageError); + should.throws(() => { + eventRepository.getNQLSubset('type:1+(data.created_at:1+data.member_id:1)'); + }, errors.IncorrectUsageError); + }); + + it('throws when using properties that aren\'t in the allowlist', function () { + should.throws(() => { + eventRepository.getNQLSubset('(types:1)'); + }, errors.IncorrectUsageError); + }); + + it('throws when using an OR', function () { + should.throws(() => { + eventRepository.getNQLSubset('type:1,data.created_at:1'); + }, errors.IncorrectUsageError); + + should.throws(() => { + eventRepository.getNQLSubset('type:1+data.created_at:1,data.member_id:1'); + }, errors.IncorrectUsageError); + + should.throws(() => { + eventRepository.getNQLSubset('type:1,data.created_at:1+data.member_id:1'); + }, errors.IncorrectUsageError); + }); + + it('passes when using it correctly with one filter', function () { + const res = eventRepository.getNQLSubset('type:email_delivered_event'); + res.should.be.an.Object(); + res.should.deepEqual({ + type: 'type:email_delivered_event' + }); + }); + + it('passes when using it correctly with multiple filters', function () { + const res = eventRepository.getNQLSubset('type:-[email_delivered_event,email_opened_event,email_failed_event]+data.created_at:<0+data.member_id:123'); + res.should.be.an.Object(); + res.should.deepEqual({ + 'data.created_at': 'data.created_at:<0', + 'data.member_id': 'data.member_id:123', + type: 'type:-[email_delivered_event,email_opened_event,email_failed_event]' + }); + }); + + it('passes when using it correctly with multiple filters used several times', function () { + const res = eventRepository.getNQLSubset('type:-email_delivered_event+data.created_at:<0+data.member_id:123+type:-[email_opened_event,email_failed_event]+data.created_at:>10'); + res.should.be.an.Object(); + res.should.deepEqual({ + 'data.created_at': 'data.created_at:<0+data.created_at:>10', + 'data.member_id': 'data.member_id:123', + type: 'type:-email_delivered_event+type:-[email_opened_event,email_failed_event]' + }); + }); + }); +});