Skip to content

Commit 6f1fe89

Browse files
marvelmflovilmart
authored andcommitted
Relative time queries (#4289)
* Add relative time queries * Encode successful result * Add integration test * Add more error cases * Remove unnecessary new Date * Error when time has both 'in' and 'ago' * naturalTimeToDate -> relativeTimeToDate * Add $relativeTime operator * Throw error if $relativeTime is invalid * Add integration test for invalid relative time * Exclude $exists query * Only run integration tests on MongoDB * Add it_only_db test helper https://github.com/parse-community/parse-server/blame/bd2ea87c1d508efe337a1e8880443b1a52a8fb81/CONTRIBUTING.md#L23 * Handle where val might be null or undefined * Add integration test for multiple results * Lowercase text before processing * Always past if not future * Precompute seconds multiplication * Add shorthand for interval hr, hrs min, mins sec, secs * Throw error if $relativeTime is used with $exists, $ne, and $eq * Improve coverage for relativeTimeToDate * Add test for erroring on floating point units * Remove unnecessary dropDatabase function * Unit test $ne, $exists, $eq * Verify field type * Fix unit test for $exists Unnest query object
1 parent 1dd58b7 commit 6f1fe89

File tree

5 files changed

+330
-4
lines changed

5 files changed

+330
-4
lines changed

spec/.eslintrc.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"Container": true,
1515
"equal": true,
1616
"notEqual": true,
17+
"it_only_db": true,
1718
"it_exclude_dbs": true,
1819
"describe_only_db": true,
1920
"describe_only": true,

spec/MongoTransform.spec.js

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,3 +347,118 @@ describe('transformUpdate', () => {
347347
done();
348348
});
349349
});
350+
351+
describe('transformConstraint', () => {
352+
describe('$relativeTime', () => {
353+
it('should error on $eq, $ne, and $exists', () => {
354+
expect(() => {
355+
transform.transformConstraint({
356+
$eq: {
357+
ttl: {
358+
$relativeTime: '12 days ago',
359+
}
360+
}
361+
});
362+
}).toThrow();
363+
364+
expect(() => {
365+
transform.transformConstraint({
366+
$ne: {
367+
ttl: {
368+
$relativeTime: '12 days ago',
369+
}
370+
}
371+
});
372+
}).toThrow();
373+
374+
expect(() => {
375+
transform.transformConstraint({
376+
$exists: {
377+
$relativeTime: '12 days ago',
378+
}
379+
});
380+
}).toThrow();
381+
});
382+
})
383+
});
384+
385+
describe('relativeTimeToDate', () => {
386+
const now = new Date('2017-09-26T13:28:16.617Z');
387+
388+
describe('In the future', () => {
389+
it('should parse valid natural time', () => {
390+
const text = 'in 12 days 10 hours 24 minutes 30 seconds';
391+
const { result, status, info } = transform.relativeTimeToDate(text, now);
392+
expect(result.toISOString()).toBe('2017-10-08T23:52:46.617Z');
393+
expect(status).toBe('success');
394+
expect(info).toBe('future');
395+
});
396+
});
397+
398+
describe('In the past', () => {
399+
it('should parse valid natural time', () => {
400+
const text = '2 days 12 hours 1 minute 12 seconds ago';
401+
const { result, status, info } = transform.relativeTimeToDate(text, now);
402+
expect(result.toISOString()).toBe('2017-09-24T01:27:04.617Z');
403+
expect(status).toBe('success');
404+
expect(info).toBe('past');
405+
});
406+
});
407+
408+
describe('Error cases', () => {
409+
it('should error if string is completely gibberish', () => {
410+
expect(transform.relativeTimeToDate('gibberishasdnklasdnjklasndkl123j123')).toEqual({
411+
status: 'error',
412+
info: "Time should either start with 'in' or end with 'ago'",
413+
});
414+
});
415+
416+
it('should error if string contains neither `ago` nor `in`', () => {
417+
expect(transform.relativeTimeToDate('12 hours 1 minute')).toEqual({
418+
status: 'error',
419+
info: "Time should either start with 'in' or end with 'ago'",
420+
});
421+
});
422+
423+
it('should error if there are missing units or numbers', () => {
424+
expect(transform.relativeTimeToDate('in 12 hours 1')).toEqual({
425+
status: 'error',
426+
info: 'Invalid time string. Dangling unit or number.',
427+
});
428+
429+
expect(transform.relativeTimeToDate('12 hours minute ago')).toEqual({
430+
status: 'error',
431+
info: 'Invalid time string. Dangling unit or number.',
432+
});
433+
});
434+
435+
it('should error on floating point numbers', () => {
436+
expect(transform.relativeTimeToDate('in 12.3 hours')).toEqual({
437+
status: 'error',
438+
info: "'12.3' is not an integer.",
439+
});
440+
});
441+
442+
it('should error if numbers are invalid', () => {
443+
expect(transform.relativeTimeToDate('12 hours 123a minute ago')).toEqual({
444+
status: 'error',
445+
info: "'123a' is not an integer.",
446+
});
447+
});
448+
449+
it('should error on invalid interval units', () => {
450+
expect(transform.relativeTimeToDate('4 score 7 years ago')).toEqual({
451+
status: 'error',
452+
info: "Invalid interval: 'score'",
453+
});
454+
});
455+
456+
it("should error when string contains 'ago' and 'in'", () => {
457+
expect(transform.relativeTimeToDate('in 1 day 2 minutes ago')).toEqual({
458+
status: 'error',
459+
info: "Time cannot have both 'in' and 'ago'",
460+
});
461+
});
462+
});
463+
});
464+

