diff --git a/spec/MongoTransform.spec.js b/spec/MongoTransform.spec.js index 6834a6b28d..60adb4b7f0 100644 --- a/spec/MongoTransform.spec.js +++ b/spec/MongoTransform.spec.js @@ -4,6 +4,7 @@ const transform = require('../lib/Adapters/Storage/Mongo/MongoTransform'); const dd = require('deep-diff'); const mongodb = require('mongodb'); +const Utils = require('../lib/Utils'); describe('parseObjectToMongoObjectForCreate', () => { it('a basic number', done => { @@ -592,7 +593,7 @@ describe('relativeTimeToDate', () => { describe('In the future', () => { it('should parse valid natural time', () => { const text = 'in 1 year 2 weeks 12 days 10 hours 24 minutes 30 seconds'; - const { result, status, info } = transform.relativeTimeToDate(text, now); + const { result, status, info } = Utils.relativeTimeToDate(text, now); expect(result.toISOString()).toBe('2018-10-22T23:52:46.617Z'); expect(status).toBe('success'); expect(info).toBe('future'); @@ -602,7 +603,7 @@ describe('relativeTimeToDate', () => { 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); + const { result, status, info } = Utils.relativeTimeToDate(text, now); expect(result.toISOString()).toBe('2017-09-24T01:27:04.617Z'); expect(status).toBe('success'); expect(info).toBe('past'); @@ -612,7 +613,7 @@ describe('relativeTimeToDate', () => { describe('From now', () => { it('should equal current time', () => { const text = 'now'; - const { result, status, info } = transform.relativeTimeToDate(text, now); + const { result, status, info } = Utils.relativeTimeToDate(text, now); expect(result.toISOString()).toBe('2017-09-26T13:28:16.617Z'); expect(status).toBe('success'); expect(info).toBe('present'); @@ -621,54 +622,54 @@ describe('relativeTimeToDate', () => { describe('Error cases', () => { it('should error if string is completely gibberish', () => { - expect(transform.relativeTimeToDate('gibberishasdnklasdnjklasndkl123j123')).toEqual({ + expect(Utils.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({ + expect(Utils.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({ + expect(Utils.relativeTimeToDate('in 12 hours 1')).toEqual({ status: 'error', info: 'Invalid time string. Dangling unit or number.', }); - expect(transform.relativeTimeToDate('12 hours minute ago')).toEqual({ + expect(Utils.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({ + expect(Utils.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({ + expect(Utils.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({ + expect(Utils.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({ + expect(Utils.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 bf870c92b8..6de0957999 100644 --- a/spec/ParseQuery.spec.js +++ b/spec/ParseQuery.spec.js @@ -4766,7 +4766,7 @@ describe('Parse.Query testing', () => { .catch(done.fail); }); - it_only_db('mongo')('should handle relative times correctly', function (done) { + it('should handle relative times correctly', async () => { const now = Date.now(); const obj1 = new Parse.Object('MyCustomObject', { name: 'obj1', @@ -4777,94 +4777,75 @@ describe('Parse.Query testing', () => { 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(() => { - const q = new Parse.Query('MyCustomObject'); - q.greaterThan('ttl', { $relativeTime: 'now' }); - return q.find({ useMasterKey: true }); - }) - .then(results => { - expect(results.length).toBe(1); - }) - .then(() => { - const q = new Parse.Query('MyCustomObject'); - q.greaterThan('ttl', { $relativeTime: 'now' }); - q.lessThan('ttl', { $relativeTime: 'in 1 day' }); - return q.find({ useMasterKey: true }); - }) - .then(results => { - expect(results.length).toBe(0); - }) - .then(() => { - const q = new Parse.Query('MyCustomObject'); - q.greaterThan('ttl', { $relativeTime: '1 year 3 weeks ago' }); - return q.find({ useMasterKey: true }); - }) - .then(results => { - expect(results.length).toBe(2); - }) - .then(done, done.fail); + await Parse.Object.saveAll([obj1, obj2]) + const q1 = new Parse.Query('MyCustomObject'); + q1.greaterThan('ttl', { $relativeTime: 'in 1 day' }); + const results1 = await q1.find({ useMasterKey: true }); + expect(results1.length).toBe(1); + + const q2 = new Parse.Query('MyCustomObject'); + q2.greaterThan('ttl', { $relativeTime: '1 day ago' }); + const results2 = await q2.find({ useMasterKey: true }); + expect(results2.length).toBe(1); + + const q3 = new Parse.Query('MyCustomObject'); + q3.lessThan('ttl', { $relativeTime: '5 days ago' }); + const results3 = await q3.find({ useMasterKey: true }); + expect(results3.length).toBe(0); + + const q4 = new Parse.Query('MyCustomObject'); + q4.greaterThan('ttl', { $relativeTime: '3 days ago' }); + const results4 = await q4.find({ useMasterKey: true }); + expect(results4.length).toBe(2); + + const q5 = new Parse.Query('MyCustomObject'); + q5.greaterThan('ttl', { $relativeTime: 'now' }); + const results5 = await q5.find({ useMasterKey: true }); + expect(results5.length).toBe(1); + + const q6 = new Parse.Query('MyCustomObject'); + q6.greaterThan('ttl', { $relativeTime: 'now' }); + q6.lessThan('ttl', { $relativeTime: 'in 1 day' }); + const results6 = await q6.find({ useMasterKey: true }); + expect(results6.length).toBe(0); + + const q7 = new Parse.Query('MyCustomObject'); + q7.greaterThan('ttl', { $relativeTime: '1 year 3 weeks ago' }); + const results7 = await q7.find({ useMasterKey: true }); + expect(results7.length).toBe(2); }); - it_only_db('mongo')('should error on invalid relative time', function (done) { + it('should error on invalid relative time', async () => { const obj1 = new Parse.Object('MyCustomObject', { name: 'obj1', ttl: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // 2 days from now }); - + await obj1.save({ useMasterKey: true }); 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()); + try { + await q.find({ useMasterKey: true }); + fail("Should have thrown error"); + } catch(error) { + expect(error.code).toBe(Parse.Error.INVALID_JSON); + } }); - it_only_db('mongo')('should error when using $relativeTime on non-Date field', function (done) { + it('should error when using $relativeTime on non-Date field', async () => { const obj1 = new Parse.Object('MyCustomObject', { name: 'obj1', nonDateField: 'abcd', ttl: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // 2 days from now }); - + await obj1.save({ useMasterKey: true }); 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()); + try { + await q.find({ useMasterKey: true }); + fail("Should have thrown error"); + } catch(error) { + expect(error.code).toBe(Parse.Error.INVALID_JSON); + } }); it('should match complex structure with dot notation when using matchesKeyInQuery', function (done) { diff --git a/spec/PostgresStorageAdapter.spec.js b/spec/PostgresStorageAdapter.spec.js index b042206db2..dfb6bf4100 100644 --- a/spec/PostgresStorageAdapter.spec.js +++ b/spec/PostgresStorageAdapter.spec.js @@ -149,6 +149,135 @@ describe_only_db('postgres')('PostgresStorageAdapter', () => { await expectAsync(adapter.getClass('UnknownClass')).toBeRejectedWith(undefined); }); + it('$relativeTime should error on $eq', async () => { + const tableName = '_User'; + const schema = { + fields: { + objectId: { type: 'String' }, + username: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + authData: { type: 'Object' }, + }, + }; + const client = adapter._client; + await adapter.createTable(tableName, schema); + await client.none('INSERT INTO $1:name ($2:name, $3:name) VALUES ($4, $5)', [ + tableName, + 'objectId', + 'username', + 'Bugs', + 'Bunny', + ]); + const database = Config.get(Parse.applicationId).database; + await database.loadSchema({ clearCache: true }); + try { + await database.find( + tableName, + { + createdAt: { + $eq: { + $relativeTime: '12 days ago' + } + } + }, + { } + ); + fail("Should have thrown error"); + } catch(error) { + expect(error.code).toBe(Parse.Error.INVALID_JSON); + } + await dropTable(client, tableName); + }); + + it('$relativeTime should error on $ne', async () => { + const tableName = '_User'; + const schema = { + fields: { + objectId: { type: 'String' }, + username: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + authData: { type: 'Object' }, + }, + }; + const client = adapter._client; + await adapter.createTable(tableName, schema); + await client.none('INSERT INTO $1:name ($2:name, $3:name) VALUES ($4, $5)', [ + tableName, + 'objectId', + 'username', + 'Bugs', + 'Bunny', + ]); + const database = Config.get(Parse.applicationId).database; + await database.loadSchema({ clearCache: true }); + try { + await database.find( + tableName, + { + createdAt: { + $ne: { + $relativeTime: '12 days ago' + } + } + }, + { } + ); + fail("Should have thrown error"); + } catch(error) { + expect(error.code).toBe(Parse.Error.INVALID_JSON); + } + await dropTable(client, tableName); + }); + + it('$relativeTime should error on $exists', async () => { + const tableName = '_User'; + const schema = { + fields: { + objectId: { type: 'String' }, + username: { type: 'String' }, + email: { type: 'String' }, + emailVerified: { type: 'Boolean' }, + createdAt: { type: 'Date' }, + updatedAt: { type: 'Date' }, + authData: { type: 'Object' }, + }, + }; + const client = adapter._client; + await adapter.createTable(tableName, schema); + await client.none('INSERT INTO $1:name ($2:name, $3:name) VALUES ($4, $5)', [ + tableName, + 'objectId', + 'username', + 'Bugs', + 'Bunny', + ]); + const database = Config.get(Parse.applicationId).database; + await database.loadSchema({ clearCache: true }); + try { + await database.find( + tableName, + { + createdAt: { + $exists: { + $relativeTime: '12 days ago' + } + } + }, + { } + ); + fail("Should have thrown error"); + } catch(error) { + expect(error.code).toBe(Parse.Error.INVALID_JSON); + } + await dropTable(client, tableName); + }); + it('should use index for caseInsensitive query using Postgres', async () => { const tableName = '_User'; const schema = { diff --git a/src/Adapters/Storage/Mongo/MongoTransform.js b/src/Adapters/Storage/Mongo/MongoTransform.js index 5578077778..91ad23fa4a 100644 --- a/src/Adapters/Storage/Mongo/MongoTransform.js +++ b/src/Adapters/Storage/Mongo/MongoTransform.js @@ -2,6 +2,7 @@ import log from '../../../logger'; import _ from 'lodash'; var mongodb = require('mongodb'); var Parse = require('parse/node').Parse; +const Utils = require('../../../Utils'); const transformKey = (className, fieldName, schema) => { // Check if the schema is known since it's a built-in field. @@ -634,133 +635,6 @@ 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 && text !== 'now') { - 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 && text !== 'now') { - 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 'yr': - case 'yrs': - case 'year': - case 'years': - seconds += val * 31536000; // 365 * 24 * 60 * 60 - break; - - case 'wk': - case 'wks': - case 'week': - case 'weeks': - seconds += val * 604800; // 7 * 24 * 60 * 60 - break; - - case 'd': - 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), - }; - } else if (past) { - return { - status: 'success', - info: 'past', - result: new Date(now.valueOf() - milliseconds), - }; - } else { - return { - status: 'success', - info: 'present', - result: new Date(now.valueOf()), - }; - } -} - // 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 @@ -813,7 +687,7 @@ function transformConstraint(constraint, field, count = false) { ); } - const parserResult = relativeTimeToDate(val.$relativeTime); + const parserResult = Utils.relativeTimeToDate(val.$relativeTime); if (parserResult.status === 'success') { answer[key] = parserResult.result; break; @@ -1556,7 +1430,6 @@ module.exports = { transformUpdate, transformWhere, mongoObjectToParseObject, - relativeTimeToDate, transformConstraint, transformPointerString, }; diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index f787d9f1a9..de5712b79d 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -7,6 +7,9 @@ import _ from 'lodash'; // @flow-disable-next import { v4 as uuidv4 } from 'uuid'; import sql from './sql'; +import { StorageAdapter } from '../StorageAdapter'; +import type { SchemaType, QueryType, QueryOptions } from '../StorageAdapter'; +const Utils = require('../../../Utils'); const PostgresRelationDoesNotExistError = '42P01'; const PostgresDuplicateRelationError = '42P07'; @@ -22,9 +25,6 @@ const debug = function (...args: any) { log.debug.apply(log, args); }; -import { StorageAdapter } from '../StorageAdapter'; -import type { SchemaType, QueryType, QueryOptions } from '../StorageAdapter'; - const parseTypeToPostgresType = type => { switch (type.type) { case 'String': @@ -374,6 +374,11 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus patterns.push( `(${constraintFieldName} <> $${index} OR ${constraintFieldName} IS NULL)` ); + } else if (typeof fieldValue.$ne === 'object' && fieldValue.$ne.$relativeTime) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + '$relativeTime can only be used with the $lt, $lte, $gt, and $gte operators' + ); } else { patterns.push(`($${index}:name <> $${index + 1} OR $${index}:name IS NULL)`); } @@ -399,6 +404,11 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus if (fieldName.indexOf('.') >= 0) { values.push(fieldValue.$eq); patterns.push(`${transformDotField(fieldName)} = $${index++}`); + } else if (typeof fieldValue.$eq === 'object' && fieldValue.$eq.$relativeTime) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + '$relativeTime can only be used with the $lt, $lte, $gt, and $gte operators' + ); } else { values.push(fieldName, fieldValue.$eq); patterns.push(`$${index}:name = $${index + 1}`); @@ -513,7 +523,12 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus } if (typeof fieldValue.$exists !== 'undefined') { - if (fieldValue.$exists) { + if (typeof fieldValue.$exists === 'object' && fieldValue.$exists.$relativeTime) { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + '$relativeTime can only be used with the $lt, $lte, $gt, and $gte operators' + ); + } else if (fieldValue.$exists) { patterns.push(`$${index}:name IS NOT NULL`); } else { patterns.push(`$${index}:name IS NULL`); @@ -757,7 +772,7 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus Object.keys(ParseToPosgresComparator).forEach(cmp => { if (fieldValue[cmp] || fieldValue[cmp] === 0) { const pgComparator = ParseToPosgresComparator[cmp]; - const postgresValue = toPostgresValue(fieldValue[cmp]); + let postgresValue = toPostgresValue(fieldValue[cmp]); let constraintFieldName; if (fieldName.indexOf('.') >= 0) { let castType; @@ -775,6 +790,24 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus ? `CAST ((${transformDotField(fieldName)}) AS ${castType})` : transformDotField(fieldName); } else { + if (typeof postgresValue === 'object' && postgresValue.$relativeTime) { + if (schema.fields[fieldName].type !== 'Date') { + throw new Parse.Error( + Parse.Error.INVALID_JSON, + '$relativeTime can only be used with Date field' + ); + } + const parserResult = Utils.relativeTimeToDate(postgresValue.$relativeTime); + if (parserResult.status === 'success') { + postgresValue = toPostgresValue(parserResult.result); + } else { + console.error('Error while parsing relative date', parserResult); + throw new Parse.Error( + Parse.Error.INVALID_JSON, + `bad $relativeTime (${postgresValue.$relativeTime}) value. ${parserResult.info}` + ); + } + } constraintFieldName = `$${index++}:name`; values.push(fieldName); } diff --git a/src/Utils.js b/src/Utils.js index e78d7ddd6c..c96be08495 100644 --- a/src/Utils.js +++ b/src/Utils.js @@ -200,6 +200,138 @@ class Utils { } } } + + /** + * Computes the relative date based on a string. + * @param {String} text The string to interpret the date from. + * @param {Date} now The date the string is comparing against. + * @returns {Object} The relative date object. + **/ + static 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 && text !== 'now') { + 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 && text !== 'now') { + 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 'yr': + case 'yrs': + case 'year': + case 'years': + seconds += val * 31536000; // 365 * 24 * 60 * 60 + break; + + case 'wk': + case 'wks': + case 'week': + case 'weeks': + seconds += val * 604800; // 7 * 24 * 60 * 60 + break; + + case 'd': + 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), + }; + } else if (past) { + return { + status: 'success', + info: 'past', + result: new Date(now.valueOf() - milliseconds), + }; + } else { + return { + status: 'success', + info: 'present', + result: new Date(now.valueOf()), + }; + } + } } module.exports = Utils;