Перейти к содержанию

Обновление документов

После сохранения документа в базе данных его можно изменить с помощью одного из нескольких методов обновления: updateOne, updateMany и replaceOne. Методы updateOne и updateMany принимают документ фильтра в качестве первого параметра и документ модификатора, описывающий изменения, которые необходимо внести, в качестве второго параметра. Метод replaceOne также принимает фильтр в качестве первого параметра, а в качестве второго параметра ожидает документ, которым он заменит документ, соответствующий фильтру.

Обновление документа является атомарным: если два обновления происходят одновременно, будет применено то из них, которое достигнет сервера первым, а затем будет применено следующее. Таким образом, конфликтующие обновления можно безопасно отправлять в быстрой последовательности, не опасаясь повреждения каких-либо документов: последнее обновление «победит». Стоит рассмотреть шаблон версионности документов (см.раздел «Шаблоны проектирования схем»), если вам не нужно поведение по умолчанию.

Замена документа

Метод replaceOne полностью заменяет соответствующий документ новым. Это может быть полезно для драматической схемы миграции. Например, предположим, что мы вносим серьезные изменения в пользовательский документ, который выглядит следующим образом:

1
2
3
4
5
6
{
"_id" : ObjectId("4b2b9f67a1f631733d917a7a"),
"name" : "joe",
"friends" : 32,
"enemies" : 2
}

Мы хотим переместить поля friends и enemies в поддокумент relationships. Мы можем изменить структуру документа в оболочке, а затем заменить версию базы данных с помощью replaceOne:

1
2
> var joe = db.users.findOne({"name" : "joe"});
> joe.relationships = {"friends" : joe.friends, "enemies" : joe.enemies};
1
2
3
4
{
"friends" : 32,
"enemies" : 2
}
1
> joe.username = joe.name;
1
'joe';
1
> delete joe.friends;
1
true;
1
> delete joe.enemies;
1
true;
1
> delete joe.name;
1
true;
1
> db.users.replaceOne({"name" : "joe"}, joe);

Теперь при использовании метода findOne видно, что структура документа была обновлена:

1
2
3
4
5
6
7
8
{
"_id" : ObjectId("4b2b9f67a1f631733d917a7a"),
"username" : "joe",
"relationships" : {
"friends" : 32,
"enemies" : 2
}
}

Распространенной ошибкой является сопоставление более чем одного документа с критериями, а затем создание дублирующего значения _id со вторым параметром. База данных выдаст ошибку, и ни один из документов не будет обновлен.

Например, предположим, что мы создали несколько документов с одинаковым значением "name", но мы этого не понимаем:

1
> db.people.find()
1
2
3
{"_id" : ObjectId("4b2b9f67a1f631733d917a7b"), "name" : "joe", "age" : 65}
{"_id" : ObjectId("4b2b9f67a1f631733d917a7c"), "name" : "joe", "age" : 20}
{"_id" : ObjectId("4b2b9f67a1f631733d917a7d"), "name" : "joe", "age" : 49}

Теперь, если у Джо № 2 день рождения, мы хотим увеличить значение его ключа age, поэтому можно написать следующее:

1
> joe = db.people.findOne({"name" : "joe", "age" : 20});
1
2
3
4
5
{
"_id" : ObjectId("4b2b9f67a1f631733d917a7c"),
"name" : "joe",
"age" : 20
}
1
2
3
> joe.age++;
> db.people.replaceOne({"name" : "joe"}, joe);
E11001 duplicate key on update

Что произошло? Когда вы выполните обновление, база данных будет искать документ, соответствующий {"name" : "joe"}. Первым она найдет 65-летнего Джо. Она попытается заменить этот документ на тот, что содержится в переменной joe, но в этой коллекции уже есть документ с таким же _id. Таким образом, обновление завершится неудачно, поскольку значения _id должны быть уникальными. Лучший способ избежать подобной ситуации – убедиться, что в вашем обновлении всегда указан уникальный документ, возможно, путем сопоставления с ключом, подобным _id. В случае с предыдущим примером это обновление будет правильным:

1
> db.people.replaceOne({"_id" : ObjectId("4b2b9f67a1f631733d917a7c")}, joe)

Использование "_id" для фильтра также будет эффективным, поскольку значения "_id" формируют основу для первичного индекса коллекции.

Использование операторов обновления

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

Предположим, мы храним аналитику веб-сайта в коллекции и хотим, чтобы счетчик увеличивался каждый раз, когда кто-то посещает страницу. Мы можем использовать операторы обновления, чтобы делать это увеличение атомарно. Каждый URL-адрес и количество просмотров страниц хранятся в документе, который выглядит следующим образом:

1
2
3
4
5
{
"_id" : ObjectId("4b253b067525f35f94b60a31"),
"url" : "www.example.com",
"pageviews" : 52
}

Каждый раз, когда кто-то заходит на страницу, мы можем найти эту страницу по URL-адресу и использовать модификатор $inc, чтобы увеличить значение ключа pageviews:

1
2
3
4
> db.analytics.updateOne(
    { "url" : "www.example.com" },
    { "$inc" : {"pageviews" : 1} }
)
1
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }

Теперь если мы воспользуемся методом findOne, то увидим, что количество просмотров страниц увеличилось на единицу:

1
> db.analytics.findOne()
1
2
3
4
5
{
"_id" : ObjectId("4b253b067525f35f94b60a31"),
"url" : "www.example.com",
"pageviews" : 53
}

При использовании операторов значение "_id" нельзя изменить. Обратите внимание, что "_id" можно изменить путем замены всего документа. Можно изменить значения для любого другого ключа, включая иные ключи с уникальной индексацией.

Начало работы с модификатором $set

Модификатор $set устанавливает значение поля. Если поле еще не существует, оно будет создано. Это может быть удобно для обновления схем или добавления пользовательских ключей. Например, предположим, что у вас есть простой профиль пользователя, сохраненный в виде документа, который выглядит примерно так:

1
> db.users.findOne()
1
2
3
4
5
6
7
{
"_id" : ObjectId("4b253b067525f35f94b60a31"),
"name" : "joe",
"age" : 30,
"sex" : "male",
"location" : "Wisconsin"
}

Это довольно простой профиль. Если пользователь хочет сохранить свою любимую книгу в своем профиле, он может добавить ее, используя модфикатор $set:

1
2
3
4
> db.users.updateOne(
    {"_id" : ObjectId("4b253b067525f35f94b60a31")},
    {"$set" : {"favorite book" : "War and Peace"}}
)

Теперь у документа будет ключ "favorite book":

1
> db.users.findOne()
1
2
3
4
5
6
7
8
{
"_id" : ObjectId("4b253b067525f35f94b60a31"),
"name" : "joe",
"age" : 30,
"sex" : "male",
"location" : "Wisconsin",
"favorite book" : "War and Peace"
}

Если пользователь решает, что ему по-настоящему нравится другая книга, можно снова использовать модификатор $set для изменения значения:

1
2
3
4
> db.users.updateOne(
    {"name" : "joe"},
    {"$set" : {"favorite book" : "Green Eggs and Ham"}}
)

$set может даже изменить тип ключа, который он модифицирует. Например, если наш непостоянный пользователь решит, что ему на самом деле нравится всего несколько книг, он может изменить значение ключа "favorite book" в массив:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
> db.users.updateOne(
    {"name" : "joe"},
    {"$set" : {
        "favorite book" : [
            "Cat's Cradle",
            "Foundation Trilogy",
            "Ender's Game"
        ]
    }}
)

Если пользователь понимает, что ему на самом деле не нравится чтение, он может полностью удалить ключ с помощью $unset:

1
2
3
4
> db.users.updateOne(
    {"name" : "joe"},
    {"$unset" : {"favorite book" : 1}}
)

Теперь документ будет таким же, каким он был в начале этого примера.

Модификатор $set также можно использовать для доступа к вложенным документам и изменять их:

1
> db.blog.posts.findOne()
1
2
3
4
5
6
7
8
9
{
"_id" : ObjectId("4b253b067525f35f94b60a31"),
"title" : "A Blog Post",
"content" : "...",
"author" : {
"name" : "joe",
"email" : "[email protected]"
}
}
1
2
3
4
5
> db.blog.posts.updateOne(
    {"author.name" : "joe"},
    {"$set" : {"author.name" : "joe schmoe"}}
)
> db.blog.posts.findOne()
1
2
3
4
5
6
7
8
9
{
"_id" : ObjectId("4b253b067525f35f94b60a31"),
"title" : "A Blog Post",
"content" : "...",
"author" : {
"name" : "joe schmoe",
"email" : "[email protected]"
}
}

Всегда нужно использовать $-модификатор для добавления, изменения или удаления ключей. Распространенная ошибка, которую некоторые совершают поначалу, состоит в том, что они пытаются установить значение ключа на какое-то другое значение, выполняя обновление, подобное этому:

