Skip to content

#4678: Converting strings to Date when schema.type is Date within agg… #4743

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 25 commits into from
Jun 26, 2018
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
fa8c902
#4678: Converting strings to Date when schema.type is Date within agg…
cjbland Apr 30, 2018
9a12b7c
Added test cases to test new date match aggregate query
cjbland Apr 30, 2018
044453a
Added function to parse match aggregate arguments and convert necessa…
cjbland May 1, 2018
90b43ba
Merge branch 'master' into fix/4678
cjbland May 1, 2018
1714711
Added missing return value
cjbland May 2, 2018
867863e
Merge branch 'fix/4678' of https://github.com/cjbland/parse-server in…
cjbland May 2, 2018
d922a7a
Improved code quality based on suggestions and figured out why tests …
cjbland May 4, 2018
7261ba0
Added tests from @dplewis
cjbland May 19, 2018
beb54bb
Supporting project aggregation as well as exists operator
cjbland May 19, 2018
403e81b
Merge branch 'master' into fix/4678
cjbland May 19, 2018
9526c65
Merge branch 'master' into fix/4678
cjbland May 19, 2018
7993f45
Merge branch 'fix/4678' of https://github.com/cjbland/parse-server in…
cjbland May 19, 2018
f8c06d9
Merge branch 'master' into fix/4678
cjbland May 22, 2018
abf42d9
Excluding exists match for postgres
cjbland May 23, 2018
f7edfa5
Merge branch 'master' into fix/4678
cjbland Jun 16, 2018
cdc90e2
Merge branch 'master' into fix/4678
cjbland Jun 16, 2018
52b2f7f
Merge branch 'fix/4678' of https://github.com/cjbland/parse-server in…
cjbland Jun 16, 2018
ae3ce3b
Merge branch 'master' into fix/4678
cjbland Jun 18, 2018
da3afbb
Handling the $group operator similar to $match and $project
cjbland Jun 19, 2018
a7c428d
Added more tests for better code coverage
cjbland Jun 20, 2018
cc25079
Excluding certain tests from being run on postgres
cjbland Jun 20, 2018
1a51a82
Excluding one more test from postgres
cjbland Jun 21, 2018
af531c1
clean up
dplewis Jun 24, 2018
6b8fded
Merge branch 'master' into fix/4678
dplewis Jun 26, 2018
f8d7826
Merge branch 'master' into fix/4678
dplewis Jun 26, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions spec/ParseQuery.Aggregate.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,32 @@ describe('Parse.Query Aggregate testing', () => {
});
});

it('group by date object transform', (done) => {
const obj1 = new TestObject();
const obj2 = new TestObject();
const obj3 = new TestObject();
const pipeline = [{
group: {
objectId: { day: { $dayOfMonth: "$updatedAt" }, month: { $month: "$createdAt" }, year: { $year: "$createdAt" } },
count: { $sum: 1 }
}
}];
Parse.Object.saveAll([obj1, obj2, obj3]).then(() => {
const query = new Parse.Query(TestObject);
return query.aggregate(pipeline);
}).then((results) => {
const createdAt = new Date(obj1.createdAt);
expect(results[0].objectId.day).toEqual(createdAt.getUTCDate());
expect(results[0].objectId.month).toEqual(createdAt.getMonth() + 1);
expect(results[0].objectId.year).toEqual(createdAt.getUTCFullYear());
done();
}).catch((error) => {
console.log(error);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see in the travis logs that this catch gets called.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cjbland Can you remove the catch from this test? It does pass now

console.log('error');
done();
});
});

it_exclude_dbs(['postgres'])('cannot group by date field (excluding createdAt and updatedAt)', (done) => {
const obj1 = new TestObject({ dateField: new Date(1990, 11, 1) });
const obj2 = new TestObject({ dateField: new Date(1990, 5, 1) });
Expand Down Expand Up @@ -339,6 +365,27 @@ describe('Parse.Query Aggregate testing', () => {
}).catch(done.fail);
});

it('match comparison date query', (done) => {
const today = new Date();
const yesterday = new Date();
const tomorrow = new Date();
yesterday.setDate(today.getDate() - 1);
tomorrow.setDate(today.getDate() + 1);
const obj1 = new TestObject({ dateField: yesterday });
const obj2 = new TestObject({ dateField: today });
const obj3 = new TestObject({ dateField: tomorrow });
const pipeline = [
{ match: { dateField: { $lt: tomorrow } } }
];
Parse.Object.saveAll([obj1, obj2, obj3]).then(() => {
const query = new Parse.Query(TestObject);
return query.aggregate(pipeline);
}).then((results) => {
expect(results.length).toBe(2);
done();
});
});

