Calculate date difference in year, month, day

Viewed 8274

I have the following query:

db.getCollection('user').aggregate([
   {$unwind: "$education"},
   {$project: {
      duration: {"$divide":[{$subtract: ['$education.to', '$education.from'] }, 1000 * 60 * 60 * 24 * 365]}
   }},
   {$group: {
     _id: '$_id',
     "duration": {$sum: '$duration'}  
   }}]
])

Above query result is:

{
    "_id" : ObjectId("59fabb20d7905ef056f55ac1"),
    "duration" : 2.34794520547945
}

/* 2 */
{
    "_id" : ObjectId("59fab630203f02f035301fc3"),
    "duration" : 2.51232876712329
}

But what I want to do is get its duration in year+ month + day format, something like: 2 y, 3 m, 20 d. One another point, if a course is going on the to field is null, and another field isGoingOn: true, so here I should calculate the duration by using current date instead of to field. And user has array of course subdocuments

education: [
   {
      "courseName": "Java",
      "from" : ISODate("2010-12-08T00:00:00.000Z"),
      "to" : ISODate("2011-05-31T00:00:00.000Z"), 
      "isGoingOn": false
   },
   {
      "courseName": "PHP",
      "from" : ISODate("2013-12-08T00:00:00.000Z"),
      "to" : ISODate("2015-05-31T00:00:00.000Z"), 
      "isGoingOn": false
   },
   {
      "courseName": "Mysql",
      "from" : ISODate("2017-02-08T00:00:00.000Z"),
      "to" : null, 
      "isGoingOn": true
   }
]

One another point is this: that date may be not continuous in one subdocument to the other subdocument. A user may have a course for 1 year, and then after two years, he/she started his/her next course for 1 year, and 3 months (it means this user has a total of 2 years and 3-month course duration). What I want is get date difference of each subdocument in educations array, and sum those. Suppose in my sample data Java course duration is 6 month, and 22 days, PHP course duration is 1 year, and 6 months, and 22 days, and the last one is from 8 Feb 2017 till now, and it's going on, so my education duration is the sum of these intervals.

3 Answers

Well you could just simply use the existing date aggregation operators as opposed to using math to convert to "days" as you presently have:

db.getCollection('user').aggregate([
  { "$unwind": "$education" },
  { "$group": {
    "_id": "$_id",
    "years": {
      "$sum": {
        "$subtract": [
          { "$subtract": [
            { "$year": { "$ifNull": [ "$education.to", new Date() ] } },
            { "$year": "$education.from" }
          ]},
          { "$cond": {
            "if": {
              "$gt": [
                { "$month": { "$ifNull": [ "$education.to", new Date() ] } },
                { "$month": "$education.from" }
              ]
            },
            "then": 0,
            "else": 1
          }}
        ]
      }
    },
    "months": {
      "$sum": {
        "$add": [
          { "$subtract": [
            { "$month": { "$ifNull": [ "$education.to", new Date() ] } },
            { "$month": "$education.from" }
          ]},
          { "$cond": {
            "if": {
              "$gt": [
                { "$month": { "$ifNull": ["$education.to", new Date() ] } },
                { "$month": "$education.from" }
              ]
            },
            "then": 0,
            "else": 12
          }}
        ]
      }
    },
    "days": {
      "$sum": {
        "$add": [
          { "$subtract": [
            { "$dayOfYear": { "$ifNull": [ "$education.to", new Date() ] } },
            { "$dayOfYear": "$education.from" }
          ]},
          { "$cond": {
            "if": {
              "$gt": [
                { "$month": { "$ifNull": [ "$education.to", new Date() ] } },
                { "$month": "$education.from" }
              ]
            },
            "then": 0,
            "else": 365
          }}
        ]
      }
    }
  }},
  { "$project": {
    "years": {
      "$add": [
        "$years",
        { "$add": [
          { "$floor": { "$divide": [ "$months", 12 ] } },
          { "$floor": { "$divide": [ "$days", 365 ] } }
        ]}
      ]
    },
    "months": {
      "$mod": [
        { "$add": [
          "$months",
          { "$floor": {
            "$multiply": [
              { "$divide": [ "$days", 365 ] },
              12
            ]
          }}
        ]},
        12
      ]
    },
    "days": { "$mod": [ "$days", 365 ] }
  }}
])