1
2
3
4
> db.blog.posts.updateOne(
    {"author.name" : "joe"},
    {"author.name" : "joe schmoe"}
)

Это приведет к ошибке. Документ обновления должен содержать операторы обновления. Предыдущие версии CRUD API не перехватывали этот тип ошибки. Более ранние методы обновления просто выполняли замену всего документа в подобных ситуациях. Именно такой тип ловушек и привел к созданию нового CRUD API.

Инкрементирование и декрементирование

Оператор $inc можно использовать для изменения значения существующего ключа или для создания нового ключа, если он еще не существует. Это полезно для обновления аналитики, кармы, голосов или чего-либо еще, имеющего изменяемое числовое значение.

Предположим, мы создаем коллекцию игр, в которой хотим сохранять игры и обновлять оценки по мере их изменения. Когда пользователь начинает играть, скажем, в пейнтбол, мы можем вставить документ, который идентифицирует эту игру по имени и пользователю, который в нее играет:

1
> db.games.insertOne({"game" : "pinball", "user" : "joe"})

Когда мяч попадает в бампер, счет должен расти. Поскольку очки в пейнтболе начисляются довольно свободно, допустим, что базовая единица очков, которую игрок может заработать, равна 50. Мы можем использовать модификатор $inc, чтобы добавить 50 к счету игрока:

1
2
3
4
> db.games.updateOne(
    {"game" : "pinball", "user" : "joe"},
    {"$inc" : {"score" : 50}}
)

Если мы посмотрим на документ после этого обновления, то увидим следующее:

1
> db.games.findOne()
1
2
3
4
5
6
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"game" : "pinball",
"user" : "joe",
"score" : 50
}

Ключа score еще не было, поэтому он был создан с помощью модификатора $inc, и для него было установлено значение, равное сумме приращения: 50.

Если мяч попадает в «бонусный» слот, нужно добавить к счету 10 000 очков. Это можно сделать, передав $inc другое значение:

1
2
3
4
> db.games.updateOne(
    {"game" : "pinball", "user" : "joe"},
    {"$inc" : {"score" : 10000}}
)

Теперь если мы посмотрим на игру, то увидим следующее:

1
> db.games.findOne()
1
2
3
4
5
6
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"game" : "pinball",
"user" : "joe",
"score" : 10050
}

Ключ score существовал и имел числовое значение, поэтому сервер добавил к нему 10 000. Модификатор "$inc" похож на "$set", но он предназначен для увеличения (и уменьшения) чисел. Его можно использовать только для значений типа integer, long, double или decimal. Если он используется для любого другого типа значения, это окончится неудачей. Сюда входят типы, которые многие языки будут автоматически преобразовывать в числа, такие как нули, логические значения или строки из числовых символов:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
> db.strcounts.insert({"count" : "1"})
WriteResult({ "nInserted" : 1 })
> db.strcounts.update({}, {"$inc" : {"count" : 1}})
WriteResult({
    "nMatched" : 0,
    "nUpserted" : 0,
    "nModified" : 0,
    "writeError" : {
        "code" : 16837,
        "errmsg" : "Cannot apply $inc to a value of non-numeric type.
        {_id: ObjectId('5726c0d36855a935cb57a659')} has the field 'count' of
        non-numeric type String"
    }
})

Кроме того, значение ключа $inc должно быть числом. Нельзя увеличивать на строку, массив или другое нечисловое значение. В результате появится сообщение об ошибке «Допускается использование модификатора $inc только с числами». Чтобы модифицировать другие типы, используйте модификатор $set или один из следующих операторов массива.

Операторы массива

Для манипулирования массивами существует обширный класс операторов обновления. Массивы представляют собой распространенные и мощные структуры данных: это не только списки, на которые можно ссылаться по индексу, они также могут удваиваться как наборы.

Добавление элементов. Оператор $push добавляет элементы в конец массива, если массив существует, и создает новый массив, если его нет. Например, предположим, что мы сохраняем посты из блога и хотим добавить ключ comments, содержащий массив. Мы можем вставить комментарий в несуществующий массив comments, который создаст массив и добавит комментарий:

1
> db.blog.posts.findOne()
1
2
3
4
5
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"title" : "A blog post",
"content" : "..."
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
> db.blog.posts.updateOne(
    {"title" : "A blog post"},
    {"$push" : {
        "comments" : {
            "name" : "joe",
            "email" : "[email protected]",
            "content" : "nice post."
        }
    }}
)
1
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }
1
> db.blog.posts.findOne()
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"title" : "A blog post",
"content" : "...",
"comments" : [
{
"name" : "joe",
"email" : "[email protected]",
"content" : "nice post."
}
]
}

