diff options
author | Nick Zolnierz <nicholas.zolnierz@mongodb.com> | 2018-01-19 11:36:11 -0500 |
---|---|---|
committer | Nick Zolnierz <nicholas.zolnierz@mongodb.com> | 2018-02-05 16:48:17 -0500 |
commit | b721d0f20ad3dc1b1e9c20c63592c7da15846935 (patch) | |
tree | 291ca9bda643d1c92d0559e5e8abaa75412cd9ab | |
parent | 0f2cc83cdb0320563f6de507885e9c7b17313fa7 (diff) | |
download | mongo-b721d0f20ad3dc1b1e9c20c63592c7da15846935.tar.gz |
SERVER-32771: Add format specifier for $dateFromString expression
-rw-r--r-- | jstests/aggregation/expressions/date_from_string.js | 490 | ||||
-rw-r--r-- | jstests/aggregation/expressions/date_to_string.js | 28 | ||||
-rw-r--r-- | jstests/aggregation/extras/utils.js | 13 | ||||
-rw-r--r-- | src/mongo/db/pipeline/expression.cpp | 56 | ||||
-rw-r--r-- | src/mongo/db/pipeline/expression.h | 4 | ||||
-rw-r--r-- | src/mongo/db/pipeline/expression_test.cpp | 172 | ||||
-rw-r--r-- | src/mongo/db/query/datetime/date_time_support.cpp | 134 | ||||
-rw-r--r-- | src/mongo/db/query/datetime/date_time_support.h | 16 | ||||
-rw-r--r-- | src/mongo/db/query/datetime/date_time_support_test.cpp | 130 |
9 files changed, 879 insertions, 164 deletions
diff --git a/jstests/aggregation/expressions/date_from_string.js b/jstests/aggregation/expressions/date_from_string.js index 78d6b815b78..9dfe4a355b4 100644 --- a/jstests/aggregation/expressions/date_from_string.js +++ b/jstests/aggregation/expressions/date_from_string.js @@ -1,4 +1,4 @@ -load("jstests/aggregation/extras/utils.js"); // For assertErrorCode +load("jstests/aggregation/extras/utils.js"); // For assertErrorCode and assertErrMsgContains. (function() { "use strict"; @@ -12,20 +12,63 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode assert.writeOK(coll.insert({_id: 0})); let testCases = [ - {expect: "2017-07-04T11:56:02Z", inputString: "2017-07-04T11:56:02Z"}, - {expect: "2017-07-04T11:56:02.813Z", inputString: "2017-07-04T11:56:02.813Z"}, - {expect: "2017-07-04T11:56:02.810Z", inputString: "2017-07-04T11:56:02.81Z"}, - {expect: "2017-07-04T11:56:02.800Z", inputString: "2017-07-04T11:56:02.8Z"}, - {expect: "2017-07-04T11:56:02Z", inputString: "2017-07-04T11:56.02"}, - {expect: "2017-07-04T11:56:02.813Z", inputString: "2017-07-04T11:56.02.813"}, - {expect: "2017-07-04T11:56:02.810Z", inputString: "2017-07-04T11:56.02.81"}, - {expect: "2017-07-04T11:56:02.800Z", inputString: "2017-07-04T11:56.02.8"}, + { + expect: "2017-07-04T11:56:02Z", + inputString: "2017-07-04T11:56:02Z", + format: "%Y-%m-%dT%H:%M:%SZ" + }, + { + expect: "2017-07-04T11:56:02.813Z", + inputString: "2017-07-04T11:56:02.813Z", + format: "%Y-%m-%dT%H:%M:%S.%LZ" + }, + { + expect: "2017-07-04T11:56:02.810Z", + inputString: "2017-07-04T11:56:02.81Z", + format: "%Y-%m-%dT%H:%M:%S.%LZ" + }, + { + expect: "2017-07-04T11:56:02.800Z", + inputString: "2017-07-04T11:56:02.8Z", + format: "%Y-%m-%dT%H:%M:%S.%LZ" + }, + { + expect: "2017-07-04T11:56:02Z", + inputString: "2017-07-04T11:56.02", + format: "%Y-%m-%dT%H:%M.%S" + }, + { + expect: "2017-07-04T11:56:02.813Z", + inputString: "2017-07-04T11:56.02.813", + format: "%Y-%m-%dT%H:%M.%S.%L" + }, + { + expect: "2017-07-04T11:56:02.810Z", + inputString: "2017-07-04T11:56.02.81", + format: "%Y-%m-%dT%H:%M.%S.%L" + }, + { + expect: "2017-07-04T11:56:02.800Z", + inputString: "2017-07-04T11:56.02.8", + format: "%Y-%m-%dT%H:%M.%S.%L" + }, ]; testCases.forEach(function(testCase) { + assert.eq([{_id: 0, date: ISODate(testCase.expect)}], + coll.aggregate( + {$project: {date: {$dateFromString: {dateString: testCase.inputString}}}}) + .toArray(), + tojson(testCase)); assert.eq( [{_id: 0, date: ISODate(testCase.expect)}], - coll.aggregate( - {$project: {date: {'$dateFromString': {"dateString": testCase.inputString}}}}) + coll.aggregate({ + $project: { + date: { + $dateFromString: + {dateString: testCase.inputString, format: testCase.format} + } + } + }) .toArray(), tojson(testCase)); }); @@ -37,10 +80,26 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode assert.writeOK(coll.insert({_id: 0})); testCases = [ - {expect: "2017-07-04T10:56:02Z", inputString: "2017-07-04T11:56.02"}, - {expect: "2017-07-04T10:56:02.813Z", inputString: "2017-07-04T11:56.02.813"}, - {expect: "2017-07-04T10:56:02.810Z", inputString: "2017-07-04T11:56.02.81"}, - {expect: "2017-07-04T10:56:02.800Z", inputString: "2017-07-04T11:56.02.8"}, + { + expect: "2017-07-04T10:56:02Z", + inputString: "2017-07-04T11:56.02", + format: "%Y-%m-%dT%H:%M.%S" + }, + { + expect: "2017-07-04T10:56:02.813Z", + inputString: "2017-07-04T11:56.02.813", + format: "%Y-%m-%dT%H:%M.%S.%L" + }, + { + expect: "2017-07-04T10:56:02.810Z", + inputString: "2017-07-04T11:56.02.81", + format: "%Y-%m-%dT%H:%M.%S.%L" + }, + { + expect: "2017-07-04T10:56:02.800Z", + inputString: "2017-07-04T11:56.02.8", + format: "%Y-%m-%dT%H:%M.%S.%L" + }, ]; testCases.forEach(function(testCase) { assert.eq( @@ -55,6 +114,20 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode }) .toArray(), tojson(testCase)); + assert.eq([{_id: 0, date: ISODate(testCase.expect)}], + coll.aggregate({ + $project: { + date: { + $dateFromString: { + dateString: testCase.inputString, + timezone: "Europe/London", + format: testCase.format + } + } + } + }) + .toArray(), + tojson(testCase)); }); /* --------------------------------------------------------------------------------------- */ @@ -64,10 +137,26 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode assert.writeOK(coll.insert({_id: 0})); testCases = [ - {expect: "2017-07-04T10:56:02Z", inputString: "2017-07-04T11:56.02"}, - {expect: "2017-07-04T10:56:02.813Z", inputString: "2017-07-04T11:56.02.813"}, - {expect: "2017-07-04T10:56:02.810Z", inputString: "2017-07-04T11:56.02.81"}, - {expect: "2017-07-04T10:56:02.800Z", inputString: "2017-07-04T11:56.02.8"}, + { + expect: "2017-07-04T10:56:02Z", + inputString: "2017-07-04T11:56.02", + format: "%Y-%m-%dT%H:%M.%S" + }, + { + expect: "2017-07-04T10:56:02.813Z", + inputString: "2017-07-04T11:56.02.813", + format: "%Y-%m-%dT%H:%M.%S.%L" + }, + { + expect: "2017-07-04T10:56:02.810Z", + inputString: "2017-07-04T11:56.02.81", + format: "%Y-%m-%dT%H:%M.%S.%L" + }, + { + expect: "2017-07-04T10:56:02.800Z", + inputString: "2017-07-04T11:56.02.8", + format: "%Y-%m-%dT%H:%M.%S.%L" + }, ]; testCases.forEach(function(testCase) { assert.eq([{_id: 0, date: ISODate(testCase.expect)}], @@ -81,6 +170,20 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode }) .toArray(), tojson(testCase)); + assert.eq([{_id: 0, date: ISODate(testCase.expect)}], + coll.aggregate({ + $project: { + date: { + $dateFromString: { + dateString: testCase.inputString, + timezone: "+01:00", + format: testCase.format + } + } + } + }) + .toArray(), + tojson(testCase)); }); /* --------------------------------------------------------------------------------------- */ @@ -88,40 +191,64 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode coll.drop(); assert.writeOK(coll.insert([ - {_id: 0, dateString: "2017-07-06T12:35:37Z"}, - {_id: 1, dateString: "2017-07-06T12:35:37.513Z"}, - {_id: 2, dateString: "2017-07-06T12:35:37"}, - {_id: 3, dateString: "2017-07-06T12:35:37.513"}, - {_id: 4, dateString: "1960-07-10T12:10:37.448"}, + {_id: 0, dateString: "2017-07-06T12:35:37Z", format: "%Y-%m-%dT%H:%M:%SZ"}, + {_id: 1, dateString: "2017-07-06T12:35:37.513Z", format: "%Y-%m-%dT%H:%M:%S.%LZ"}, + {_id: 2, dateString: "2017-07-06T12:35:37", format: "%Y-%m-%dT%H:%M:%S"}, + {_id: 3, dateString: "2017-07-06T12:35:37.513", format: "%Y-%m-%dT%H:%M:%S.%L"}, + {_id: 4, dateString: "1960-07-10T12:10:37.448", format: "%Y-%m-%dT%H:%M:%S.%L"}, ])); + let expectedResults = [ + {"_id": 0, "date": ISODate("2017-07-06T12:35:37Z")}, + {"_id": 1, "date": ISODate("2017-07-06T12:35:37.513Z")}, + {"_id": 2, "date": ISODate("2017-07-06T12:35:37Z")}, + {"_id": 3, "date": ISODate("2017-07-06T12:35:37.513Z")}, + {"_id": 4, "date": ISODate("1960-07-10T12:10:37.448Z")}, + ]; + assert.eq(expectedResults, + coll.aggregate([ + { + $project: {date: {$dateFromString: {dateString: "$dateString"}}}, + }, + {$sort: {_id: 1}} + ]) + .toArray()); + + // Repeat the test with an explicit format specifier string. assert.eq( - [ - {"_id": 0, "date": ISODate("2017-07-06T12:35:37Z")}, - {"_id": 1, "date": ISODate("2017-07-06T12:35:37.513Z")}, - {"_id": 2, "date": ISODate("2017-07-06T12:35:37Z")}, - {"_id": 3, "date": ISODate("2017-07-06T12:35:37.513Z")}, - {"_id": 4, "date": ISODate("1960-07-10T12:10:37.448Z")}, - ], + expectedResults, coll.aggregate([ { - $project: {date: {'$dateFromString': {dateString: "$dateString"}}}, + $project: + {date: {$dateFromString: {dateString: "$dateString", format: "$format"}}}, }, {$sort: {_id: 1}} ]) .toArray()); + expectedResults = [ + {"_id": 0, "date": new Date(1499344537000)}, + {"_id": 1, "date": new Date(1499344537513)}, + {"_id": 2, "date": new Date(1499344537000)}, + {"_id": 3, "date": new Date(1499344537513)}, + {"_id": 4, "date": new Date(-299072962552)}, + ]; + assert.eq(expectedResults, + coll.aggregate([ + { + $project: {date: {$dateFromString: {dateString: "$dateString"}}}, + }, + {$sort: {_id: 1}} + ]) + .toArray()); + + // Repeat the test with an explicit format specifier string. assert.eq( - [ - {"_id": 0, "date": new Date(1499344537000)}, - {"_id": 1, "date": new Date(1499344537513)}, - {"_id": 2, "date": new Date(1499344537000)}, - {"_id": 3, "date": new Date(1499344537513)}, - {"_id": 4, "date": new Date(-299072962552)}, - ], + expectedResults, coll.aggregate([ { - $project: {date: {'$dateFromString': {dateString: "$dateString"}}}, + $project: + {date: {$dateFromString: {dateString: "$dateString", format: "$format"}}}, }, {$sort: {_id: 1}} ]) @@ -141,27 +268,45 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode {_id: 6, dateString: "2017-07-06T12:35:37.513", timezone: "+04:00"}, ])); + expectedResults = [ + {"_id": 0, "date": ISODate("2017-07-06T12:35:37.513Z")}, + {"_id": 1, "date": ISODate("2017-07-06T12:35:37.513Z")}, + {"_id": 2, "date": ISODate("1960-07-10T16:35:37.513Z")}, + {"_id": 3, "date": ISODate("1960-07-10T11:35:37.513Z")}, + {"_id": 4, "date": ISODate("2017-07-06T19:35:37.513Z")}, + {"_id": 5, "date": ISODate("2017-07-06T10:35:37.513Z")}, + {"_id": 6, "date": ISODate("2017-07-06T08:35:37.513Z")}, + ]; + assert.eq( - [ - {"_id": 0, "date": ISODate("2017-07-06T12:35:37.513Z")}, - {"_id": 1, "date": ISODate("2017-07-06T12:35:37.513Z")}, - {"_id": 2, "date": ISODate("1960-07-10T16:35:37.513Z")}, - {"_id": 3, "date": ISODate("1960-07-10T11:35:37.513Z")}, - {"_id": 4, "date": ISODate("2017-07-06T19:35:37.513Z")}, - {"_id": 5, "date": ISODate("2017-07-06T10:35:37.513Z")}, - {"_id": 6, "date": ISODate("2017-07-06T08:35:37.513Z")}, - ], + expectedResults, coll.aggregate([ { - $project: { - date: - {'$dateFromString': {dateString: "$dateString", timezone: "$timezone"}} - }, + $project: + {date: {$dateFromString: {dateString: "$dateString", timezone: "$timezone"}}}, }, {$sort: {_id: 1}} ]) .toArray()); + // Repeat the test with an explicit format specifier string. + assert.eq(expectedResults, + coll.aggregate([ + { + $project: { + date: { + $dateFromString: { + dateString: "$dateString", + timezone: "$timezone", + format: "%Y-%m-%dT%H:%M:%S.%L" + } + } + }, + }, + {$sort: {_id: 1}} + ]) + .toArray()); + /* --------------------------------------------------------------------------------------- */ /* dateString from data with timezone as constant */ @@ -177,8 +322,7 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode coll.aggregate([ { $project: { - date: - {'$dateFromString': {dateString: "$dateString", timezone: "Asia/Tokyo"}} + date: {$dateFromString: {dateString: "$dateString", timezone: "Asia/Tokyo"}} }, }, {$sort: {_id: 1}} @@ -205,7 +349,7 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode { $project: { date: { - '$dateFromString': + $dateFromString: {dateString: "2017-07-19T18:52:35.199", timezone: "$timezone"} } }, @@ -223,31 +367,31 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode let pipelines = [ { expect: "2017-01-01T00:00:00Z", - pipeline: {$project: {date: {'$dateFromString': {"dateString": "2017-01-01 00:00:00"}}}} + pipeline: {$project: {date: {$dateFromString: {dateString: "2017-01-01 00:00:00"}}}} }, { expect: "2017-07-01T00:00:00Z", - pipeline: {$project: {date: {'$dateFromString': {"dateString": "2017-07-01 00:00:00"}}}} + pipeline: {$project: {date: {$dateFromString: {dateString: "2017-07-01 00:00:00"}}}} }, { expect: "2017-07-06T00:00:00Z", - pipeline: {$project: {date: {'$dateFromString': {"dateString": "2017-07-06"}}}} + pipeline: {$project: {date: {$dateFromString: {dateString: "2017-07-06"}}}} }, { expect: "2017-07-06T00:00:00Z", - pipeline: {$project: {date: {'$dateFromString': {"dateString": "2017-07-06 00:00:00"}}}} + pipeline: {$project: {date: {$dateFromString: {dateString: "2017-07-06 00:00:00"}}}} }, { expect: "2017-07-06T11:00:00Z", - pipeline: {$project: {date: {'$dateFromString': {"dateString": "2017-07-06 11:00:00"}}}} + pipeline: {$project: {date: {$dateFromString: {dateString: "2017-07-06 11:00:00"}}}} }, { expect: "2017-07-06T11:36:00Z", - pipeline: {$project: {date: {'$dateFromString': {"dateString": "2017-07-06 11:36:00"}}}} + pipeline: {$project: {date: {$dateFromString: {dateString: "2017-07-06 11:36:00"}}}} }, { expect: "2017-07-06T11:36:54Z", - pipeline: {$project: {date: {'$dateFromString': {"dateString": "2017-07-06 11:36:54"}}}} + pipeline: {$project: {date: {$dateFromString: {dateString: "2017-07-06 11:36:54"}}}} }, ]; pipelines.forEach(function(pipeline) { @@ -285,12 +429,24 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode {expect: "2017-07-14T12:02:44.771Z", inputString: "2017-07-14T12:02:44.771 Z"}, ]; testCases.forEach(function(testCase) { - assert.eq( - [{_id: 0, date: ISODate(testCase.expect)}], - coll.aggregate( - {$project: {date: {'$dateFromString': {"dateString": testCase.inputString}}}}) - .toArray(), - tojson(testCase)); + assert.eq([{_id: 0, date: ISODate(testCase.expect)}], + coll.aggregate( + {$project: {date: {$dateFromString: {dateString: testCase.inputString}}}}) + .toArray(), + tojson(testCase)); + assert.eq([{_id: 0, date: ISODate(testCase.expect)}], + coll.aggregate({ + $project: { + date: { + $dateFromString: { + dateString: testCase.inputString, + format: "%Y-%m-%dT%H:%M:%S.%L%z" + } + } + } + }) + .toArray(), + tojson(testCase)); }); /* --------------------------------------------------------------------------------------- */ @@ -319,7 +475,7 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode ], coll.aggregate([ { - $project: {date: {'$dateFromString': {dateString: "$dateString"}}}, + $project: {date: {$dateFromString: {dateString: "$dateString"}}}, }, {$sort: {_id: 1}} ]) @@ -355,13 +511,74 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode ], coll.aggregate([ { - $project: {date: {'$dateFromString': {dateString: "$dateString"}}}, + $project: {date: {$dateFromString: {dateString: "$dateString"}}}, }, {$sort: {_id: 1}} ]) .toArray()); /* --------------------------------------------------------------------------------------- */ + /* Tests formats that aren't supported with the normal $dateFromString parser. */ + + coll.drop(); + assert.writeOK(coll.insert({_id: 0})); + + testCases = [ + {inputString: "05 12 1988", format: "%d %m %Y", expect: "1988-12-05T00:00:00Z"}, + {inputString: "1992 04 26", format: "%Y %m %d", expect: "1992-04-26T00:00:00Z"}, + {inputString: "05*12*1988", format: "%d*%m*%Y", expect: "1988-12-05T00:00:00Z"}, + {inputString: "1992/04/26", format: "%Y/%m/%d", expect: "1992-04-26T00:00:00Z"}, + {inputString: "1992 % 04 % 26", format: "%Y %% %m %% %d", expect: "1992-04-26T00:00:00Z"}, + { + inputString: "Day: 05 Month: 12 Year: 1988", + format: "Day: %d Month: %m Year: %Y", + expect: "1988-12-05T00:00:00Z" + }, + {inputString: "Date: 1992/04/26", format: "Date: %Y/%m/%d", expect: "1992-04-26T00:00:00Z"}, + {inputString: "4/26/1992:+0445", format: "%m/%d/%Y:%z", expect: "1992-04-25T19:15:00Z"}, + {inputString: "4/26/1992:+285", format: "%m/%d/%Y:%Z", expect: "1992-04-25T19:15:00Z"}, + ]; + testCases.forEach(function(testCase) { + assert.eq( + [{_id: 0, date: ISODate(testCase.expect)}], + coll.aggregate({ + $project: { + date: { + $dateFromString: + {dateString: testCase.inputString, format: testCase.format} + } + } + }) + .toArray(), + tojson(testCase)); + }); + + /* --------------------------------------------------------------------------------------- */ + /* Tests for ISO year, week of year, and day of the week. */ + + testCases = [ + {inputString: "2017", format: "%G", expect: "2017-01-02T00:00:00Z"}, + {inputString: "2017, Week 53", format: "%G, Week %V", expect: "2018-01-01T00:00:00Z"}, + {inputString: "2017, Day 5", format: "%G, Day %u", expect: "2017-01-06T00:00:00Z"}, + {inputString: "53.7.2017", format: "%V.%u.%G", expect: "2018-01-07T00:00:00Z"}, + {inputString: "1.1.1", format: "%V.%u.%G", expect: "0001-01-01T00:00:00Z"}, + ]; + testCases.forEach(function(testCase) { + assert.eq( + [{_id: 0, date: ISODate(testCase.expect)}], + coll.aggregate({ + $project: { + date: { + $dateFromString: + {dateString: testCase.inputString, format: testCase.format} + } + } + }) + .toArray(), + tojson(testCase)); + }); + + /* --------------------------------------------------------------------------------------- */ /* Testing whether it throws the right assert for missing elements of a date/time string. */ coll.drop(); @@ -371,12 +588,13 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode ])); pipelines = [ - [{'$project': {date: {'$dateFromString': {dateString: "July 4th"}}}}], - [{'$project': {date: {'$dateFromString': {dateString: "12:50:53"}}}}], + [{'$project': {date: {$dateFromString: {dateString: "July 4th"}}}}], + [{'$project': {date: {$dateFromString: {dateString: "12:50:53"}}}}], ]; pipelines.forEach(function(pipeline) { - assertErrorCode(coll, pipeline, 40545, tojson(pipeline)); + assertErrMsgContains( + coll, pipeline, 40545, "an incomplete date/time string has been found"); }); /* --------------------------------------------------------------------------------------- */ @@ -389,12 +607,12 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode ])); pipelines = [ - [{'$project': {date: {'$dateFromString': {dateString: "2017, 12:50:53"}}}}], - [{'$project': {date: {'$dateFromString': {dateString: "60.Monday1770/06:59"}}}}], + [{'$project': {date: {$dateFromString: {dateString: "2017, 12:50:53"}}}}], + [{'$project': {date: {$dateFromString: {dateString: "60.Monday1770/06:59"}}}}], ]; pipelines.forEach(function(pipeline) { - assertErrorCode(coll, pipeline, 40553, tojson(pipeline)); + assertErrMsgContains(coll, pipeline, 40553, "Error parsing date string"); }); /* --------------------------------------------------------------------------------------- */ @@ -408,14 +626,11 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode ])); pipelines = [ - [{$project: {date: {'$dateFromString': {"dateString": "$tz"}}}}, {$sort: {_id: 1}}], + [{$project: {date: {$dateFromString: {dateString: "$tz"}}}}, {$sort: {_id: 1}}], [ { - $project: { - date: { - '$dateFromString': {"dateString": "2017-07-11T17:05:19Z", "timezone": "$tz"} - } - } + $project: + {date: {$dateFromString: {dateString: "2017-07-11T17:05:19Z", timezone: "$tz"}}} }, {$sort: {_id: 1}} ], @@ -426,17 +641,37 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode tojson(pipeline)); }); + coll.drop(); + assert.writeOK(coll.insert([ + {_id: 0}, + {_id: 1, format: null}, + {_id: 2, format: undefined}, + ])); + + assert.eq( + [{_id: 0, date: null}, {_id: 1, date: null}, {_id: 2, date: null}], + coll.aggregate({ + $project: { + date: { + $dateFromString: {dateString: "2017-07-11T17:05:19Z", format: "$format"} + } + } + }) + .toArray()); + /* --------------------------------------------------------------------------------------- */ /* Parse errors. */ - let pipeline = {$project: {date: {'$dateFromString': "no-object"}}}; - assertErrorCode(coll, pipeline, 40540); + let pipeline = [{$project: {date: {$dateFromString: "no-object"}}}]; + assertErrMsgContains( + coll, pipeline, 40540, "$dateFromString only supports an object as an argument"); - pipeline = {$project: {date: {'$dateFromString': {"unknown": "$tz"}}}}; - assertErrorCode(coll, pipeline, 40541); + pipeline = [{$project: {date: {$dateFromString: {"unknown": "$tz"}}}}]; + assertErrMsgContains(coll, pipeline, 40541, "Unrecognized argument"); - pipeline = {$project: {date: {'$dateFromString': {"dateString": 5}}}}; - assertErrorCode(coll, pipeline, 40543); + pipeline = [{$project: {date: {$dateFromString: {dateString: 5}}}}]; + assertErrMsgContains( + coll, pipeline, 40543, "$dateFromString requires that 'dateString' be a string"); /* --------------------------------------------------------------------------------------- */ /* Passing in time zone with date/time string. */ @@ -444,7 +679,7 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode pipeline = { $project: { date: { - '$dateFromString': + $dateFromString: {dateString: "2017-07-12T22:23:55 GMT+02:00", timezone: "Europe/Amsterdam"} } } @@ -454,7 +689,7 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode pipeline = { $project: { date: { - '$dateFromString': + $dateFromString: {dateString: "2017-07-12T22:23:55Z", timezone: "Europe/Amsterdam"} } } @@ -464,7 +699,7 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode pipeline = { $project: { date: { - '$dateFromString': { + $dateFromString: { dateString: "2017-07-12T22:23:55 America/New_York", timezone: "Europe/Amsterdam" } @@ -474,9 +709,76 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode assertErrorCode(coll, pipeline, 40553); pipeline = { - $project: - {date: {'$dateFromString': {dateString: "2017-07-12T22:23:55 Europe/Amsterdam"}}} + $project: {date: {$dateFromString: {dateString: "2017-07-12T22:23:55 Europe/Amsterdam"}}} }; assertErrorCode(coll, pipeline, 40553); + /* --------------------------------------------------------------------------------------- */ + /* Error cases for $dateFromString with format specifier string. */ + + // Test umatched format specifier string. + pipeline = [{$project: {date: {$dateFromString: {dateString: "2018-01", format: "%Y-%m-%d"}}}}]; + assertErrMsgContains(coll, pipeline, 40553, "Data missing"); + + pipeline = [{$project: {date: {$dateFromString: {dateString: "2018-01", format: "%Y"}}}}]; + assertErrMsgContains(coll, pipeline, 40553, "Trailing data"); + + // Test missing specifier prefix '%'. + pipeline = [{$project: {date: {$dateFromString: {dateString: "1992-26-04", format: "Y-d-m"}}}}]; + assertErrMsgContains(coll, pipeline, 40553, "Format literal not found"); + + pipeline = [{$project: {date: {$dateFromString: {dateString: "1992", format: "%n"}}}}]; + assertErrMsgContains(coll, pipeline, 18536, "Invalid format character"); + + pipeline = [{ + $project: { + date: { + $dateFromString: + {dateString: "4/26/1992:+0445", format: "%m/%d/%Y:%z", timezone: "+0500"} + } + } + }]; + assertErrMsgContains( + coll, + pipeline, + 40554, + "you cannot pass in a date/time string with GMT offset together with a timezone argument"); + + pipeline = [{$project: {date: {$dateFromString: {dateString: "4/26/1992", format: 5}}}}]; + assertErrMsgContains( + coll, pipeline, 40684, "$dateFromString requires that 'format' be a string"); + + pipeline = [{$project: {date: {$dateFromString: {dateString: "4/26/1992", format: {}}}}}]; + assertErrMsgContains( + coll, pipeline, 40684, "$dateFromString requires that 'format' be a string"); + + pipeline = + [{$project: {date: {$dateFromString: {dateString: "ISO Day 6", format: "ISO Day %u"}}}}]; + assertErrMsgContains(coll, pipeline, 40553, "The parsed date was invalid"); + + pipeline = + [{$project: {date: {$dateFromString: {dateString: "ISO Week 52", format: "ISO Week %V"}}}}]; + assertErrMsgContains(coll, pipeline, 40553, "The parsed date was invalid"); + + pipeline = [{ + $project: { + date: {$dateFromString: {dateString: "ISO Week 1, 2018", format: "ISO Week %V, %Y"}} + } + }]; + assertErrMsgContains( + coll, pipeline, 40553, "Mixing of ISO dates with natural dates is not allowed"); + + pipeline = + [{$project: {date: {$dateFromString: {dateString: "12/31/2018", format: "%m/%d/%G"}}}}]; + assertErrMsgContains( + coll, pipeline, 40553, "Mixing of ISO dates with natural dates is not allowed"); + + // Test embedded null bytes in the 'dateString' and 'format' fields. + pipeline = + [{$project: {date: {$dateFromString: {dateString: "12/31\0/2018", format: "%m/%d/%Y"}}}}]; + assertErrMsgContains(coll, pipeline, 40553, "Data missing"); + + pipeline = + [{$project: {date: {$dateFromString: {dateString: "12/31/2018", format: "%m/%d\0/%Y"}}}}]; + assertErrMsgContains(coll, pipeline, 40553, "Trailing data"); })(); diff --git a/jstests/aggregation/expressions/date_to_string.js b/jstests/aggregation/expressions/date_to_string.js index f15d566fabd..2022edc459b 100644 --- a/jstests/aggregation/expressions/date_to_string.js +++ b/jstests/aggregation/expressions/date_to_string.js @@ -104,6 +104,34 @@ load("jstests/aggregation/extras/utils.js"); // For assertErrorCode .toArray()); /* --------------------------------------------------------------------------------------- */ + coll.drop(); + + assert.writeOK(coll.insert([ + {_id: 0, date: new ISODate("2017-01-01T15:08:51.911Z")}, + {_id: 1, date: new ISODate("2017-07-04T15:09:12.911Z")}, + {_id: 2, date: new ISODate("2017-12-04T15:09:14.911Z")}, + ])); + + assert.eq( + [ + {_id: 0, date: "Natural: 2017-W1-01, ISO: 2016-W7-52"}, + {_id: 1, date: "Natural: 2017-W3-27, ISO: 2017-W2-27"}, + {_id: 2, date: "Natural: 2017-W2-49, ISO: 2017-W1-49"}, + ], + coll.aggregate([ + { + $project: { + date: { + $dateToString: + {format: "Natural: %Y-W%w-%U, ISO: %G-W%u-%V", date: "$date"} + } + } + }, + {$sort: {_id: 1}} + ]) + .toArray()); + + /* --------------------------------------------------------------------------------------- */ /* Test that missing expressions, turn into BSON null values */ coll.drop(); diff --git a/jstests/aggregation/extras/utils.js b/jstests/aggregation/extras/utils.js index d664aedb9f1..2c6151ffe73 100644 --- a/jstests/aggregation/extras/utils.js +++ b/jstests/aggregation/extras/utils.js @@ -277,3 +277,16 @@ function assertErrorCode(coll, pipe, code, errmsg) { assert.eq(cursorRes.code, code, tojson(cursorRes)); } } + +/** + * Assert that an aggregation fails with a specific code and the error message contains the given + * string. + */ +function assertErrMsgContains(coll, pipe, code, expectedMessage) { + const response = assert.commandFailedWithCode( + coll.getDB().runCommand({aggregate: coll.getName(), pipeline: pipe, cursor: {}}), code); + assert.neq( + -1, + response.errmsg.indexOf(expectedMessage), + "Error message did not contain '" + expectedMessage + "', found:\n" + tojson(response)); +} diff --git a/src/mongo/db/pipeline/expression.cpp b/src/mongo/db/pipeline/expression.cpp index 3c3156b3435..40e499291ff 100644 --- a/src/mongo/db/pipeline/expression.cpp +++ b/src/mongo/db/pipeline/expression.cpp @@ -1299,12 +1299,15 @@ intrusive_ptr<Expression> ExpressionDateFromString::parse( BSONElement dateStringElem; BSONElement timeZoneElem; + BSONElement formatElem; const BSONObj args = expr.embeddedObject(); for (auto&& arg : args) { auto field = arg.fieldNameStringData(); - if (field == "dateString"_sd) { + if (field == "format"_sd) { + formatElem = arg; + } else if (field == "dateString"_sd) { dateStringElem = arg; } else if (field == "timezone"_sd) { timeZoneElem = arg; @@ -1317,25 +1320,33 @@ intrusive_ptr<Expression> ExpressionDateFromString::parse( uassert(40542, "Missing 'dateString' parameter to $dateFromString", dateStringElem); - return new ExpressionDateFromString(expCtx, - parseOperand(expCtx, dateStringElem, vps), - timeZoneElem ? parseOperand(expCtx, timeZoneElem, vps) - : nullptr); + return new ExpressionDateFromString( + expCtx, + parseOperand(expCtx, dateStringElem, vps), + timeZoneElem ? parseOperand(expCtx, timeZoneElem, vps) : nullptr, + formatElem ? parseOperand(expCtx, formatElem, vps) : nullptr); } ExpressionDateFromString::ExpressionDateFromString( const boost::intrusive_ptr<ExpressionContext>& expCtx, intrusive_ptr<Expression> dateString, - intrusive_ptr<Expression> timeZone) - : Expression(expCtx), _dateString(std::move(dateString)), _timeZone(std::move(timeZone)) {} + intrusive_ptr<Expression> timeZone, + intrusive_ptr<Expression> format) + : Expression(expCtx), + _dateString(std::move(dateString)), + _timeZone(std::move(timeZone)), + _format(std::move(format)) {} intrusive_ptr<Expression> ExpressionDateFromString::optimize() { _dateString = _dateString->optimize(); if (_timeZone) { _timeZone = _timeZone->optimize(); } + if (_format) { + _format = _format->optimize(); + } - if (ExpressionConstant::allNullOrConstant({_dateString, _timeZone})) { + if (ExpressionConstant::allNullOrConstant({_dateString, _timeZone, _format})) { // Everything is a constant, so we can turn into a constant. return ExpressionConstant::create(getExpressionContext(), evaluate(Document{})); } @@ -1346,7 +1357,8 @@ Value ExpressionDateFromString::serialize(bool explain) const { return Value( Document{{"$dateFromString", Document{{"dateString", _dateString->serialize(explain)}, - {"timezone", _timeZone ? _timeZone->serialize(explain) : Value()}}}}); + {"timezone", _timeZone ? _timeZone->serialize(explain) : Value()}, + {"format", _format ? _format->serialize(explain) : Value()}}}}); } Value ExpressionDateFromString::evaluate(const Document& root) const { @@ -1366,6 +1378,27 @@ Value ExpressionDateFromString::evaluate(const Document& root) const { dateString.getType() == BSONType::String); const std::string& dateTimeString = dateString.getString(); + if (_format) { + const Value format = _format->evaluate(root); + + if (format.nullish()) { + return Value(BSONNULL); + } + + uassert(40684, + str::stream() << "$dateFromString requires that 'format' be a string, found: " + << typeName(format.getType()) + << " with value " + << format.toString(), + format.getType() == BSONType::String); + const std::string& formatString = format.getString(); + + TimeZone::validateFromStringFormat(formatString); + + return Value(getExpressionContext()->timeZoneDatabase->fromString( + dateTimeString, timeZone, StringData(formatString))); + } + return Value(getExpressionContext()->timeZoneDatabase->fromString(dateTimeString, timeZone)); } @@ -1374,6 +1407,9 @@ void ExpressionDateFromString::_doAddDependencies(DepsTracker* deps) const { if (_timeZone) { _timeZone->addDependencies(deps); } + if (_format) { + _format->addDependencies(deps); + } } /* ---------------------- ExpressionDateToParts ----------------------- */ @@ -1560,7 +1596,7 @@ intrusive_ptr<Expression> ExpressionDateToString::parse( const string format = formatElem.str(); - TimeZone::validateFormat(format); + TimeZone::validateToStringFormat(format); return new ExpressionDateToString(expCtx, format, diff --git a/src/mongo/db/pipeline/expression.h b/src/mongo/db/pipeline/expression.h index 045614133d3..2dd3b1cc42c 100644 --- a/src/mongo/db/pipeline/expression.h +++ b/src/mongo/db/pipeline/expression.h @@ -873,10 +873,12 @@ protected: private: ExpressionDateFromString(const boost::intrusive_ptr<ExpressionContext>& expCtx, boost::intrusive_ptr<Expression> dateString, - boost::intrusive_ptr<Expression> timeZone); + boost::intrusive_ptr<Expression> timeZone, + boost::intrusive_ptr<Expression> format); boost::intrusive_ptr<Expression> _dateString; boost::intrusive_ptr<Expression> _timeZone; + boost::intrusive_ptr<Expression> _format; }; class ExpressionDateFromParts final : public Expression { diff --git a/src/mongo/db/pipeline/expression_test.cpp b/src/mongo/db/pipeline/expression_test.cpp index b893229d6f0..6e41e9e75fc 100644 --- a/src/mongo/db/pipeline/expression_test.cpp +++ b/src/mongo/db/pipeline/expression_test.cpp @@ -5191,6 +5191,22 @@ TEST_F(ExpressionDateFromStringTest, SerializesToObjectSyntax) { ASSERT_VALUE_EQ(dateExp->serialize(true), expectedSerialization); ASSERT_VALUE_EQ(dateExp->serialize(false), expectedSerialization); + + spec = BSON("$dateFromString" << BSON("dateString" + << "2017-07-04T13:06:44Z" + << "timezone" + << "Europe/London" + << "format" + << "%Y-%d-%mT%H:%M:%S")); + dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); + expectedSerialization = + Value(Document{{"$dateFromString", + Document{{"dateString", Document{{"$const", "2017-07-04T13:06:44Z"_sd}}}, + {"timezone", Document{{"$const", "Europe/London"_sd}}}, + {"format", Document{{"$const", "%Y-%d-%mT%H:%M:%S"_sd}}}}}}); + + ASSERT_VALUE_EQ(dateExp->serialize(true), expectedSerialization); + ASSERT_VALUE_EQ(dateExp->serialize(false), expectedSerialization); } TEST_F(ExpressionDateFromStringTest, OptimizesToConstantIfAllInputsAreConstant) { @@ -5212,6 +5228,16 @@ TEST_F(ExpressionDateFromStringTest, OptimizesToConstantIfAllInputsAreConstant) dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); ASSERT(dynamic_cast<ExpressionConstant*>(dateExp->optimize().get())); + // Test that it becomes a constant with the dateString, timezone, and format being a constant. + spec = BSON("$dateFromString" << BSON("dateString" + << "2017-07-04T13:09:57" + << "timezone" + << "Europe/London" + << "format" + << "%Y-%m-%dT%H:%M:%S")); + dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); + ASSERT(dynamic_cast<ExpressionConstant*>(dateExp->optimize().get())); + dateVal = Date_t::fromMillisSinceEpoch(1499170197000); ASSERT_VALUE_EQ(Value(dateVal), dateExp->evaluate(Document{})); @@ -5228,6 +5254,16 @@ TEST_F(ExpressionDateFromStringTest, OptimizesToConstantIfAllInputsAreConstant) << "$tz")); dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); ASSERT_FALSE(dynamic_cast<ExpressionConstant*>(dateExp->optimize().get())); + + // Test that it does *not* become a constant if format is not a constant. + spec = BSON("$dateFromString" << BSON("dateString" + << "2017-07-04T13:09:57Z" + << "timezone" + << "Europe/London" + << "format" + << "$format")); + dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); + ASSERT_FALSE(dynamic_cast<ExpressionConstant*>(dateExp->optimize().get())); } TEST_F(ExpressionDateFromStringTest, RejectsUnparsableString) { @@ -5289,6 +5325,79 @@ TEST_F(ExpressionDateFromStringTest, RejectsTimeZoneInStringAndArgument) { ASSERT_THROWS_CODE(dateExp->evaluate({}), AssertionException, 40554); } +TEST_F(ExpressionDateFromStringTest, RejectsNonStringFormat) { + auto expCtx = getExpCtx(); + + auto spec = BSON("$dateFromString" << BSON("dateString" + << "2017-07-13T10:02:57" + << "format" + << 2)); + auto dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); + ASSERT_THROWS_CODE(dateExp->evaluate({}), AssertionException, 40684); + + spec = BSON("$dateFromString" << BSON("dateString" + << "July 4, 2017" + << "format" + << true)); + dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); + ASSERT_THROWS_CODE(dateExp->evaluate({}), AssertionException, 40684); +} + +TEST_F(ExpressionDateFromStringTest, RejectsStringsThatDoNotMatchFormat) { + auto expCtx = getExpCtx(); + + auto spec = BSON("$dateFromString" << BSON("dateString" + << "2017-07" + << "format" + << "%Y-%m-%d")); + auto dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); + ASSERT_THROWS_CODE(dateExp->evaluate({}), AssertionException, 40553); + + spec = BSON("$dateFromString" << BSON("dateString" + << "2017-07" + << "format" + << "%m-%Y")); + dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); + ASSERT_THROWS_CODE(dateExp->evaluate({}), AssertionException, 40553); +} + +TEST_F(ExpressionDateFromStringTest, EscapeCharacterAllowsPrefixUsage) { + auto expCtx = getExpCtx(); + + auto spec = BSON("$dateFromString" << BSON("dateString" + << "2017 % 01 % 01" + << "format" + << "%Y %% %m %% %d")); + auto dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); + ASSERT_EQ("2017-01-01T00:00:00.000Z", dateExp->evaluate(Document{}).toString()); +} + + +TEST_F(ExpressionDateFromStringTest, EvaluatesToNullIfFormatIsNullish) { + auto expCtx = getExpCtx(); + + auto spec = BSON("$dateFromString" << BSON("dateString" + << "1/1/2017" + << "format" + << BSONNULL)); + auto dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); + ASSERT_VALUE_EQ(Value(BSONNULL), dateExp->evaluate(Document{})); + + spec = BSON("$dateFromString" << BSON("dateString" + << "1/1/2017" + << "format" + << "$missing")); + dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); + ASSERT_VALUE_EQ(Value(BSONNULL), dateExp->evaluate(Document{})); + + spec = BSON("$dateFromString" << BSON("dateString" + << "1/1/2017" + << "format" + << BSONUndefined)); + dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); + ASSERT_VALUE_EQ(Value(BSONNULL), dateExp->evaluate(Document{})); +} + TEST_F(ExpressionDateFromStringTest, ReadWithUTCOffset) { auto expCtx = getExpCtx(); @@ -5297,40 +5406,83 @@ TEST_F(ExpressionDateFromStringTest, ReadWithUTCOffset) { << "timezone" << "-01:00")); auto dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); - auto dateVal = Date_t::fromMillisSinceEpoch(1501242472912); - ASSERT_VALUE_EQ(Value(dateVal), dateExp->evaluate(Document{})); + ASSERT_EQ("2017-07-28T11:47:52.912Z", dateExp->evaluate(Document{}).toString()); spec = BSON("$dateFromString" << BSON("dateString" << "2017-07-28T10:47:52.912" << "timezone" << "+01:00")); dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); - dateVal = Date_t::fromMillisSinceEpoch(1501235272912); - ASSERT_VALUE_EQ(Value(dateVal), dateExp->evaluate(Document{})); + ASSERT_EQ("2017-07-28T09:47:52.912Z", dateExp->evaluate(Document{}).toString()); spec = BSON("$dateFromString" << BSON("dateString" << "2017-07-28T10:47:52.912" << "timezone" << "+0445")); dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); - dateVal = Date_t::fromMillisSinceEpoch(1501221772912); - ASSERT_VALUE_EQ(Value(dateVal), dateExp->evaluate(Document{})); + ASSERT_EQ("2017-07-28T06:02:52.912Z", dateExp->evaluate(Document{}).toString()); spec = BSON("$dateFromString" << BSON("dateString" << "2017-07-28T10:47:52.912" << "timezone" << "+10:45")); dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); - dateVal = Date_t::fromMillisSinceEpoch(1501200172912); - ASSERT_VALUE_EQ(Value(dateVal), dateExp->evaluate(Document{})); + ASSERT_EQ("2017-07-28T00:02:52.912Z", dateExp->evaluate(Document{}).toString()); spec = BSON("$dateFromString" << BSON("dateString" << "1945-07-28T10:47:52.912" << "timezone" << "-08:00")); dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); - dateVal = Date_t::fromMillisSinceEpoch(-770879527088); - ASSERT_VALUE_EQ(Value(dateVal), dateExp->evaluate(Document{})); + ASSERT_EQ("1945-07-28T18:47:52.912Z", dateExp->evaluate(Document{}).toString()); +} + +TEST_F(ExpressionDateFromStringTest, ConvertStringWithUTCOffsetAndFormat) { + auto expCtx = getExpCtx(); + + auto spec = BSON("$dateFromString" << BSON("dateString" + << "10:47:52.912 on 7/28/2017" + << "timezone" + << "-01:00" + << "format" + << "%H:%M:%S.%L on %m/%d/%Y")); + auto dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); + ASSERT_EQ("2017-07-28T11:47:52.912Z", dateExp->evaluate(Document{}).toString()); + + spec = BSON("$dateFromString" << BSON("dateString" + << "10:47:52.912 on 7/28/2017" + << "timezone" + << "+01:00" + << "format" + << "%H:%M:%S.%L on %m/%d/%Y")); + dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); + ASSERT_EQ("2017-07-28T09:47:52.912Z", dateExp->evaluate(Document{}).toString()); +} + +TEST_F(ExpressionDateFromStringTest, ConvertStringWithISODateFormat) { + auto expCtx = getExpCtx(); + + auto spec = BSON("$dateFromString" << BSON("dateString" + << "Day 7 Week 53 Year 2017" + << "format" + << "Day %u Week %V Year %G")); + auto dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); + ASSERT_EQ("2018-01-07T00:00:00.000Z", dateExp->evaluate(Document{}).toString()); + + // Week and day of week default to '1' if not specified. + spec = BSON("$dateFromString" << BSON("dateString" + << "Week 53 Year 2017" + << "format" + << "Week %V Year %G")); + dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); + ASSERT_EQ("2018-01-01T00:00:00.000Z", dateExp->evaluate(Document{}).toString()); + + spec = BSON("$dateFromString" << BSON("dateString" + << "Day 7 Year 2017" + << "format" + << "Day %u Year %G")); + dateExp = Expression::parseExpression(expCtx, spec, expCtx->variablesParseState); + ASSERT_EQ("2017-01-08T00:00:00.000Z", dateExp->evaluate(Document{}).toString()); } } // namespace ExpressionDateFromStringTest diff --git a/src/mongo/db/query/datetime/date_time_support.cpp b/src/mongo/db/query/datetime/date_time_support.cpp index 2b6cb8ec078..c41df5b28d6 100644 --- a/src/mongo/db/query/datetime/date_time_support.cpp +++ b/src/mongo/db/query/datetime/date_time_support.cpp @@ -65,6 +65,67 @@ long long seconds(Date_t date) { return durationCount<Seconds>(Milliseconds(millis)); } +// +// Format specifier map when parsing a date from a string with a required format. +// +const std::vector<timelib_format_specifier> kDateFromStringFormatMap = { + {'d', TIMELIB_FORMAT_DAY_TWO_DIGIT}, + {'G', TIMELIB_FORMAT_YEAR_ISO}, + {'H', TIMELIB_FORMAT_HOUR_TWO_DIGIT_24_MAX}, + {'L', TIMELIB_FORMAT_MILLISECOND_THREE_DIGIT}, + {'m', TIMELIB_FORMAT_MONTH_TWO_DIGIT}, + {'M', TIMELIB_FORMAT_MINUTE_TWO_DIGIT}, + {'S', TIMELIB_FORMAT_SECOND_TWO_DIGIT}, + {'u', TIMELIB_FORMAT_DAY_OF_WEEK_ISO}, + {'V', TIMELIB_FORMAT_WEEK_OF_YEAR_ISO}, + {'Y', TIMELIB_FORMAT_YEAR_FOUR_DIGIT}, + {'z', TIMELIB_FORMAT_TIMEZONE_OFFSET}, + {'Z', TIMELIB_FORMAT_TIMEZONE_OFFSET_MINUTES}, + {'\0', TIMELIB_FORMAT_END}}; + +// +// Format specifier map when converting a date to a string. +// +const std::vector<timelib_format_specifier> kDateToStringFormatMap = { + {'d', TIMELIB_FORMAT_DAY_TWO_DIGIT}, + {'G', TIMELIB_FORMAT_YEAR_ISO}, + {'H', TIMELIB_FORMAT_HOUR_TWO_DIGIT_24_MAX}, + {'j', TIMELIB_FORMAT_DAY_OF_YEAR}, + {'L', TIMELIB_FORMAT_MILLISECOND_THREE_DIGIT}, + {'m', TIMELIB_FORMAT_MONTH_TWO_DIGIT}, + {'M', TIMELIB_FORMAT_MINUTE_TWO_DIGIT}, + {'S', TIMELIB_FORMAT_SECOND_TWO_DIGIT}, + {'w', TIMELIB_FORMAT_DAY_OF_WEEK}, + {'u', TIMELIB_FORMAT_DAY_OF_WEEK_ISO}, + {'U', TIMELIB_FORMAT_WEEK_OF_YEAR}, + {'V', TIMELIB_FORMAT_WEEK_OF_YEAR_ISO}, + {'Y', TIMELIB_FORMAT_YEAR_FOUR_DIGIT}, + {'z', TIMELIB_FORMAT_TIMEZONE_OFFSET}, + {'Z', TIMELIB_FORMAT_TIMEZONE_OFFSET_MINUTES}}; + + +// Verifies that any '%' is followed by a valid format character as indicated by 'allowedFormats', +// and that the 'format' string ends with an even number of '%' symbols. +void validateFormat(StringData format, + const std::vector<timelib_format_specifier>& allowedFormats) { + for (auto it = format.begin(); it != format.end(); ++it) { + if (*it != '%') { + continue; + } + + ++it; // next character must be format modifier + uassert(18535, "Unmatched '%' at end of format string", it != format.end()); + + const bool validSpecifier = (*it == '%') || + std::find_if(allowedFormats.begin(), allowedFormats.end(), [=](const auto& format) { + return format.specifier == *it; + }) != allowedFormats.end(); + uassert(18536, + str::stream() << "Invalid format character '%" << *it << "' in format string", + validSpecifier); + } +} + } // namespace const TimeZoneDatabase* TimeZoneDatabase::get(ServiceContext* serviceContext) { @@ -133,17 +194,37 @@ static timelib_tzinfo* timezonedatabase_gettzinfowrapper(char* tz_id, return nullptr; } -Date_t TimeZoneDatabase::fromString(StringData dateString, boost::optional<TimeZone> tz) const { +Date_t TimeZoneDatabase::fromString(StringData dateString, + boost::optional<TimeZone> tz, + boost::optional<StringData> format) const { std::unique_ptr<timelib_error_container, TimeZoneDatabase::TimelibErrorContainerDeleter> errors{}; timelib_error_container* rawErrors; - std::unique_ptr<timelib_time, TimeZone::TimelibTimeDeleter> parsedTime( - timelib_strtotime(const_cast<char*>(dateString.toString().c_str()), - dateString.size(), - &rawErrors, - _timeZoneDatabase.get(), - timezonedatabase_gettzinfowrapper)); + timelib_time* rawTime; + if (!format) { + // Without a format, timelib will attempt to parse a string as best as it can, accepting a + // variety of formats. + rawTime = timelib_strtotime(const_cast<char*>(dateString.rawData()), + dateString.size(), + &rawErrors, + _timeZoneDatabase.get(), + timezonedatabase_gettzinfowrapper); + } else { + const timelib_format_config dateFormatConfig = { + &kDateFromStringFormatMap[0], + // Format specifiers must be prefixed by '%'. + '%'}; + rawTime = timelib_parse_from_format_with_map(const_cast<char*>(format->rawData()), + const_cast<char*>(dateString.rawData()), + dateString.size(), + &rawErrors, + _timeZoneDatabase.get(), + timezonedatabase_gettzinfowrapper, + &dateFormatConfig); + } + std::unique_ptr<timelib_time, TimeZone::TimelibTimeDeleter> parsedTime(rawTime); + errors.reset(rawErrors); // If the parsed string has a warning or error, throw an error. @@ -458,41 +539,12 @@ Seconds TimeZone::utcOffset(Date_t date) const { return Seconds(time->z); } -void TimeZone::validateFormat(StringData format) { - for (auto it = format.begin(); it != format.end(); ++it) { - if (*it != '%') { - continue; - } +void TimeZone::validateToStringFormat(StringData format) { + return validateFormat(format, kDateToStringFormatMap); +} - ++it; // next character must be format modifier - uassert(18535, "Unmatched '%' at end of $dateToString format string", it != format.end()); - - - switch (*it) { - // all of these fall through intentionally - case '%': - case 'Y': - case 'm': - case 'd': - case 'H': - case 'M': - case 'S': - case 'L': - case 'j': - case 'w': - case 'U': - case 'G': - case 'V': - case 'u': - case 'z': - case 'Z': - break; - default: - uasserted(18536, - str::stream() << "Invalid format character '%" << *it - << "' in $dateToString format string"); - } - } +void TimeZone::validateFromStringFormat(StringData format) { + return validateFormat(format, kDateFromStringFormatMap); } std::string TimeZone::formatDate(StringData format, Date_t date) const { diff --git a/src/mongo/db/query/datetime/date_time_support.h b/src/mongo/db/query/datetime/date_time_support.h index 9d5b3188abc..36125268532 100644 --- a/src/mongo/db/query/datetime/date_time_support.h +++ b/src/mongo/db/query/datetime/date_time_support.h @@ -271,9 +271,10 @@ public: /** * Verifies that any '%' is followed by a valid format character, and that 'format' string - * ends with an even number of '%' symbols + * ends with an even number of '%' symbols. */ - static void validateFormat(StringData format); + static void validateToStringFormat(StringData format); + static void validateFromStringFormat(StringData format); private: std::unique_ptr<_timelib_time, TimelibTimeDeleter> getTimelibTime(Date_t) const; @@ -358,11 +359,15 @@ public: std::unique_ptr<TimeZoneDatabase> timeZoneDatabase); /** - * Constructs a Date_t from a string description of a date. + * Constructs a Date_t from a string description of a date, with an optional format specifier + * string. * * 'dateString' may contain time zone information if the information is simply an offset from * UTC, in which case the returned Date_t will be adjusted accordingly. * + * The supported format specifiers for the 'format' string are listed in + * kDateFromStringFormatMap. + * * Throws a AssertionException if any of the following occur: * * The string cannot be parsed into a date. * * The string specifies a time zone that is not simply an offset from UTC, like @@ -371,8 +376,11 @@ public: * string '2017-07-04T00:00:00Z'. * * 'tz' is provided, but 'dateString' specifies an offset from UTC, like '-0400' * in the string '2017-07-04 -0400'. + * * The string does not match the 'format' specifier. */ - Date_t fromString(StringData dateString, boost::optional<TimeZone> tz) const; + Date_t fromString(StringData dateString, + boost::optional<TimeZone> tz, + boost::optional<StringData> format = boost::none) const; /** * Returns a TimeZone object representing the UTC time zone. diff --git a/src/mongo/db/query/datetime/date_time_support_test.cpp b/src/mongo/db/query/datetime/date_time_support_test.cpp index 96d1327c773..aeccf4030fa 100644 --- a/src/mongo/db/query/datetime/date_time_support_test.cpp +++ b/src/mongo/db/query/datetime/date_time_support_test.cpp @@ -37,6 +37,7 @@ namespace mongo { namespace { const TimeZoneDatabase kDefaultTimeZoneDatabase{}; +const TimeZone kDefaultTimeZone = TimeZoneDatabase::utcZone(); TEST(GetTimeZone, DoesReturnKnownTimeZone) { // Just asserting that these do not throw exceptions. @@ -939,15 +940,28 @@ TEST(NewYorkTimeAfterEpoch, DoesOutputFormatDate) { } TEST(DateFormat, ThrowsUserExceptionIfGivenUnrecognizedFormatter) { - ASSERT_THROWS_CODE(TimeZoneDatabase::utcZone().validateFormat("%x"), AssertionException, 18536); + ASSERT_THROWS_CODE( + TimeZoneDatabase::utcZone().validateToStringFormat("%x"), AssertionException, 18536); + ASSERT_THROWS_CODE( + TimeZoneDatabase::utcZone().validateFromStringFormat("%x"), AssertionException, 18536); } TEST(DateFormat, ThrowsUserExceptionIfGivenUnmatchedPercent) { - ASSERT_THROWS_CODE(TimeZoneDatabase::utcZone().validateFormat("%"), AssertionException, 18535); ASSERT_THROWS_CODE( - TimeZoneDatabase::utcZone().validateFormat("%%%"), AssertionException, 18535); + TimeZoneDatabase::utcZone().validateToStringFormat("%"), AssertionException, 18535); + ASSERT_THROWS_CODE( + TimeZoneDatabase::utcZone().validateToStringFormat("%%%"), AssertionException, 18535); + ASSERT_THROWS_CODE( + TimeZoneDatabase::utcZone().validateToStringFormat("blahblah%"), AssertionException, 18535); + + // Repeat the tests with the format map for $dateFromString. + ASSERT_THROWS_CODE( + TimeZoneDatabase::utcZone().validateFromStringFormat("%"), AssertionException, 18535); ASSERT_THROWS_CODE( - TimeZoneDatabase::utcZone().validateFormat("blahblah%"), AssertionException, 18535); + TimeZoneDatabase::utcZone().validateFromStringFormat("%%%"), AssertionException, 18535); + ASSERT_THROWS_CODE(TimeZoneDatabase::utcZone().validateFromStringFormat("blahblah%"), + AssertionException, + 18535); } TEST(DateFormat, ThrowsUserExceptionIfGivenDateBeforeYear0) { @@ -976,5 +990,113 @@ TEST(DateFormat, ThrowsUserExceptionIfGivenDateAfterYear9999) { TimeZoneDatabase::utcZone().formatDate("%G", Date_t::max()), AssertionException, 18537); } +TEST(DateFromString, CorrectlyParsesStringThatMatchesFormat) { + auto input = "2017-07-04T10:56:02Z"; + auto format = "%Y-%m-%dT%H:%M:%SZ"_sd; + auto date = kDefaultTimeZoneDatabase.fromString(input, kDefaultTimeZone, format); + ASSERT_EQ(TimeZoneDatabase::utcZone().formatDate(format, date), input); +} + +TEST(DateFromString, RejectsStringWithInvalidYearFormat) { + ASSERT_THROWS_CODE(kDefaultTimeZoneDatabase.fromString("201", kDefaultTimeZone, "%Y"_sd), + AssertionException, + 40545); + ASSERT_THROWS_CODE(kDefaultTimeZoneDatabase.fromString("20i7", kDefaultTimeZone, "%Y"_sd), + AssertionException, + 40553); +} + +TEST(DateFromString, RejectsStringWithInvalidMinuteFormat) { + // Minute must be 2 digits with leading zero. + ASSERT_THROWS_CODE(kDefaultTimeZoneDatabase.fromString( + "2017-01-01T00:1:00", kDefaultTimeZone, "%Y-%m-%dT%H%M%S"_sd), + AssertionException, + 40553); + ASSERT_THROWS_CODE(kDefaultTimeZoneDatabase.fromString( + "2017-01-01T00:0i:00", kDefaultTimeZone, "%Y-%m-%dT%H%M%S"_sd), + AssertionException, + 40553); +} + +TEST(DateFromString, RejectsStringWithInvalidSecondsFormat) { + // Seconds must be 2 digits with leading zero. + ASSERT_THROWS_CODE(kDefaultTimeZoneDatabase.fromString( + "2017-01-01T00:00:1", kDefaultTimeZone, "%Y-%m-%dT%H%M%S"_sd), + AssertionException, + 40553); + ASSERT_THROWS_CODE(kDefaultTimeZoneDatabase.fromString( + "2017-01-01T00:00:i0", kDefaultTimeZone, "%Y-%m-%dT%H%M%S"_sd), + AssertionException, + 40553); +} + +TEST(DateFromString, RejectsStringWithInvalidMillisecondsFormat) { + ASSERT_THROWS_CODE(kDefaultTimeZoneDatabase.fromString( + "2017-01-01T00:00:00.i", kDefaultTimeZone, "%Y-%m-%dT%H:%M:%S.%L"_sd), + AssertionException, + 40553); +} + +TEST(DateFromString, RejectsStringWithInvalidISOYear) { + ASSERT_THROWS_CODE(kDefaultTimeZoneDatabase.fromString("20i7", kDefaultTimeZone, "%G"_sd), + AssertionException, + 40553); +} + +TEST(DateFromString, RejectsStringWithInvalidISOWeekOfYear) { + // ISO week of year must be between 1 and 53. + ASSERT_THROWS_CODE(kDefaultTimeZoneDatabase.fromString("2017-55", kDefaultTimeZone, "%G-%V"_sd), + AssertionException, + 40553); + ASSERT_THROWS_CODE(kDefaultTimeZoneDatabase.fromString("2017-FF", kDefaultTimeZone, "%G-%V"_sd), + AssertionException, + 40553); +} + +TEST(DateFromString, RejectsStringWithInvalidISODayOfWeek) { + // Day of week must be single digit between 1 and 7. + ASSERT_THROWS_CODE(kDefaultTimeZoneDatabase.fromString("2017-8", kDefaultTimeZone, "%G-%u"_sd), + AssertionException, + 40553); + ASSERT_THROWS_CODE(kDefaultTimeZoneDatabase.fromString("2017-0", kDefaultTimeZone, "%G-%u"_sd), + AssertionException, + 40553); + ASSERT_THROWS_CODE(kDefaultTimeZoneDatabase.fromString("2017-a", kDefaultTimeZone, "%G-%u"_sd), + AssertionException, + 40553); + ASSERT_THROWS_CODE(kDefaultTimeZoneDatabase.fromString("2017-11", kDefaultTimeZone, "%G-%u"_sd), + AssertionException, + 40553); + ASSERT_THROWS_CODE( + kDefaultTimeZoneDatabase.fromString("2017-123", kDefaultTimeZone, "%G-%u"_sd), + AssertionException, + 40553); +} + +TEST(DateFromString, RejectsStringWithInvalidTimezoneOffset) { + // Timezone offset minutes (%Z) requires format +/-mmm. + ASSERT_THROWS_CODE( + kDefaultTimeZoneDatabase.fromString("2017 500", kDefaultTimeZone, "%G %Z"_sd), + AssertionException, + 40553); + ASSERT_THROWS_CODE( + kDefaultTimeZoneDatabase.fromString("2017 0500", kDefaultTimeZone, "%G %Z"_sd), + AssertionException, + 40553); + ASSERT_THROWS_CODE( + kDefaultTimeZoneDatabase.fromString("2017 +i00", kDefaultTimeZone, "%G %Z"_sd), + AssertionException, + 40553); +} + +TEST(DateFromString, EmptyFormatStringThrowsForAllInputs) { + ASSERT_THROWS_CODE(kDefaultTimeZoneDatabase.fromString("1/1/2017", kDefaultTimeZone, ""_sd), + AssertionException, + 40553); + ASSERT_THROWS_CODE(kDefaultTimeZoneDatabase.fromString("", kDefaultTimeZone, ""_sd), + AssertionException, + 40545); +} + } // namespace } // namespace mongo |