It is "sort of" an approximation on the "days" and "months" without the necessary operations to be "certain" of leap years, but it would get you the result which should be "near enough" for most purposes.

You can even do this without $unwind as long as your MongoDB version is 3.2 or greater:

db.getCollection('user').aggregate([
  { "$addFields": {
    "duration": {
      "$let": {
        "vars": {
          "edu": {
            "$map": {
              "input": "$education",
              "as": "e",
              "in": {
                "$let": {
                  "vars": { "toDate": { "$ifNull": ["$$e.to", new Date()] } },
                  "in": {
                    "years": {
                      "$subtract": [
                        { "$subtract": [
                          { "$year": "$$toDate" },
                          { "$year": "$$e.from" }   
                        ]},
                        { "$cond": {
                          "if": { "$gt": [{ "$month": "$$toDate" },{ "$month": "$$e.from" }] },
                          "then": 0,
                          "else": 1
                        }}
                      ]
                    },
                    "months": {
                      "$add": [
                        { "$subtract": [
                          { "$ifNull": [{ "$month": "$$toDate" }, new Date() ] },
                          { "$month": "$$e.from" }
                        ]},
                        { "$cond": {
                          "if": { "$gt": [{ "$month": "$$toDate" },{ "$month": "$$e.from" }] },
                          "then": 0,
                          "else": 12
                        }}
                      ]
                    },
                    "days": {
                      "$add": [
                        { "$subtract": [
                          { "$ifNull": [{ "$dayOfYear": "$$toDate" }, new Date() ] },
                          { "$dayOfYear": "$$e.from" }
                        ]},
                        { "$cond": {
                          "if": { "$gt": [{ "$month": "$$toDate" },{ "$month": "$$e.from" }] },
                          "then": 0,
                          "else": 365
                        }}
                      ]
                    }
                  }
                }
              }
            }    
          }
        },
        "in": {
          "$let": {
            "vars": {
              "years": { "$sum": "$$edu.years" },
              "months": { "$sum": "$$edu.months" },
              "days": { "$sum": "$$edu.days" }    
            },
            "in": {
              "years": {
                "$add": [
                  "$$years",
                  { "$add": [
                    { "$floor": { "$divide": [ "$$months", 12 ] } },
                    { "$floor": { "$divide": [ "$$days", 365 ] } }
                  ]}
                ]
              },
              "months": {
                "$mod": [
                  { "$add": [
                    "$$months",
                    { "$floor": {
                      "$multiply": [
                        { "$divide": [ "$$days", 365 ] },
                        12
                      ]
                    }}
                  ]},
                  12
                ]
              },
              "days": { "$mod": [ "$$days", 365 ] }
            }
          }
        }
      }
    }
  }}
]) 

This is because from MongoDB 3.4 you can use $sum directly with an array of or any list of expressions in stages like $addFields or $project, and the $map can apply those same "date aggregation operator" expressions against each array element in place of doing $unwind first.

So the main math can really be done in one part of "reducing" the array, and then each total can be adjusted by the general "divisors" for the years, and the "modulo" or "remainder" from any overruns in the months and days.

Essentially returns:

{
    "_id" : ObjectId("5a07688e98e4471d8aa87940"),
    "education" : [ 
        {
            "courseName" : "Java",
            "from" : ISODate("2010-12-08T00:00:00.000Z"),
            "to" : ISODate("2011-05-31T00:00:00.000Z"),
            "isGoingOn" : false
        }, 
        {
            "courseName" : "PHP",
            "from" : ISODate("2013-12-08T00:00:00.000Z"),
            "to" : ISODate("2015-05-31T00:00:00.000Z"),
            "isGoingOn" : false
        }, 
        {
            "courseName" : "Mysql",
            "from" : ISODate("2017-02-08T00:00:00.000Z"),
            "to" : null,
            "isGoingOn" : true
        }
    ],
    "duration" : {
        "years" : 3.0,
        "months" : 3.0,
        "days" : 259.0
    }
}

Given the 11th of November 2017

Related