Агрегат MongoDB в ежедневной группировке

У меня есть документы на mongo, которые выглядят примерно так:

{
  _id : ObjectId("..."),
  "make" : "Nissan",
  ..
},
{
  _id : ObjectId("..."),
  "make" : "Nissan",
  "saleDate" :  ISODate("2013-04-10T12:39:50.676Z"),
  ..
}

В идеале я хотел бы иметь возможность подсчитывать количество автомобилей, проданных за день, по маркам. Затем я хотел бы просмотреть либо сегодня, либо окно, например, сегодня, в течение последних семи дней.

Я смог выполнить ежедневное представление с каким-то уродливым кодом

db.inventory.aggregate(
  { $match : { "saleDate" : { $gte: ISODate("2013-04-10T00:00:00.000Z"), $lt: ISODate("2013-04-11T00:00:00.000Z")  } } } ,
  { $group : { _id : { make : "$make", saleDayOfMonth : { $dayOfMonth : "$saleDate" } }, cnt : { $sum : 1 } } }
)

Что затем дает результаты

{
  "result" : [
    {
      "_id" : {
        "make" : "Nissan",
        "saleDayOfMonth" : 10
      },
      "cnt" : 2
    },
    {
      "_id" : {
        "make" : "Toyota",
        "saleDayOfMonth" : 10
      },
      "cnt" : 4
    },
  ],
  "ok" : 1
}

Так что это нормально, но я бы предпочел не изменять два значения datetime в запросе. Затем, как я упоминал выше, я хотел бы иметь возможность запускать этот запрос (опять же, без необходимости изменять его каждый раз) и видеть те же результаты, сгруппированные по дням за последнюю неделю.

О, и вот образцы данных, которые я использовал для запроса

db.inventory.save({"make" : "Nissan","saleDate" :  ISODate("2013-04-10T12:39:50.676Z")});
db.inventory.save({"make" : "Nissan"});
db.inventory.save({"make" : "Nissan","saleDate" :  ISODate("2013-04-10T11:39:50.676Z")});
db.inventory.save({"make" : "Toyota","saleDate" :  ISODate("2013-04-09T11:39:50.676Z")});
db.inventory.save({"make" : "Toyota","saleDate" :  ISODate("2013-04-10T11:38:50.676Z")});
db.inventory.save({"make" : "Toyota","saleDate" :  ISODate("2013-04-10T11:37:50.676Z")});
db.inventory.save({"make" : "Toyota","saleDate" :  ISODate("2013-04-10T11:36:50.676Z")});
db.inventory.save({"make" : "Toyota","saleDate" :  ISODate("2013-04-10T11:35:50.676Z")});

Заранее спасибо, Кевин


person Kevin    schedule 11.04.2013    source источник
comment
Что означает 676Z?   -  person Aboozar Rajabi    schedule 18.03.2017
comment
Просто обновление (с 2017 года, вау, этот вопрос старый ...), я изменил принятый ответ, поскольку структура Mongo эволюционировала, чтобы решить эту проблему намного проще. Тем не менее, спасибо Асе за ее оригинальный ответ. @AboozarRajabi, 676Z является необязательной частью формата времени ISO 8601, в данном случае 676Z представляет две части 2013-04-10T11: 35: 50.676Z, первые 676 - миллисекунды, а Z - ярлык для обозначения часового пояса UTC.   -  person Kevin    schedule 19.03.2017
comment
На самом деле, он развился еще дальше, и с версии 3.6 (выпущенной в 2017 году) вам больше не нужно преобразовывать дату в строку, чтобы сделать это, не говоря уже о том, что я просто заметил, что никто из нас не включил вторую часть вашего вопроса, а именно, как для просмотра окна, например, сегодня и за последние семь дней - последние семь дней, особенно включая дни, когда не было продаж, немного отличается от базовой части этого вопроса.   -  person Asya Kamsky    schedule 08.04.2018
comment
Я обновляю свой ответ на основе версии 3.6, включая то, как добавить даты, когда ничего не произошло (0 продаж).   -  person Asya Kamsky    schedule 08.04.2018
comment
@Kevin, вам действительно следует пересмотреть решение об отмене принятого ответа.   -  person Asya Kamsky    schedule 02.04.2020


