diff --git a/spec/.eslintrc.json b/spec/.eslintrc.json index 32b0a12f32..007ccf2390 100644 --- a/spec/.eslintrc.json +++ b/spec/.eslintrc.json @@ -14,6 +14,7 @@ "Container": true, "equal": true, "notEqual": true, + "it_only_db": true, "it_exclude_dbs": true, "describe_only_db": true, "describe_only": true, diff --git a/spec/MongoTransform.spec.js b/spec/MongoTransform.spec.js index 4c99f658f2..73c179d79f 100644 --- a/spec/MongoTransform.spec.js +++ b/spec/MongoTransform.spec.js @@ -347,3 +347,118 @@ describe('transformUpdate', () => { done(); }); }); + +describe('transformConstraint', () => { + describe('$relativeTime', () => { + it('should error on $eq, $ne, and $exists', () => { + expect(() => { + transform.transformConstraint({ + $eq: { + ttl: { + $relativeTime: '12 days ago', + } + } + }); + }).toThrow(); + + expect(() => { + transform.transformConstraint({ + $ne: { + ttl: { + $relativeTime: '12 days ago', + } + } + }); + }).toThrow(); + + expect(() => { + transform.transformConstraint({ + $exists: { + $relativeTime: '12 days ago', + } + }); + }).toThrow(); + }); + }) +}); + +describe('relativeTimeToDate', () => { + const now = new Date('2017-09-26T13:28:16.617Z'); + + describe('In the future', () => { + it('should parse valid natural time', () => { + const text = 'in 12 days 10 hours 24 minutes 30 seconds'; + const { result, status, info } = transform.relativeTimeToDate(text, now); + expect(result.toISOString()).toBe('2017-10-08T23:52:46.617Z'); + expect(status).toBe('success'); + expect(info).toBe('future'); + }); + }); + + describe('In the past', () => { + it('should parse valid natural time', () => { + const text = '2 days 12 hours 1 minute 12 seconds ago'; + const { result, status, info } = transform.relativeTimeToDate(text, now); + expect(result.toISOString()).toBe('2017-09-24T01:27:04.617Z'); + expect(status).toBe('success'); + expect(info).toBe('past'); + }); + }); + + describe('Error cases', () => { + it('should error if string is completely gibberish', () => { + expect(transform.relativeTimeToDate('gibberishasdnklasdnjklasndkl123j123')).toEqual({ + status: 'error', + info: "Time should either start with 'in' or end with 'ago'", + }); + }); + + it('should error if string contains neither `ago` nor `in`', () => { + expect(transform.relativeTimeToDate('12 hours 1 minute')).toEqual({ + status: 'error', + info: "Time should either start with 'in' or end with 'ago'", + }); + }); + + it('should error if there are missing units or numbers', () => { + expect(transform.relativeTimeToDate('in 12 hours 1')).toEqual({ + status: 'error', + info: 'Invalid time string. Dangling unit or number.', + }); + + expect(transform.relativeTimeToDate('12 hours minute ago')).toEqual({ + status: 'error', + info: 'Invalid time string. Dangling unit or number.', + }); + }); + + it('should error on floating point numbers', () => { + expect(transform.relativeTimeToDate('in 12.3 hours')).toEqual({ + status: 'error', + info: "'12.3' is not an integer.", + }); + }); + + it('should error if numbers are invalid', () => { + expect(transform.relativeTimeToDate('12 hours 123a minute ago')).toEqual({ + status: 'error', + info: "'123a' is not an integer.", + }); + }); + + it('should error on invalid interval units', () => { + expect(transform.relativeTimeToDate('4 score 7 years ago')).toEqual({ + status: 'error', + info: "Invalid interval: 'score'", + }); + }); + + it("should error when string contains 'ago' and 'in'", () => { + expect(transform.relativeTimeToDate('in 1 day 2 minutes ago')).toEqual({ + status: 'error', + info: "Time cannot have both 'in' and 'ago'", + }); + }); + }); +}); + diff --git a/spec/ParseQuery.spec.js b/spec/ParseQuery.spec.js index eb82afab60..15884a1cd1 100644 --- a/spec/ParseQuery.spec.js +++ b/spec/ParseQuery.spec.js @@ -3105,7 +3105,80 @@ describe('Parse.Query testing', () => { equal(result.has('testPointerField'), result.get('shouldBe')); }); done(); - } - ).catch(done.fail); + }).catch(done.fail); + }); + + it_only_db('mongo')('should handle relative times correctly', function(done) { + const now = Date.now(); + const obj1 = new Parse.Object('MyCustomObject', { + name: 'obj1', + ttl: new Date(now + 2 * 24 * 60 * 60 * 1000), // 2 days from now + }); + const obj2 = new Parse.Object('MyCustomObject', { + name: 'obj2', + ttl: new Date(now - 2 * 24 * 60 * 60 * 1000), // 2 days ago + }); + + Parse.Object.saveAll([obj1, obj2]) + .then(() => { + const q = new Parse.Query('MyCustomObject'); + q.greaterThan('ttl', { $relativeTime: 'in 1 day' }); + return q.find({ useMasterKey: true }); + }) + .then((results) => { + expect(results.length).toBe(1); + }) + .then(() => { + const q = new Parse.Query('MyCustomObject'); + q.greaterThan('ttl', { $relativeTime: '1 day ago' }); + return q.find({ useMasterKey: true }); + }) + .then((results) => { + expect(results.length).toBe(1); + }) + .then(() => { + const q = new Parse.Query('MyCustomObject'); + q.lessThan('ttl', { $relativeTime: '5 days ago' }); + return q.find({ useMasterKey: true }); + }) + .then((results) => { + expect(results.length).toBe(0); + }) + .then(() => { + const q = new Parse.Query('MyCustomObject'); + q.greaterThan('ttl', { $relativeTime: '3 days ago' }); + return q.find({ useMasterKey: true }); + }) + .then((results) => { + expect(results.length).toBe(2); + }) + .then(done, done.fail); + }); + + it_only_db('mongo')('should error on invalid relative time', function(done) { + const obj1 = new Parse.Object('MyCustomObject', { + name: 'obj1', + ttl: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // 2 days from now + }); + + const q = new Parse.Query('MyCustomObject'); + q.greaterThan('ttl', { $relativeTime: '-12 bananas ago' }); + obj1.save({ useMasterKey: true }) + .then(() => q.find({ useMasterKey: true })) + .then(done.fail, done); + }); + + it_only_db('mongo')('should error when using $relativeTime on non-Date field', function(done) { + const obj1 = new Parse.Object('MyCustomObject', { + name: 'obj1', + nonDateField: 'abcd', + ttl: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // 2 days from now + }); + + const q = new Parse.Query('MyCustomObject'); + q.greaterThan('nonDateField', { $relativeTime: '1 day ago' }); + obj1.save({ useMasterKey: true }) + .then(() => q.find({ useMasterKey: true })) + .then(done.fail, done); }); }); diff --git a/spec/helper.js b/spec/helper.js index 98182cfa0d..109f1b0716 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -409,6 +409,14 @@ global.it_exclude_dbs = excluded => { } } +global.it_only_db = db => { + if (process.env.PARSE_SERVER_TEST_DB === db) { + return it; + } else { + return xit; + } +}; + global.fit_exclude_dbs = excluded => { if (excluded.indexOf(process.env.PARSE_SERVER_TEST_DB) >= 0) { return xit; diff --git a/src/Adapters/Storage/Mongo/MongoTransform.js b/src/Adapters/Storage/Mongo/MongoTransform.js index d745668c80..9c5c525621 100644 --- a/src/Adapters/Storage/Mongo/MongoTransform.js +++ b/src/Adapters/Storage/Mongo/MongoTransform.js @@ -533,6 +533,109 @@ function transformTopLevelAtom(atom, field) { } } +function relativeTimeToDate(text, now = new Date()) { + text = text.toLowerCase(); + + let parts = text.split(' '); + + // Filter out whitespace + parts = parts.filter((part) => part !== ''); + + const future = parts[0] === 'in'; + const past = parts[parts.length - 1] === 'ago'; + + if (!future && !past) { + return { status: 'error', info: "Time should either start with 'in' or end with 'ago'" }; + } + + if (future && past) { + return { + status: 'error', + info: "Time cannot have both 'in' and 'ago'", + }; + } + + // strip the 'ago' or 'in' + if (future) { + parts = parts.slice(1); + } else { // past + parts = parts.slice(0, parts.length - 1); + } + + if (parts.length % 2 !== 0) { + return { + status: 'error', + info: 'Invalid time string. Dangling unit or number.', + }; + } + + const pairs = []; + while(parts.length) { + pairs.push([ parts.shift(), parts.shift() ]); + } + + let seconds = 0; + for (const [num, interval] of pairs) { + const val = Number(num); + if (!Number.isInteger(val)) { + return { + status: 'error', + info: `'${num}' is not an integer.`, + }; + } + + switch(interval) { + case 'day': + case 'days': + seconds += val * 86400; // 24 * 60 * 60 + break; + + case 'hr': + case 'hrs': + case 'hour': + case 'hours': + seconds += val * 3600; // 60 * 60 + break; + + case 'min': + case 'mins': + case 'minute': + case 'minutes': + seconds += val * 60; + break; + + case 'sec': + case 'secs': + case 'second': + case 'seconds': + seconds += val; + break; + + default: + return { + status: 'error', + info: `Invalid interval: '${interval}'`, + }; + } + } + + const milliseconds = seconds * 1000; + if (future) { + return { + status: 'success', + info: 'future', + result: new Date(now.valueOf() + milliseconds) + }; + } + if (past) { + return { + status: 'success', + info: 'past', + result: new Date(now.valueOf() - milliseconds) + }; + } +} + // Transforms a query constraint from REST API format to Mongo format. // A constraint is something with fields like $lt. // If it is not a valid constraint but it could be a valid something @@ -565,9 +668,33 @@ function transformConstraint(constraint, field) { case '$gte': case '$exists': case '$ne': - case '$eq': - answer[key] = transformer(constraint[key]); + case '$eq': { + const val = constraint[key]; + if (val && typeof val === 'object' && val.$relativeTime) { + if (field && field.type !== 'Date') { + throw new Parse.Error(Parse.Error.INVALID_JSON, '$relativeTime can only be used with Date field'); + } + + switch (key) { + case '$exists': + case '$ne': + case '$eq': + throw new Parse.Error(Parse.Error.INVALID_JSON, '$relativeTime can only be used with the $lt, $lte, $gt, and $gte operators'); + } + + const parserResult = relativeTimeToDate(val.$relativeTime); + if (parserResult.status === 'success') { + answer[key] = parserResult.result; + break; + } + + log.info('Error while parsing relative date', parserResult); + throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $relativeTime (${key}) value. ${parserResult.info}`); + } + + answer[key] = transformer(val); break; + } case '$in': case '$nin': { @@ -1196,4 +1323,6 @@ module.exports = { transformUpdate, transformWhere, mongoObjectToParseObject, + relativeTimeToDate, + transformConstraint, };