Теперь, если мы хотим добавить еще один комментарий, мы можем просто снова использовать $push:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
> db.blog.posts.updateOne(
    {"title" : "A blog post"},
    {"$push" : {
        "comments" : {
            "name" : "bob",
            "email" : "[email protected]",
            "content" : "good post."
        }
    }}
)
1
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }
1
> db.blog.posts.findOne()
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"title" : "A blog post",
"content" : "...",
"comments" : [
{
"name" : "joe",
"email" : "[email protected]",
"content" : "nice post."
},
{
"name" : "bob",
"email" : "[email protected]",
"content" : "good post."
}
]
}

Это «простая» форма оператора push, но вы можете использовать ее и для более сложных операций с массивами. Язык запросов MongoDB предоставляет модификаторы для некоторых операторов, включая $push. Вы можете сдвинуть несколько значений за одну операцию, используя модификатор $each для оператора $push:

1
2
3
4
5
6
7
8
> db.stock.ticker.updateOne(
    {"_id" : "GOOG"},
    {"$push" : {
        "hourly" : {
            "$each" : [562.776, 562.790, 559.123]
        }
    }}
)

В результате этого в массив будет добавлено три новых элемента. Если вы хотите, чтобы массив увеличивался до определенной длины, вы можете использовать модификатор $slice с $push, чтобы предотвратить рост массива выше определенного размера, успешно создавая список элементов "top N":

1
2
3
4
5
6
7
8
9
> db.movies.updateOne(
    {"genre" : "horror"},
    {"$push" : {
        "top10" : {
            "$each" : ["Nightmare on Elm Street", "Saw"],
            "$slice" : -10
        }
    }}
)

В этом примере мы ограничиваем массив последними 10 добавленными элементами.

Если массив меньше 10 элементов (после добавления), все элементы будут сохранены. Если массив больше 10 элементов, будут сохранены только последние 10 элементов. Таким образом, $slice можно использовать для создания очереди в документе.

Наконец, можно применять модификатор $sort к операциям с $push перед усечением:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
> db.movies.updateOne(
    {"genre" : "horror"},
    {"$push" : {
        "top10" : {
            "$each" : [
                {"name" : "Nightmare on Elm Street", "rating" : 6.6},
                {"name" : "Saw", "rating" : 4.3}
            ],
        "$slice" : -10,
        "$sort" : {
            "rating" : -1
        }}
    }}
)

Все объекты в массиве будут отсортированы по полю rating, и первые 10 останутся. Обратите внимание, что вы должны использовать модификатор $each; нельзя просто использовать модификаторы $slice или $sort с модификатором $push при работе с массивом.

Использование массивов в качестве наборов. Возможно, вы захотите рассматривать массив как набор, добавляя только значения, если они отсутствуют. Это можно сделать с помощью модификатора $ne в документе запроса. Например, чтобы вставить автора в список цитат, но только если его там еще нет, используйте следующий код:

1
2
3
4
> db.papers.updateOne(
    {"authors cited" : {"$ne" : "Richie"}},
    {$push : {"authors cited" : "Richie"}}
)

Это также можно сделать с помощью $addToSet, что полезно в тех случаях, когда модификатор $ne не работает или же $addToSet лучше описывает то, что происходит.

Например, предположим, у вас есть документ, который представляет пользователя. У вас может быть набор адресов электронной почты, которые добавили пользователи:

1
> db.users.findOne({"_id" : ObjectId("4b2d75476cc613d5ee930164")})
1
2
3
4
5
6
7
8
9
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"username" : "joe",
"emails" : [
"[email protected]",
"[email protected]",
"[email protected]"
]
}

При добавлении еще одного адреса вы можете использовать модификатор $addToSet для предотвращения дублирования:

1
2
3
4
> db.users.updateOne(
    {"_id" : ObjectId("4b2d75476cc613d5ee930164")},
    {"$addToSet" : {"emails" : "[email protected]"}}
)
1
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 0 }
1
> db.users.findOne({"_id" : ObjectId("4b2d75476cc613d5ee930164")})
1
2
3
4
5
6
7
8
9
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"username" : "joe",
"emails" : [
"[email protected]",
"[email protected]",
"[email protected]",
]
}
1
2
3
4
> db.users.updateOne(
    {"_id" : ObjectId("4b2d75476cc613d5ee930164")},
    {"$addToSet" : {"emails" : "[email protected]"}}
)
1
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }
1
> db.users.findOne({"_id" : ObjectId("4b2d75476cc613d5ee930164")})
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"username" : "joe",
"emails" : [
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]"
]
}