Ответы (3)


В Mongo 2.8 RC2 появился новый оператор агрегирования данных: $ dateToString который можно использовать для группировки по дням и просто иметь в результате "ГГГГ-ММ-ДД":

Пример из документации:

db.sales.aggregate(
  [
     {
         $project: {
                yearMonthDay: { $dateToString: { format: "%Y-%m-%d", date: "$date" } },
                time: { $dateToString: { format: "%H:%M:%S:%L", date: "$date" } }
         }
     }
  ]
)

приведет к:

{ "_id" : 1, "yearMonthDay" : "2014-01-01", "time" : "08:15:39:736" }
person ephigenia    schedule 22.12.2014
comment
Согласно документации, $ dateToString возвращает строку, поэтому вы можете потерять некоторую возможность, которая у вас будет с объектом даты - person Constantin Guay; 02.12.2015
comment
Извините за удар, но как на это влияют часовые пояса? Если я правильно прочитал, это будет сгруппировано по UTC yearMonthDay. Есть идеи, как учитывать зоны? - person Sivli; 20.11.2016
comment
Начиная с версии 3.6 поддерживается преобразование часового пояса в различных операторах даты. - person Asya Kamsky; 28.03.2018
comment
вместо $ project вы можете использовать $ addFields, а затем создать новое поле в документе в конвейере, указав только дату без времени. Это может быть полезно для $ group на дате, т. Е. Для поиска последней записи дня и т. Д. Операторы будущего конвейера могут продолжать использовать исходное поле «дата» для сортировки и т. Д. Без потери точности. - person barrypicker; 25.10.2019

ОБНОВЛЕНИЕ. Обновленный ответ основан на функциях даты в 3.6, а также показывает, как включать даты в диапазон, по которому не было продаж (что не было упомянуто ни в одном исходном ответе, включая мой).

Пример данных:

db.inventory.find()
{ "_id" : ObjectId("5aca30eefa1585de22d7095f"), "make" : "Nissan", "saleDate" : ISODate("2013-04-10T12:39:50.676Z") }
{ "_id" : ObjectId("5aca30eefa1585de22d70960"), "make" : "Nissan" }
{ "_id" : ObjectId("5aca30effa1585de22d70961"), "make" : "Nissan", "saleDate" : ISODate("2013-04-10T11:39:50.676Z") }
{ "_id" : ObjectId("5aca30effa1585de22d70962"), "make" : "Toyota", "saleDate" : ISODate("2013-04-09T11:39:50.676Z") }
{ "_id" : ObjectId("5aca30effa1585de22d70963"), "make" : "Toyota", "saleDate" : ISODate("2013-04-10T11:38:50.676Z") }
{ "_id" : ObjectId("5aca30effa1585de22d70964"), "make" : "Toyota", "saleDate" : ISODate("2013-04-10T11:37:50.676Z") }
{ "_id" : ObjectId("5aca30effa1585de22d70965"), "make" : "Toyota", "saleDate" : ISODate("2013-04-10T11:36:50.676Z") }
{ "_id" : ObjectId("5aca30effa1585de22d70966"), "make" : "Toyota", "saleDate" : ISODate("2013-04-10T11:35:50.676Z") }
{ "_id" : ObjectId("5aca30f9fa1585de22d70967"), "make" : "Toyota", "saleDate" : ISODate("2013-04-11T11:35:50.676Z") }
{ "_id" : ObjectId("5aca30fffa1585de22d70968"), "make" : "Toyota", "saleDate" : ISODate("2013-04-13T11:35:50.676Z") }
{ "_id" : ObjectId("5aca3921fa1585de22d70969"), "make" : "Honda", "saleDate" : ISODate("2013-04-13T00:00:00Z") }

Определение startDate и endDate как переменных и их использование в агрегировании:

startDate = ISODate("2013-04-08T00:00:00Z");
endDate = ISODate("2013-04-15T00:00:00Z");

