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
This commit is contained in:
Thibaut Patel 2022-01-24 18:53:10 +01:00
parent e4e28aae3d
commit 1370682a60
2 changed files with 136 additions and 4 deletions

View File

@ -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.<string, string>} */
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];

View File

@ -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]'
});
});
});
});