spec/ParseQuery.spec.js

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3105,7 +3105,80 @@ describe('Parse.Query testing', () => {
31053105
equal(result.has('testPointerField'), result.get('shouldBe'));
31063106
});
31073107
done();
3108-
}
3109-
).catch(done.fail);
3108+
}).catch(done.fail);
3109+
});
3110+
3111+
it_only_db('mongo')('should handle relative times correctly', function(done) {
3112+
const now = Date.now();
3113+
const obj1 = new Parse.Object('MyCustomObject', {
3114+
name: 'obj1',
3115+
ttl: new Date(now + 2 * 24 * 60 * 60 * 1000), // 2 days from now
3116+
});
3117+
const obj2 = new Parse.Object('MyCustomObject', {
3118+
name: 'obj2',
3119+
ttl: new Date(now - 2 * 24 * 60 * 60 * 1000), // 2 days ago
3120+
});
3121+
3122+
Parse.Object.saveAll([obj1, obj2])
3123+
.then(() => {
3124+
const q = new Parse.Query('MyCustomObject');
3125+
q.greaterThan('ttl', { $relativeTime: 'in 1 day' });
3126+
return q.find({ useMasterKey: true });
3127+
})
3128+
.then((results) => {
3129+
expect(results.length).toBe(1);
3130+
})
3131+
.then(() => {
3132+
const q = new Parse.Query('MyCustomObject');
3133+
q.greaterThan('ttl', { $relativeTime: '1 day ago' });
3134+
return q.find({ useMasterKey: true });
3135+
})
3136+
.then((results) => {
3137+
expect(results.length).toBe(1);
3138+
})
3139+
.then(() => {
3140+
const q = new Parse.Query('MyCustomObject');
3141+
q.lessThan('ttl', { $relativeTime: '5 days ago' });
3142+
return q.find({ useMasterKey: true });
3143+
})
3144+
.then((results) => {
3145+
expect(results.length).toBe(0);
3146+
})
3147+
.then(() => {
3148+
const q = new Parse.Query('MyCustomObject');
3149+
q.greaterThan('ttl', { $relativeTime: '3 days ago' });
3150+
return q.find({ useMasterKey: true });
3151+
})
3152+
.then((results) => {
3153+
expect(results.length).toBe(2);
3154+
})
3155+
.then(done, done.fail);
3156+
});
3157+
3158+
it_only_db('mongo')('should error on invalid relative time', function(done) {
3159+
const obj1 = new Parse.Object('MyCustomObject', {
3160+
name: 'obj1',
3161+
ttl: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // 2 days from now
3162+
});
3163+
3164+
const q = new Parse.Query('MyCustomObject');
3165+
q.greaterThan('ttl', { $relativeTime: '-12 bananas ago' });
3166+
obj1.save({ useMasterKey: true })
3167+
.then(() => q.find({ useMasterKey: true }))
3168+
.then(done.fail, done);
3169+
});
3170+
3171+
it_only_db('mongo')('should error when using $relativeTime on non-Date field', function(done) {
3172+
const obj1 = new Parse.Object('MyCustomObject', {
3173+
name: 'obj1',
3174+
nonDateField: 'abcd',
3175+
ttl: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // 2 days from now
3176+
});
3177+
3178+
const q = new Parse.Query('MyCustomObject');
3179+
q.greaterThan('nonDateField', { $relativeTime: '1 day ago' });
3180+
obj1.save({ useMasterKey: true })
3181+
.then(() => q.find({ useMasterKey: true }))
3182+
.then(done.fail, done);
31103183
});
31113184
});

spec/helper.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,14 @@ global.it_exclude_dbs = excluded => {
409409
}
410410
}
411411