db.inventory.aggregate([
  { $match : { "saleDate" : { $gte: startDate, $lt: endDate} } },
  {$addFields:{
     saleDate:{$dateFromParts:{
                  year:{$year:"$saleDate"},
                  month:{$month:"$saleDate"},
                  day:{$dayOfMonth:"$saleDate"}
     }},
     dateRange:{$map:{
        input:{$range:[0, {$subtract:[endDate,startDate]}, 1000*60*60*24]},
        in:{$add:[startDate, "$$this"]}
     }}
  }},
  {$unwind:"$dateRange"},
  {$group:{
     _id:"$dateRange", 
     sales:{$push:{$cond:[
                {$eq:["$dateRange","$saleDate"]},
                {make:"$make",count:1},
                {count:0}
     ]}}
  }},
  {$sort:{_id:1}},
  {$project:{
     _id:0,
     saleDate:"$_id",
     totalSold:{$sum:"$sales.count"},
     byBrand:{$arrayToObject:{$reduce:{
        input: {$filter:{input:"$sales",cond:"$$this.count"}},
        initialValue: {$map:{input:{$setUnion:["$sales.make"]}, in:{k:"$$this",v:0}}}, 
        in:{$let:{
           vars:{t:"$$this",v:"$$value"},
           in:{$map:{
              input:"$$v",
              in:{
                 k:"$$this.k",
                 v:{$cond:[
                     {$eq:["$$this.k","$$t.make"]},
                     {$add:["$$this.v","$$t.count"]},
                     "$$this.v"
                 ]}
              }
           }}
        }}
     }}}
  }}
])

На выборочных данных это дает результаты:

{ "saleDate" : ISODate("2013-04-08T00:00:00Z"), "totalSold" : 0, "byBrand" : {  } }
{ "saleDate" : ISODate("2013-04-09T00:00:00Z"), "totalSold" : 1, "byBrand" : { "Toyota" : 1 } }
{ "saleDate" : ISODate("2013-04-10T00:00:00Z"), "totalSold" : 6, "byBrand" : { "Nissan" : 2, "Toyota" : 4 } }
{ "saleDate" : ISODate("2013-04-11T00:00:00Z"), "totalSold" : 1, "byBrand" : { "Toyota" : 1 } }
{ "saleDate" : ISODate("2013-04-12T00:00:00Z"), "totalSold" : 0, "byBrand" : {  } }
{ "saleDate" : ISODate("2013-04-13T00:00:00Z"), "totalSold" : 2, "byBrand" : { "Honda" : 1, "Toyota" : 1 } }
{ "saleDate" : ISODate("2013-04-14T00:00:00Z"), "totalSold" : 0, "byBrand" : {  } }

Это агрегирование также может быть выполнено с двумя $group этапами и простым $project вместо $group и сложным $project. Вот:

db.inventory.aggregate([
   {$match : { "saleDate" : { $gte: startDate, $lt: endDate} } },
   {$addFields:{saleDate:{$dateFromParts:{year:{$year:"$saleDate"}, month:{$month:"$saleDate"}, day:{$dayOfMonth : "$saleDate" }}},dateRange:{$map:{input:{$range:[0, {$subtract:[endDate,startDate]}, 1000*60*60*24]},in:{$add:[startDate, "$$this"]}}}}},
   {$unwind:"$dateRange"},
   {$group:{
      _id:{date:"$dateRange",make:"$make"},
      count:{$sum:{$cond:[{$eq:["$dateRange","$saleDate"]},1,0]}}
   }},
   {$group:{
      _id:"$_id.date",
      total:{$sum:"$count"},
      byBrand:{$push:{k:"$_id.make",v:{$sum:"$count"}}}
   }},
   {$sort:{_id:1}},
   {$project:{
      _id:0,
      saleDate:"$_id",
      totalSold:"$total",
      byBrand:{$arrayToObject:{$filter:{input:"$byBrand",cond:"$$this.v"}}}
   }}
])

Те же результаты:

{ "saleDate" : ISODate("2013-04-08T00:00:00Z"), "totalSold" : 0, "byBrand" : { "Honda" : 0, "Toyota" : 0, "Nissan" : 0 } }
{ "saleDate" : ISODate("2013-04-09T00:00:00Z"), "totalSold" : 1, "byBrand" : { "Honda" : 0, "Nissan" : 0, "Toyota" : 1 } }
{ "saleDate" : ISODate("2013-04-10T00:00:00Z"), "totalSold" : 6, "byBrand" : { "Honda" : 0, "Toyota" : 4, "Nissan" : 2 } }
{ "saleDate" : ISODate("2013-04-11T00:00:00Z"), "totalSold" : 1, "byBrand" : { "Toyota" : 1, "Honda" : 0, "Nissan" : 0 } }
{ "saleDate" : ISODate("2013-04-12T00:00:00Z"), "totalSold" : 0, "byBrand" : { "Toyota" : 0, "Nissan" : 0, "Honda" : 0 } }
{ "saleDate" : ISODate("2013-04-13T00:00:00Z"), "totalSold" : 2, "byBrand" : { "Honda" : 1, "Toyota" : 1, "Nissan" : 0 } }
{ "saleDate" : ISODate("2013-04-14T00:00:00Z"), "totalSold" : 0, "byBrand" : { "Toyota" : 0, "Honda" : 0, "Nissan" : 0 } }

Исходный ответ, основанный на версии 2.6:

Возможно, вы захотите взглянуть на мою запись в блоге о том, как бороться с различными манипуляциями с датой в Aggregation Framework здесь.

Что вы можете сделать, так это использовать фазу $project для усечения ваших дат до ежедневного разрешения, а затем запустить агрегирование для всего набора данных (или только его части) и агрегировать по дате и производителю.

Предположим, вы хотите узнать, сколько автомобилей вы продали по маркам и датам в этом году:

match={"$match" : {
               "saleDate" : { "$gt" : new Date(2013,0,1) }
      }
};

proj1={"$project" : {
        "_id" : 0,
        "saleDate" : 1,
        "make" : 1,
        "h" : {
            "$hour" : "$saleDate"
        },
        "m" : {
            "$minute" : "$saleDate"
        },
        "s" : {
            "$second" : "$saleDate"
        },
        "ml" : {
            "$millisecond" : "$saleDate"
        }
    }
};

proj2={"$project" : {
        "_id" : 0,
        "make" : 1,
        "saleDate" : {
            "$subtract" : [
                "$saleDate",
                {
                    "$add" : [
                        "$ml",
                        {
                            "$multiply" : [
                                "$s",
                                1000
                            ]
                        },
                        {
                            "$multiply" : [
                                "$m",
                                60,
                                1000
                            ]
                        },
                        {
                            "$multiply" : [
                                "$h",
                                60,
                                60,
                                1000
                            ]
                        }
                    ]
                }
            ]
        }
    }
};

group={"$group" : {
        "_id" : {
            "m" : "$make",
            "d" : "$saleDate"
        },
        "count" : {
            "$sum" : 1
        }
    }
};

Теперь запуск агрегации дает вам:

db.inventory.aggregate(match, proj1, proj2, group)
{
    "result" : [
        {
            "_id" : {
                "m" : "Toyota",
                "d" : ISODate("2013-04-10T00:00:00Z")
            },
            "count" : 4
        },
        {
            "_id" : {
                "m" : "Toyota",
                "d" : ISODate("2013-04-09T00:00:00Z")
            },
            "count" : 1
        },
        {
            "_id" : {
                "m" : "Nissan",
                "d" : ISODate("2013-04-10T00:00:00Z")
            },
            "count" : 2
        }
    ],
    "ok" : 1
}

Вы можете добавить еще одну фазу {$ project}, чтобы улучшить результат, и вы можете добавить шаг {$ sort}, но в основном для каждой даты, для каждого make вы получаете счет того, сколько было продано.