Вы также можете использовать его в сочетании с модификатором $each для добавления нескольких уникальных значений, что невозможно сделать с помощью комбинации $ne/$push. Например, можно использовать эти операторы, если пользователь хочет добавить несколько адресов электронной почты:

1
2
3
4
5
6
7
8
> db.users.updateOne(
    {"_id" : ObjectId("4b2d75476cc613d5ee930164")},
    {"$addToSet" : {
        "emails" : {
            "$each" : ["[email protected]", "[email protected]", "[email protected]"]
        }
    }}
)
1
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }
1
> db.users.findOne({"_id" : ObjectId("4b2d75476cc613d5ee930164")})
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"username" : "joe",
"emails" : [
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]"
"[email protected]"
"[email protected]"
]
}

Удаление элементов. Существует несколько способов удалить элементы из массива. Если вы хотите рассматривать массив как очередь или стек, можно использовать оператор $pop, который может удалять элементы с любого конца. {"$pop" : {"key" : 1}} удаляет элемент из конца массива. {"$pop" : {"key" : -1}} удаляет его с начала.

Иногда элемент должен быть удален на основе определенных критериев, а не своего положения в массиве. Оператор $pull используется для удаления элементов массива, соответствующих заданным критериям. Например, предположим, у нас есть список вещей, которые нужно сделать, но не в каком-то определенном порядке:

1
> db.lists.insertOne({"todo" : ["dishes", "laundry", "dry cleaning"]})

Если мы сначала занимается стиркой ("laundry"), можно удалить ее из списка следующим образом:

1
> db.lists.updateOne({}, {"$pull" : {"todo" : "laundry"}})

Теперь, если мы применим метод findeOne(), то увидим, что в массиве осталось только два элемента:

1
> db.lists.findOne()
1
2
3
4
5
6
7
{
"_id" : ObjectId("4b2d75476cc613d5ee930164"),
"todo" : [
"dishes",
"dry cleaning"
]
}

При использовании оператора $pull удаляются все совпадающие документы, а не только одно совпадение. Если у вас есть массив, который выглядит как [1, 1, 2, 1], и вы удалите 1, то получите массив из одного элемента, [2].

Операторы массива могут использоваться только для ключей со значениями массива. Например, нельзя применять оператор $push, когда речь идет о целом числе, или использовать оператор $pop, когда перед вами строка. Используйте операторы $set или $inc для изменения скалярных значений.

Модификации позиционного массива. Манипулирование массивом становится немного сложнее, когда у вас есть несколько значений в массиве и вы хотите изменить некоторые из них. Есть два способа манипулирования значениями в массивах: по позиции или с помощью оператора позиции (символ $).

Массивы используют индексацию с отсчетом от 0, и элементы можно выбирать так, как если бы их индекс был ключом документа. Например, предположим, что у нас есть документ, содержащий массив с несколькими встраиваемыми документами, например пост в блоге с комментариями:

1
> db.blog.posts.findOne()
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"_id" : ObjectId("4b329a216cc613d5ee930192"),
"content" : "...",
"comments" : [
{
"comment" : "good post",
"author" : "John",
"votes" : 0
},
{
"comment" : "i thought it was too short",
"author" : "Claire",
"votes" : 3
},
{
"comment" : "free watches",
"author" : "Alice",
"votes" : -5
},
{
"comment" : "vacation getaways",
"author" : "Lynn",
"votes" : -7
}
]
}

Если мы хотим увеличить количество голосов за первый комментарий, то можем написать следующее:

1
2
3
4
> db.blog.updateOne(
    {"post" : post_id},
    {"$inc" : {"comments.0.votes" : 1}}
)

Однако во многих случаях мы не знаем, какой индекс массива нужно изменить, предварительно не запросив документ и не изучив его. Чтобы решить этот вопрос, в MongoDB существует позиционный оператор $, который определяет, какому элементу массива соответствует документ запроса, и обновляет этот элемент. Например, если у нас есть пользователь по имени Джон, который меняет свое имя на Джим, мы можем заменить его в комментариях с помощью позиционного оператора:

1
2
3
4
> db.blog.updateOne(
    {"comments.author" : "John"},
    {"$set" : {"comments.$.author" : "Jim"}}
)