it('match comparison query', (done) => {
const options = Object.assign({}, masterKeyOptions, {
body: {
Expand Down Expand Up @@ -474,6 +521,96 @@ describe('Parse.Query Aggregate testing', () => {
});
});

it('match exists query', (done) => {
const pipeline = [
{ match: { score: { $exists: true } } }
];
const query = new Parse.Query(TestObject);
query.aggregate(pipeline).then((results) => {
expect(results.length).toEqual(4);
done();
});
});

it('match date query - createdAt', (done) => {
const obj1 = new TestObject();
const obj2 = new TestObject();

Parse.Object.saveAll([obj1, obj2]).then(() => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const pipeline = [
{ match: { 'createdAt': { $gte: today } } }
];
const query = new Parse.Query(TestObject);
return query.aggregate(pipeline);
}).then((results) => {
// Four objects were created initially, we added two more.
expect(results.length).toEqual(6);
done();
});
});

it('match date query - updatedAt', (done) => {
const obj1 = new TestObject();
const obj2 = new TestObject();

Parse.Object.saveAll([obj1, obj2]).then(() => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const pipeline = [
{ match: { 'updatedAt': { $gte: today } } }
];
const query = new Parse.Query(TestObject);
return query.aggregate(pipeline);
}).then((results) => {
// Four objects were added initially, we added two more.
expect(results.length).toEqual(6);
done();
});
});

it('match date query - empty', (done) => {
const obj1 = new TestObject();
const obj2 = new TestObject();

Parse.Object.saveAll([obj1, obj2]).then(() => {
const now = new Date();
const future = new Date(now.getFullYear(), now.getMonth() + 1, now.getDate());
const pipeline = [
{ match: { 'createdAt': future } }
];
const query = new Parse.Query(TestObject);
return query.aggregate(pipeline);
}).then((results) => {
expect(results.length).toEqual(0);
done();
});
});

it_exclude_dbs(['postgres'])('match pointer with operator query', (done) => {
const pointer = new PointerObject();

const obj1 = new TestObject({ pointer });
const obj2 = new TestObject({ pointer });
const obj3 = new TestObject();

Parse.Object.saveAll([pointer, obj1, obj2, obj3]).then(() => {
const pipeline = [
{ match: { pointer: { $exists: true } } }
];
const query = new Parse.Query(TestObject);
return query.aggregate(pipeline);
}).then((results) => {
expect(results.length).toEqual(2);
expect(results[0].pointer.objectId).toEqual(pointer.id);
expect(results[1].pointer.objectId).toEqual(pointer.id);
expect(results.some(result => result.objectId === obj1.id)).toEqual(true);
expect(results.some(result => result.objectId === obj2.id)).toEqual(true);
done();
});
});

it('project query', (done) => {
const options = Object.assign({}, masterKeyOptions, {
body: {
Expand Down Expand Up @@ -512,6 +649,26 @@ describe('Parse.Query Aggregate testing', () => {
}).catch(done.fail);
});

it('project pointer query', (done) => {
const pointer = new PointerObject();
const obj = new TestObject({ pointer, name: 'hello' });

obj.save().then(() => {
const pipeline = [
{ match: { objectId: obj.id } },
{ project: { pointer: 1, name: 1, createdAt: 1 } }
];
const query = new Parse.Query(TestObject);
return query.aggregate(pipeline);
}).then((results) => {
expect(results.length).toEqual(1);
expect(results[0].name).toEqual('hello');
expect(results[0].createdAt).not.toBe(undefined);
expect(results[0].pointer.objectId).toEqual(pointer.id);
done();
});
});

it('project with group query', (done) => {
const options = Object.assign({}, masterKeyOptions, {
body: {
Expand Down
123 changes: 111 additions & 12 deletions src/Adapters/Storage/Mongo/MongoStorageAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -565,18 +565,10 @@ export class MongoStorageAdapter implements StorageAdapter {
}
}
if (stage.$match) {
for (const field in stage.$match) {
if (schema.fields[field] && schema.fields[field].type === 'Pointer') {
const transformMatch = { [`_p_${field}`] : `${schema.fields[field].targetClass}$${stage.$match[field]}` };
stage.$match = transformMatch;
}
if (field === 'objectId') {
const transformMatch = Object.assign({}, stage.$match);
transformMatch._id = stage.$match[field];
delete transformMatch.objectId;
stage.$match = transformMatch;
}
}
stage.$match = this._parseAggregateArgs(schema, stage.$match);
}
if (stage.$project) {
stage.$project = this._parseAggregateProjectArgs(schema, stage.$project);
}
return stage;
});
Expand Down Expand Up @@ -608,6 +600,113 @@ export class MongoStorageAdapter implements StorageAdapter {
.catch(err => this.handleError(err));
}

// This function will recursively traverse the pipeline and convert any Pointer or Date columns.
// If we detect a pointer column we will rename the column being queried for to match the column
// in the database. We also modify the value to what we expect the value to be in the database
// as well.
// For dates, the driver expects a Date object, but we have a string coming in. So we'll convert
// the string to a Date so the driver can perform the necessary comparison.
//
// The goal of this method is to look for the "leaves" of the pipeline and determine if it needs
// to be converted. The pipeline can have a few different forms. For more details, see:
// https://docs.mongodb.com/manual/reference/operator/aggregation/
//
// If the pipeline is an array, it means we are probably parsing an '$and' or '$or' operator. In
// that case we need to loop through all of it's children to find the columns being operated on.
// If the pipeline is an object, then we'll loop through the keys checking to see if the key name
// matches one of the schema columns. If it does match a column and the column is a Pointer or
// a Date, then we'll convert the value as described above.
//
// As much as I hate recursion...this seemed like a good fit for it. We're essentially traversing
// down a tree to find a "leaf node" and checking to see if it needs to be converted.
_parseAggregateArgs(schema: any, pipeline: any): any {
if (Array.isArray(pipeline)) {
return pipeline.map((value) => this._parseAggregateArgs(schema, value));
}
else if (typeof pipeline === 'object') {
const rtnval = {};
for (const field in pipeline) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

declare const returnValue = {} here and update all fields please

if (schema.fields[field] && schema.fields[field].type === 'Pointer') {
if (typeof pipeline[field] === 'object') {
//
// Pass objects down to MongoDB...this is more than likely an $exists operator.
//
rtnval[`_p_${field}`] = pipeline[field];
}
else {
rtnval[`_p_${field}`] = `${schema.fields[field].targetClass}$${pipeline[field]}`;
}
} else if (schema.fields[field] && schema.fields[field].type === 'Date') {
rtnval[field] = this._convertToDate(pipeline[field]);
} else {
rtnval[field] = this._parseAggregateArgs(schema, pipeline[field]);
}

if (field === 'objectId') {
rtnval['_id'] = rtnval[field];
delete rtnval[field];
} else if (field === 'createdAt') {
rtnval['_created_at'] = rtnval[field];
delete rtnval[field];
} else if (field === 'updatedAt') {
rtnval['_updated_at'] = rtnval[field];
delete rtnval[field];
}
}
return rtnval;
}
return pipeline;
}

// This function is slightly different than the one above. Rather than trying to combine these
// two functions and making the code even harder to understand, I decided to split it up. The
// difference with this function is we are not transforming the values, only the keys of the
// pipeline.
_parseAggregateProjectArgs(schema: any, pipeline: any): any {
if (Array.isArray(pipeline)) {
return pipeline.map((value) => this._parseAggregateProjectArgs(schema, value));
}
else if (typeof pipeline === 'object') {
const rtnval = {};
for (const field in pipeline) {
if (schema.fields[field] && schema.fields[field].type === 'Pointer') {
rtnval[`_p_${field}`] = pipeline[field];
} else {
rtnval[field] = this._parseAggregateArgs(schema, pipeline[field]);
}

if (field === 'objectId') {
rtnval['_id'] = rtnval[field];
delete rtnval[field];
} else if (field === 'createdAt') {
rtnval['_created_at'] = rtnval[field];
delete rtnval[field];
} else if (field === 'updatedAt') {
rtnval['_updated_at'] = rtnval[field];
delete rtnval[field];
}
}
return rtnval;
}
return pipeline;
}

// This function will attempt to convert the provided value to a Date object. Since this is part
// of an aggregation pipeline, the value can either be a string or it can be another object with
// an operator in it (like $gt, $lt, etc). Because of this I felt it was easier to make this a
// recursive method to traverse down to the "leaf node" which is going to be the string.
_convertToDate(value: any): any {
if (typeof value === 'string') {
return new Date(value);
}

const rtnval = {}
for (const field in value) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we document what we expect the value to look like, I have trouble understanding what it does, and why we need the iterator, and recursion.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the newbie question...how do we take care of these? I've updated my branch to include your suggestions. Thanks.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function may work wrong way when "value" is
{ $exists: false }

rtnval[field] = this._convertToDate(value[field])
}
return rtnval;
}

_parseReadPreference(readPreference: ?string): ?string {
switch (readPreference) {
case 'PRIMARY':
Expand Down