person Asya Kamsky    schedule 11.04.2013
comment
Ася, спасибо, это именно то, что я искала. Я тоже боролся с разрывом функций, так что это действительно помогает. Еще раз спасибо. - person Kevin; 16.04.2013
comment
Для будущих читателей цель первого прогноза - извлечь часы / минуты / секунды, а второго - вычесть их из исходных дат и времени, оставив округленные даты. - person ZECTBynmo; 12.06.2016
comment
Это отлично работает, но как получить 0 для других дат? - person Srikar Jammi; 28.03.2018
comment
@SrikarJammi, спасибо за вопрос - я изначально пропустил эту часть вопроса, и похоже, что другие ответы тоже. Я обновил свой ответ, включив в него создание 0 значений для дат без продаж. - person Asya Kamsky; 08.04.2018
comment
$ range endValue будет больше 32-битного целого числа и не будет работать. - person Augie Gardner; 22.02.2019
comment
Во-первых, int32 будет переполняться только в том случае, если ваш диапазон дат превышает 596 дней, а во-вторых, я не уверен, что это даже актуально, поскольку в bson даты представлены как longs (int64). - person Asya Kamsky; 24.02.2019
comment
Также обратите внимание, что диапазон не от 0 до даты окончания. Это от 0 до дельты (дата окончания минус дата начала). И если вы хотите, чтобы они были такими маленькими, вы могли бы отсчитывать их в днях, а не в миллисекундах, и преобразовывать их путем умножения в части расчета даты. - person Asya Kamsky; 24.02.2019
comment
Привет, @AsyaKamsky, я видел, как ты прокомментировал один из моих постов. Извините, но у меня не было времени ответить на него. Но теперь вы можете объяснить, в чем проблема. Пожалуйста, ответьте на этот пост, я посмотрю. - person Ashh; 08.07.2019
comment
Действительно хороший ответ здесь. Один из лучших в stackoverflow. - person ozn; 16.12.2019
comment
Аналогичный вопрос stackoverflow.com/questions/60371210/. Не могли бы вы мне помочь? - person Akshay Hazari; 24.02.2020
comment
Я тоже борюсь с этим решением. Если вы используете секунды и миллисекунды, я думаю, что int32 будет недостаточно. Есть ли другой способ добиться этого? - person J-H; 01.04.2020
comment
Используете int64 или long? - person Asya Kamsky; 02.04.2020

Мне нравится ответ user1083621, но этот метод вызывает некоторые ограничения при следующих операциях с этим полем - потому что вы не можете использовать его как поле даты (например) в следующих этапах конвейера агрегации. Вы не можете ни сравнивать, ни использовать какие-либо операции агрегирования дат и после агрегирования. у вас будут струны (!). Все это можно решить путем проецирования исходного поля даты, но в этом случае у вас возникнут некоторые трудности с его сохранением на этапе группировки. И в конце концов, иногда нужно просто манипулировать началом дня, а не произвольным временем дня. Итак, вот мой метод:

{'$project': {
    'start_of_day': {'$subtract': [
        '$date',
        {'$add': [
            {'$multiply': [{'$hour': '$date'}, 3600000]},
            {'$multiply': [{'$minute': '$date'}, 60000]},
            {'$multiply': [{'$second': '$date'}, 1000]},
            {'$millisecond': '$date'}
        ]}
    ]},
}}

Это дает вам следующее:

{
    "start_of_day" : ISODate("2015-12-03T00:00:00.000Z")
},
{
    "start_of_day" : ISODate("2015-12-04T00:00:00.000Z")
}

Не могу сказать, быстрее ли он, чем метод user1083621.

person egvo    schedule 27.04.2017
comment
разве это не очень похоже на мой ответ (без форматирования этапов)? - person Asya Kamsky; 08.04.2018
comment
@ asya-kamsky Может быть. Может быть, я впервые нашел его на упомянутом вами сайте. Но я очень боюсь того, как это было представлено в ответе. Это длинный ответ, я нашел его слишком длинным, чтобы читать полностью, поэтому я опубликовал свой. Если кто-то нашел мой ответ более полезным, это был их выбор, потому что все привилегии были на вашей стороне - ваш ответ уже был наивысшим, когда я опубликовал свой. - person egvo; 17.04.2018
comment
LOL - вы думали, что мой ответ был задолго до этого - теперь он почти вдвое больше, когда я добавил 3.6 (последний) способ сделать это. :) - person Asya Kamsky; 17.04.2018