Позиционный оператор обновляет только первое совпадение. Таким образом, если бы Джон оставил несколько комментариев, его имя было бы изменено только для первого оставленного им комментария.

Обновления с использованием фильтров массива. В MongoDB версии 3.6 появился еще один параметр для обновления отдельных элементов массива: arrayFilters. Он позволяет изменять элементы массива, соответствующие конкретным критериям. Например, если мы хотим скрыть все комментарии с пятью или более отрицательными голосами, то можем сделать что-то вроде этого:

1
2
3
4
5
db.blog.updateOne(
    {"post" : post_id },
    { $set: { "comments.$[elem].hidden" : true } },
    { arrayFilters: [ { "elem.votes": { $lte: -5 } } ]}
)

Данная команда определяет elem как идентификатор для каждого совпадающего элемента в массиве comments. Если значение votes для комментария, обозначенного elem, меньше или равно –5, мы добавим поле с именем hidden в документ comments и установим для него значение true.

Upsert

Upsert (от англ. update (обновлять) + insert (вставить)) – особый тип обновления. Если не найден ни один документ, который соответствует фильтру, будет создан новый документ путем объединения критериев и обновленных документов. Если совпадающий документ найден, он будет обновлен в обычном режиме. Upsert’ы могут быть удобны, потому что могут избавить вас от необходимости «засевать» коллекцию: часто у вас может быть один и тот же код для создания и обновления документов.

Давайте вернемся к нашему примеру, где ведется запись количества просмотров для каждой страницы сайта. Без использования upsert можно было бы попытаться найти URL-адрес и увеличить количество просмотров или создать новый документ, если URL-адреса не существует. Если бы мы написали это как программу на языке JavaScript, это могло бы выглядеть примерно так:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Проверяем, есть ли у нас запись для этой страницы;
blog = db.analytics.findOne({ url: '/blog' });
// Если таковая имеется, добавляем ее к числу просмотров и сохраняем;
if (blog) {
  blog.pageviews++;
  db.analytics.save(blog);
}
// В противном случае мы создаем новый документ для этой страницы;
else {
  db.analytics.insertOne({ url: '/blog', pageviews: 1 });
}

Это означает, что мы отправляемся в базу данных и потом обратно, а также отправляем обновление или вставку каждый раз, когда кто-то посещает страницу. Если мы выполняем этот код в нескольких процессах, мы также сталкиваемся с состоянием гонки, при котором для данного URL-адреса может быть вставлено более одного документа.

Мы можем устранить состояние гонки и сократить объем кода, просто отправив upsert в базу данных (третий параметр для методов updateOne и updateMany – это документ параметров, который позволяет нам указать это):

1
2
3
4
5
> db.analytics.updateOne(
    {"url" : "/blog"},
    {"$inc" : {"pageviews" : 1}},
    {"upsert" : true}
)

Эта строка делает именно то, что делает предыдущий блок кода, за исключением того, что это быстрее! Новый документ создается с использованием документа критериев в качестве основы и применения к нему любых документов-модификаторов.

Например, если вы используете upsert, который совпадает с ключом и увеличивается до значения этого ключа, приращение будет применено к совпадению:

1
2
3
4
5
6
7
8
9
> db.users.updateOne({"rep" : 25}, {"$inc" : {"rep" : 3}}, {"upsert" : true})
WriteResult({
    "acknowledged" : true,
    "matchedCount" : 0,
    "modifiedCount" : 0,
    "upsertedId" : ObjectId("5a93b07aaea1cb8780a4cf72")
})
> db.users.findOne({"_id" : ObjectId("5727b2a7223502483c7f3acd")} )
{ "_id" : ObjectId("5727b2a7223502483c7f3acd"), "rep" : 28 }

Upsert создает новый документ, где rep равен 25, а затем увеличивает его на 3, давая нам документ, где rep равен 28. Если опция upsert не была указана, {"rep" : 25} не будет совпадать ни с одним документом, поэтому ничего не произойдет.

Если мы снова запустим upsert (с критерием {"rep" : 25}), он создаст еще один новый документ. Это связано с тем, что критерий не соответствует единственному документу в коллекции. (Его rep равен 28.)

Иногда при создании документа необходимо задать поле, но не изменять его при последующих обновлениях. Для этого и используется оператор $setOnInsert. $setOnInsert – это оператор, который устанавливает значение поля только при вставке документа. Таким образом, мы могли бы сделать что-то вроде этого:

1
2
3
4
5
> db.users.updateOne(
    {},
    {"$setOnInsert" : {"createdAt" : new Date()}},
    {"upsert" : true}
)
1
2
3
4
5
6
{
"acknowledged" : true,
"matchedCount" : 0,
"modifiedCount" : 0,
"upsertedId" : ObjectId("5727b4ac223502483c7f3ace")
}
1
> db.users.findOne()
1
2
3
4
{
"_id" : ObjectId("5727b4ac223502483c7f3ace"),
"createdAt" : ISODate("2016-05-02T20:12:28.640Z")
}

Если мы запустим это обновление снова, оно будет соответствовать существующему документу, ничего не будет вставлено, поэтому поле createAt не будет изменено:

1
2
> db.users.updateOne({}, {"$setOnInsert" : {"createdAt" : new Date()}},
... {"upsert" : true})
1
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 0 }
1
> db.users.findOne()
1
2
3
4
{
"_id" : ObjectId("5727b4ac223502483c7f3ace"),
"createdAt" : ISODate("2016-05-02T20:12:28.640Z")
}

Обратите внимание, что, как правило, вам не нужно сохранять поле createAt, поскольку ObjectId содержат метку времени создания документа. Однако оператор $setOnInsert может быть полезен для создания отступов, инициализации счетчиков и для коллекций, которые не используют ObjectId.

Сохранение

save – это функция оболочки, которая позволяет вставлять документ, если он не существует, и обновлять его, если он уже есть. Она принимает один аргумент: документ. Если документ содержит ключ _id, функция использует upsert. В противном случае будет выполнена вставка. save – действительно удобная функция, поэтому программисты могут быстро изменять документы в оболочке:

1
2
3
4
> var x = db.testcol.findOne()
> x.num = 42
42
> db.testcol.save(x)

Без нее последняя строка была бы более громоздкой:

1
db.testcol.replaceOne({"_id" : x._id}, x)

Обновление нескольких документов

До сих пор в этой главе мы использовали метод updateOne для иллюстрации операций обновления. updateOne обновляет только первый найденный документ, который соответствует критериям фильтра. Если совпадающих документов больше, они останутся без изменений. Чтобы изменить все документы, соответствующие фильтру, используйте метод updateMany. updateMany следует той же семантике, что и updateOne, и принимает те же параметры. Основное различие заключается в количестве документов, которые можно изменить.

updateMany предоставляет мощный инструмент для выполнения миграций схемы или развертывания новых функций для определенных пользователей. Предположим, например, что мы хотим сделать подарок каждому пользователю, у которого день рождения в определенный день. Можно использовать метод updateMany, чтобы добавить «подарок» ("gift") в их аккаунты. Например:

1
2
3
4
5
> db.users.insertMany([
    {birthday: "10/13/1978"},
    {birthday: "10/13/1978"},
    {birthday: "10/13/1978"}
])
1
2
3
4
5
6
7
8
{
"acknowledged" : true,
"insertedIds" : [
ObjectId("5727d6fc6855a935cb57a65b"),
ObjectId("5727d6fc6855a935cb57a65c"),
ObjectId("5727d6fc6855a935cb57a65d")
]
}
1
2
3
4
> db.users.updateMany(
    {"birthday" : "10/13/1978"},
    {"$set" : {"gift" : "Happy Birthday!"}}
)
1
{ "acknowledged" : true, "matchedCount" : 3, "modifiedCount" : 3 }

Вызов updateMany добавляет поле gift в каждый из трех документов, которые мы вставили в коллекцию users непосредственно перед этим.

Возврат обновленных документов

В некоторых случаях использования важно вернуть измененный документ. В более ранних версиях MongoDB в таких ситуациях предпочтение отдавалось методу findAndModify. Он удобен для манипулирования очередями и выполнения других операций, которые требуют атомарности в стиле get-and-set. Однако метод findAndModify подвержен пользовательским ошибкам, потому что это комплексный метод, сочетающий в себе функциональность трех различных типов операций: удаления, замены и обновления (включая upsert’ы).

В MongoDB версии 3.2 появились три новых метода коллекции для обеспечения функциональности findAndModify, но с семантикой, которую легче изучить и запомнить: findOneAndDelete, findOneAndReplace и findOneAndUpdate. Основное различие между этими методами и, например, методом updateOne заключается в том, что они позволяют атомарно получить значение модифицированного документа. В MongoDB версии 4.2 метод findOneAndUpdate был расширен, чтобы принимать конвейер агрегации для обновления. Конвейер может состоять из следующих этапов: $addFields и его псевдоним $set, $project и его псевдоним $unset и $replaceRoot и его псевдоним $replaceWith.