412+
global.it_only_db = db => {
413+
if (process.env.PARSE_SERVER_TEST_DB === db) {
414+
return it;
415+
} else {
416+
return xit;
417+
}
418+
};
419+
412420
global.fit_exclude_dbs = excluded => {
413421
if (excluded.indexOf(process.env.PARSE_SERVER_TEST_DB) >= 0) {
414422
return xit;

src/Adapters/Storage/Mongo/MongoTransform.js

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,109 @@ function transformTopLevelAtom(atom, field) {
533533
}
534534
}
535535

536+
function relativeTimeToDate(text, now = new Date()) {
537+
text = text.toLowerCase();
538+
539+
let parts = text.split(' ');
540+
541+
// Filter out whitespace
542+
parts = parts.filter((part) => part !== '');
543+
544+
const future = parts[0] === 'in';
545+
const past = parts[parts.length - 1] === 'ago';
546+
547+
if (!future && !past) {
548+
return { status: 'error', info: "Time should either start with 'in' or end with 'ago'" };
549+
}
550+
551+
if (future && past) {
552+
return {
553+
status: 'error',
554+
info: "Time cannot have both 'in' and 'ago'",
555+
};
556+
}
557+
558+
// strip the 'ago' or 'in'
559+
if (future) {
560+
parts = parts.slice(1);
561+
} else { // past
562+
parts = parts.slice(0, parts.length - 1);
563+
}
564+
565+
if (parts.length % 2 !== 0) {
566+
return {
567+
status: 'error',
568+
info: 'Invalid time string. Dangling unit or number.',
569+
};
570+
}
571+
572+
const pairs = [];
573+
while(parts.length) {
574+
pairs.push([ parts.shift(), parts.shift() ]);
575+
}
576+
577+
let seconds = 0;
578+
for (const [num, interval] of pairs) {
579+
const val = Number(num);
580+
if (!Number.isInteger(val)) {
581+
return {
582+
status: 'error',
583+
info: `'${num}' is not an integer.`,
584+
};
585+
}
586+
587+
switch(interval) {
588+
case 'day':
589+
case 'days':
590+
seconds += val * 86400; // 24 * 60 * 60
591+
break;
592+
593+
case 'hr':
594+
case 'hrs':
595+
case 'hour':
596+
case 'hours':
597+
seconds += val * 3600; // 60 * 60
598+
break;
599+
600+
case 'min':
601+
case 'mins':
602+
case 'minute':
603+
case 'minutes':
604+
seconds += val * 60;
605+
break;
606+
607+
case 'sec':
608+
case 'secs':
609+
case 'second':
610+
case 'seconds':
611+
seconds += val;
612+
break;
613+
614+
default:
615+
return {
616+
status: 'error',
617+
info: `Invalid interval: '${interval}'`,
618+
};
619+
}
620+
}
621+
622+
const milliseconds = seconds * 1000;
623+
if (future) {
624+
return {
625+
status: 'success',
626+
info: 'future',
627+
result: new Date(now.valueOf() + milliseconds)
628+
};
629+
}
630+
if (past) {
631+
return {
632+
status: 'success',
633+
info: 'past',
634+
result: new Date(now.valueOf() - milliseconds)
635+
};
636+
}
637+
}
638+
536639
// Transforms a query constraint from REST API format to Mongo format.
537640
// A constraint is something with fields like $lt.
538641
// If it is not a valid constraint but it could be a valid something
@@ -565,9 +668,33 @@ function transformConstraint(constraint, field) {
565668
case '$gte':
566669
case '$exists':
567670
case '$ne':
568-
case '$eq':
569-
answer[key] = transformer(constraint[key]);
671+
case '$eq': {
672+
const val = constraint[key];
673+
if (val && typeof val === 'object' && val.$relativeTime) {
674+
if (field && field.type !== 'Date') {
675+
throw new Parse.Error(Parse.Error.INVALID_JSON, '$relativeTime can only be used with Date field');
676+
}
677+
678+
switch (key) {
679+
case '$exists':
680+
case '$ne':
681+
case '$eq':
682+
throw new Parse.Error(Parse.Error.INVALID_JSON, '$relativeTime can only be used with the $lt, $lte, $gt, and $gte operators');
683+
}
684+
685+
const parserResult = relativeTimeToDate(val.$relativeTime);
686+
if (parserResult.status === 'success') {
687+
answer[key] = parserResult.result;
688+
break;
689+
}
690+
691+
log.info('Error while parsing relative date', parserResult);
692+
throw new Parse.Error(Parse.Error.INVALID_JSON, `bad $relativeTime (${key}) value. ${parserResult.info}`);
693+
}
694+
695+
answer[key] = transformer(val);
570696
break;
697+
}
571698

572699
case '$in':
573700
case '$nin': {
@@ -1196,4 +1323,6 @@ module.exports = {
11961323
transformUpdate,
11971324
transformWhere,
11981325
mongoObjectToParseObject,
1326+
relativeTimeToDate,
1327+
transformConstraint,
11991328
};

0 commit comments

Comments
 (0)