require('./utils'); const sinon = require('sinon'); const scope = require('../lib/scope'); const {parse} = require('../'); describe('Parser', function () { var parserError = /^Query Error: unexpected character in filter at char/; describe('Simple Expressions', function () { it('should parse simple id & value combos', function () { parse('id:3').should.eql({id: 3}); parse('slug:getting-started').should.eql({slug: 'getting-started'}); }); }); describe('Comparison Query Operators', function () { it('can parse standard equals', function () { parse('count:5').should.eql({count: 5}); parse('tag:getting-started').should.eql({tag: 'getting-started'}); parse('author:\'Joe Bloggs\'').should.eql({author: 'Joe Bloggs'}); parse('author:123-test').should.eql({author: '123-test'}); }); it('can parse not equals', function () { parse('count:-5').should.eql({count: {$ne: 5}}); parse('tag:-getting-started').should.eql({tag: {$ne: 'getting-started'}}); parse('author:-\'Joe Bloggs\'').should.eql({author: {$ne: 'Joe Bloggs'}}); }); it('can parse greater than', function () { parse('count:>5').should.eql({count: {$gt: 5}}); parse('tag:>getting-started').should.eql({tag: {$gt: 'getting-started'}}); parse('author:>\'Joe Bloggs\'').should.eql({author: {$gt: 'Joe Bloggs'}}); }); it('can parse less than', function () { parse('count:<5').should.eql({count: {$lt: 5}}); parse('tag:=5').should.eql({count: {$gte: 5}}); parse('tag:>=getting-started').should.eql({tag: {$gte: 'getting-started'}}); parse('author:>=\'Joe Bloggs\'').should.eql({author: {$gte: 'Joe Bloggs'}}); }); it('can parse less than or equals', function () { parse('count:<=5').should.eql({count: {$lte: 5}}); parse('tag:<=getting-started').should.eql({tag: {$lte: 'getting-started'}}); parse('author:<=\'Joe Bloggs\'').should.eql({author: {$lte: 'Joe Bloggs'}}); }); it('can parse IN with single value', function () { parse('count:[5]').should.eql({count: {$in: [5]}}); parse('tag:[getting-started]').should.eql({tag: {$in: ['getting-started']}}); parse('author:[\'Joe Bloggs\']').should.eql({author: {$in: ['Joe Bloggs']}}); }); it('can parse NOT IN with single value', function () { parse('count:-[5]').should.eql({count: {$nin: [5]}}); parse('tag:-[getting-started]').should.eql({tag: {$nin: ['getting-started']}}); parse('author:-[\'Joe Bloggs\']').should.eql({author: {$nin: ['Joe Bloggs']}}); }); it('can parse IN with multiple values', function () { parse('count:[5, 8, 12]').should.eql({count: {$in: [5, 8, 12]}}); parse('tag:[getting-started, ghost, really-long-1]') .should.eql({tag: {$in: ['getting-started', 'ghost', 'really-long-1']}}); parse('author:[\'Joe Bloggs\', \'John O\\\'Nolan\', \'Hello World\']') .should.eql({author: {$in: ['Joe Bloggs', 'John O\'Nolan', 'Hello World']}}); }); it('can parse NOT IN with multiple values', function () { parse('count:-[5, 8, 12]').should.eql({count: {$nin: [5, 8, 12]}}); parse('tag:-[getting-started, ghost, really-long-1]') .should.eql({tag: {$nin: ['getting-started', 'ghost', 'really-long-1']}}); parse('author:-[\'Joe Bloggs\', \'John O\\\'Nolan\', \'Hello World\']') .should.eql({author: {$nin: ['Joe Bloggs', 'John O\'Nolan', 'Hello World']}}); }); it('can parse CONTAINS, STARTSWITH and ENDSWITH with and without NOT', function () { parse(`email:~'gmail.com'`).should.eql({email: {$regex: /gmail\.com/i}}); parse(`email:-~'gmail.com'`).should.eql({email: {$not: /gmail\.com/i}}); parse(`email:~^'gmail.com'`).should.eql({email: {$regex: /^gmail\.com/i}}); parse(`email:-~^'gmail.com'`).should.eql({email: {$not: /^gmail\.com/i}}); parse(`email:~$'gmail.com'`).should.eql({email: {$regex: /gmail\.com$/i}}); parse(`email:-~$'gmail.com'`).should.eql({email: {$not: /gmail\.com$/i}}); }); it('can parse CONTAINS, STARTSWITH and ENDSWITH and handle regexchars', function () { parse(`email:~'.*+?^$(){}|[]\\'`).should.eql({email: {$regex: /\.\*\+\?\^\$\(\)\{\}\|\[\]\\/i}}); }); it('can parse CONTAINS, STARTSWITH and ENDSWITH and handle quotes', function () { parse(`name:~'john o\\'nolan'`).should.eql({name: {$regex: /john o'nolan/i}}); parse(`name:~'john o\\"nolan'`).should.eql({name: {$regex: /john o"nolan/i}}); // TODO: Fix this BUG (function () { parse(`name:~'john o"nolan'`); }).should.throw(); // This will probably never be possible (function () { parse(`name:~'john o'nolan'`); }).should.throw(); }); }); describe('Values', function () { it('can parse null', function () { parse('image:null').should.eql({image: null}); }); it('can parse NOT null', function () { parse('image:-null').should.eql({image: {$ne: null}}); }); it('can parse true', function () { parse('featured:true').should.eql({featured: true}); }); it('can parse NOT true', function () { parse('featured:-true').should.eql({featured: {$ne: true}}); }); it('can parse false', function () { parse('featured:false').should.eql({featured: false}); }); it('can parse NOT false', function () { parse('featured:-false').should.eql({featured: {$ne: false}}); }); it('can parse a Number', function () { parse('count:5').should.eql({count: 5}); }); it('can parse NOT a Number', function () { parse('count:-5').should.eql({count: {$ne: 5}}); }); }); describe('Logical Query Operators', function () { // @TODO support implicit and? it('$and', function () { parse('page:false+status:published') .should.eql({$and: [{page: false}, {status: 'published'}]}); }); // @TODO or with same property is in? it('$or', function () { parse('page:true,featured:true') .should.eql({$or: [{page: true}, {featured: true}]}); parse('page:true,page:false') .should.eql({$or: [{page: true}, {page: false}]}); }); }); describe('Grouping', function () { it('ungroups top level group', function () { parse('(status:published)').should.eql({status: 'published'}); }); it('ungroups top level group with logical query operator', function () { parse('(page:false+status:published)') .should.eql({$and: [{page: false}, {status: 'published'}]}); }); it('ungroups top level group with nested groups', function () { parse('(page:false,(status:published+featured:true))') .should.eql({ $or: [ {page: false}, { $and: [ {status: 'published'}, {featured: true} ] } ] }); }); }); describe('complex examples', function () { it('Many expressions', function () { parse('tag:photo+featured:true,tag.count:>5').should.eql({ $or: [ {$and: [{tag: 'photo'}, {featured: true}]}, {'tag.count': {$gt: 5}} ] }); parse('tag:photo+image:-null,tag.count:>5').should.eql({ $or: [ {$and: [{tag: 'photo'}, {image: {$ne: null}}]}, {'tag.count': {$gt: 5}} ] }); }); describe('Logical Groups', function () { describe('$or', function () { it('ungrouped version', function () { parse('author:-joe,tag:photo,image:-null,featured:true').should.eql( {$or: [ {author: {$ne: 'joe'}}, {tag: 'photo'}, {image: {$ne: null}}, {featured: true} ]} ); }); it('RIGHT grouped version', function () { parse('author:-joe,(tag:photo,image:-null,featured:true)').should.eql( {$or: [ {author: {$ne: 'joe'}}, {$or: [ {tag: 'photo'}, {image: {$ne: null}}, {featured: true} ]} ]} ); }); it('LEFT grouped version', function () { parse('(tag:photo,image:-null,featured:true),author:-joe').should.eql( {$or: [ {$or: [ {tag: 'photo'}, {image: {$ne: null}}, {featured: true} ]}, {author: {$ne: 'joe'}} ]} ); }); }); describe('$and', function () { it('ungrouped version', function () { parse('author:-joe+tag:photo+image:-null+featured:true').should.eql( {$and: [ {author: {$ne: 'joe'}}, {tag: 'photo'}, {image: {$ne: null}}, {featured: true} ]} ); }); it('RIGHT grouped version', function () { parse('author:-joe+(tag:photo+image:-null+featured:true)').should.eql( {$and: [ {author: {$ne: 'joe'}}, {$and: [ {tag: 'photo'}, {image: {$ne: null}}, {featured: true} ]} ]} ); }); it('LEFT grouped version', function () { parse('(tag:photo+image:-null+featured:true)+author:-joe').should.eql( {$and: [ {$and: [ {tag: 'photo'}, {image: {$ne: null}}, {featured: true} ]}, {author: {$ne: 'joe'}} ]} ); }); }); describe('$or with $and group', function () { it('ungrouped version', function () { parse('author:-joe,tag:photo+image:-null+featured:true').should.eql( {$or: [ {author: {$ne: 'joe'}}, {$and: [ {tag: 'photo'}, {image: {$ne: null}}, {featured: true} ]} ]} ); }); it('RIGHT grouped version', function () { parse('author:-joe,(tag:photo+image:-null+featured:true)').should.eql( {$or: [ {author: {$ne: 'joe'}}, {$and: [ {tag: 'photo'}, {image: {$ne: null}}, {featured: true} ]} ]} ); }); it('LEFT grouped version', function () { parse('(tag:photo+image:-null+featured:true),author:-joe').should.eql( {$or: [ {$and: [ {tag: 'photo'}, {image: {$ne: null}}, {featured: true} ]}, {author: {$ne: 'joe'}} ]} ); }); }); describe('$and with $or group', function () { it('ungrouped version', function () { parse('author:-joe+tag:photo,image:-null,featured:true').should.eql( {$or: [ {$and: [ {author: {$ne: 'joe'}}, {tag: 'photo'} ]}, {image: {$ne: null}}, {featured: true} ]} ); }); it('RIGHT grouped version', function () { parse('author:-joe+(tag:photo,image:-null,featured:true)').should.eql( {$and: [ {author: {$ne: 'joe'}}, {$or: [ {tag: 'photo'}, {image: {$ne: null}}, {featured: true} ]} ]} ); }); it('LEFT grouped version', function () { parse('(tag:photo,image:-null,featured:true)+author:-joe').should.eql( {$and: [ {$or: [ {tag: 'photo'}, {image: {$ne: null}}, {featured: true} ]}, {author: {$ne: 'joe'}} ]} ); }); }); describe('combo', function () { it('RIGHT grouped version', function () { parse( 'name:sample,(name:-sample+created_at:<=\'2016-03-03\'),(name:-sample+(created_at:\'2016-03-03\',created_at:\'2016-03-04\'))' ).should.eql( {$or: [ {name: 'sample'}, {$and: [ {name: {$ne: 'sample'}}, {created_at: {$lte: '2016-03-03'}} ]}, {$and: [ {name: {$ne: 'sample'}}, {$or: [ {created_at: '2016-03-03'}, {created_at: '2016-03-04'} ]} ]} ]} ); }); it('LEFT grouped version', function () { parse( '(name:-sample,(created_at:\'2016-03-03\',created_at:\'2016-03-04\')),(name:-sample+created_at:<=\'2016-03-03\'),name:sample' ).should.eql( {$or: [ {$or: [ {name: {$ne: 'sample'}}, {$or: [ {created_at: '2016-03-03'}, {created_at: '2016-03-04'} ]} ]}, {$and: [ {name: {$ne: 'sample'}}, {created_at: {$lte: '2016-03-03'}} ]}, {name: 'sample'} ]} ); }); }); }); it('in expressions', function () { parse('author:-joe+tag:[photo,video]').should.eql( {$and: [ {author: {$ne: 'joe'}}, {tag: {$in: ['photo', 'video']}} ]} ); parse('author:-joe+tag:-[photo,video,audio]').should.eql( {$and: [ {author: {$ne: 'joe'}}, {tag: {$nin: ['photo', 'video', 'audio']}} ]} ); parse('author:-joe+tag:[photo,video,magic,\'audio\']+post.count:>5+post.count:<100').should.eql( {$and: [ {author: {$ne: 'joe'}}, {tag: {$in: ['photo', 'video', 'magic', 'audio']}}, {'post.count': {$gt: 5}}, {'post.count': {$lt: 100}} ]} ); }); }); describe('whitespace rules', function () { it('will ignore whitespace in expressions', function () { parse('count: -5').should.eql(parse('count:-5')); parse('author: -joe + tag: [photo, video]').should.eql(parse('author:-joe+tag:[photo,video]')); }); it('will not ignore whitespace in Strings', function () { parse('author:\'Hello World\'').should.not.eql(parse('author:\'HelloWorld\'')); }); }); describe('invalid expressions', function () { it('CANNOT parse characters outside of a STRING value', function () { (function () { parse('tag:\'My Tag\'-'); }).should.throw(parserError); }); it('CANNOT parse property - operator - value in wrong order', function () { (function () { parse('\'My Tag\':tag'); }).should.throw(parserError); (function () { parse('5>:tag'); }).should.throw(parserError); }); it('CANNOT parse combination without filter expression', function () { (function () { parse('count:3+'); }).should.throw(parserError); (function () { parse(',count:3'); }).should.throw(parserError); }); it('CANNOT parse incomplete group', function () { (function () { parse('id:5,(count:3'); }).should.throw(parserError); (function () { parse('count:3)'); }).should.throw(parserError); (function () { parse('id:5(count:3)'); }).should.throw(parserError); }); it('CANNOT parse invalid IN expression', function () { (function () { parse('id:[test+ing]'); }).should.throw(parserError); (function () { parse('id:[test'); }).should.throw(parserError); (function () { parse('id:test,ing]'); }).should.throw(parserError); }); }); describe('Aliases', function () { it('can handle empty aliases', function () { parse('tag:getting-started', {aliases: {}}) .should.eql({tag: 'getting-started'}); }); it('can expand a field alias', function () { parse('tag:getting-started', {aliases: {tag: 'tags.slug'}}) .should.eql({'tags.slug': 'getting-started'}); }); it('can expand multiple field aliases', function () { parse('tag:getting-started+author:joe', {aliases: {tag: 'tags.slug', author: 'authors.slug'}}) .should.eql({$and: [{'tags.slug': 'getting-started'}, {'authors.slug': 'joe'}]}); }); }); describe('Relative dates', function () { describe('relDateToAbsolute is called correctly', function () { let relDateToAbsoluteStub; beforeEach(function () { relDateToAbsoluteStub = sinon.stub(scope, 'relDateToAbsolute'); }); afterEach(function () { sinon.restore(); }); it('last_seen_at:>=now-2d', function () { parse('last_seen_at:>=now-2d'); sinon.assert.calledWithExactly(relDateToAbsoluteStub, 'sub', '2', 'd'); }); it('last_seen_at:>=now+365d', function () { parse('last_seen_at:<=now+365d'); sinon.assert.calledWithExactly(relDateToAbsoluteStub, 'add', '365', 'd'); }); it('last_seen_at:>=now-12w', function () { parse('last_seen_at:>=now-12w'); sinon.assert.calledWithExactly(relDateToAbsoluteStub, 'sub', '12', 'w'); }); it('last_seen_at:>=now+2M', function () { parse('last_seen_at:<=now+2M'); sinon.assert.calledWithExactly(relDateToAbsoluteStub, 'add', '2', 'M'); }); it('last_seen_at:>=now-2y', function () { parse('last_seen_at:>=now-2y'); sinon.assert.calledWithExactly(relDateToAbsoluteStub, 'sub', '2', 'y'); }); it('last_seen_at:>=now+2h', function () { parse('last_seen_at:<=now+2h'); sinon.assert.calledWithExactly(relDateToAbsoluteStub, 'add', '2', 'h'); }); it('last_seen_at:>=now+2m', function () { parse('last_seen_at:<=now+2m'); sinon.assert.calledWithExactly(relDateToAbsoluteStub, 'add', '2', 'm'); }); it('last_seen_at:>=now+2s', function () { parse('last_seen_at:<=now+2s'); sinon.assert.calledWithExactly(relDateToAbsoluteStub, 'add', '2', 's'); }); }); describe('call to relDateToAbsolute results in the correct date format', function () { let relDateToAbsoluteSpy; beforeEach(function () { // spy lets requests through relDateToAbsoluteSpy = sinon.spy(scope, 'relDateToAbsolute'); }); afterEach(function () { sinon.restore(); }); it('can expand a relative date into an absolute date', function () { // This test proves that the date is in the right format, but not that it is the right date const subRes = parse('last_seen_at:>=now-2d'); subRes.should.be.an.Object().with.property('last_seen_at'); subRes.last_seen_at.should.be.an.Object().with.property('$gte'); subRes.last_seen_at.$gte.should.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); const addRes = parse('last_seen_at:<=now+2d'); addRes.should.be.an.Object().with.property('last_seen_at'); addRes.last_seen_at.should.be.an.Object().with.property('$lte'); addRes.last_seen_at.$lte.should.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); sinon.assert.calledTwice(relDateToAbsoluteSpy); }); }); }); });