Предположим, у нас есть коллекция процессов, запущенных в определенном порядке. Каждый из них представлен документом следующей формы:

1
2
3
4
5
{
"_id" : ObjectId(),
"status" : "state",
"priority" : N
}

status – это строка, которая может находиться в состоянии READY, RUNNING или DONE. Нам нужно найти задание с наивысшим приоритетом в состоянии READY, запустить функцию процесса, а затем обновить состояние до DONE. Мы могли бы попытаться запросить готовые процессы, рассортировать их по приоритету и обновить статус процесса с наивысшим приоритетом, чтобы пометить его как RUNNING. После того как мы обработали его, мы обновляем статус на DONE. Выглядит это примерно так:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var cursor = db.processes.find({ status: 'READY' });
ps = cursor.sort({ priority: -1 }).limit(1).next();
db.processes.updateOne(
  { _id: ps._id },
  { $set: { status: 'RUNNING' } }
);
do_something(ps);
db.processes.updateOne(
  { _id: ps._id },
  { $set: { status: 'DONE' } }
);

Данный алгоритм не очень хорош, потому что он зависит от состояния гонки. Предположим, у нас работает два потока. Если один поток (назовем его A) получил документ, а другой поток (назовем его B) получил тот же документ до того, как A обновил свой статус до RUNNING, оба потока будут работать в одном и том же процессе. Этого можно избежать, проверяя результат как часть запроса на обновление, но это становится сложным:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
var cursor = db.processes.find({ status: 'READY' });
cursor.sort({ priority: -1 }).limit(1);
while ((ps = cursor.next()) != null) {
  var result = db.processes.updateOne(
    { _id: ps._id, status: 'READY' },
    { $set: { status: 'RUNNING' } }
  );
  if (result.modifiedCount === 1) {
    do_something(ps);
    db.processes.updateOne(
      { _id: ps._id },
      { $set: { status: 'DONE' } }
    );
    break;
  }
  cursor = db.processes.find({ status: 'READY' });
  cursor.sort({ priority: -1 }).limit(1);
}

Кроме того, в зависимости от синхронизации один поток может выполнить всю работу, в то время как другой поток впустую отслеживает это. Поток A всегда может захватить процесс, а затем, когда B попытается заполучить тот же процесс, это окончится неудачей, и A выполнит всю работу.

Подобные ситуации идеально подходят для метода findOneAndUpdate. Он может вернуть элемент и обновить его за одну операцию. В этом случае это выглядит следующим образом:

1
2
3
4
5
> db.processes.findOneAndUpdate(
    {"status" : "READY"},
    {"$set" : {"status" : "RUNNING"}},
    {"sort" : {"priority" : -1}}
)
1
2
3
4
5
{
"_id" : ObjectId("4b3e7a18005cab32be6291f7"),
"priority" : 1,
"status" : "READY"
}

Обратите внимание, что в возвращаемом документе мы по-прежнему видим слово READY, поскольку метод findOneAndUpdate по умолчанию возвращает состояние документа до его изменения. Он вернет обновленный документ, если мы установим для поля returnNewDocument в документе параметров значение true. Документ параметров передается в качестве третьего параметра методу findOneAndUpdate:

1
2
3
4
5
6
7
8
> db.processes.findOneAndUpdate(
    {"status" : "READY"},
    {"$set" : {"status" : "RUNNING"}},
    {
        "sort" : {"priority" : -1},
        "returnNewDocument": true,
    }
)
1
2
3
4
5
{
"_id" : ObjectId("4b3e7a18005cab32be6291f7"),
"priority" : 1,
"status" : "RUNNING"
}

Таким образом, программа будет выглядеть так:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
ps = db.processes.findOneAndUpdate(
  { status: 'READY' },
  { $set: { status: 'RUNNING' } },
  { sort: { priority: -1 }, returnNewDocument: true }
);
do_something(ps);
db.process.updateOne(
  { _id: ps._id },
  { $set: { status: 'DONE' } }
);

В дополнение к этому есть два других метода, о которых вы должны знать.

findOneAndReplace принимает те же параметры и возвращает документ, соответствующий фильтру, до или после замены, в зависимости от значения returnNewDocument. Метод findOneAndDelete работает аналогичным образом, за исключением того, что он не принимает документ обновления в качестве параметра и имеет подмножество параметров двух других методов. Метод findOneAndDelete возвращает удаленный документ.

Комментарии