Why?

I was recently implementing a feature that updates a MongoDB document array of values with the new input values from the user.

The most straightforward approach to this would be replacing the entire array with an updated copy. But do you submit all values from the client side including those that haven't changed? Do you then re-validate all of them? Do you do a read of the original from the DB, merge the updates and then write the new copy?

A lot of the tradeoffs with the straightforward approaches go away with an atomic update, updating just the fields that have been changed.

MongoDB's docs contain some great examples for atomic updates of a single value, but none that address an atomic update of multiple values to separate fields.

This was my approach to doing so.

Our Document

Suppose we have a collection users with the following document:


				{
					"_id": ObjectId("5d97c95bde7cc260e40dbc07"),
					"username": "Example",
					"myArray": [
						{ "name": "foo", "type": "string", "value": "hello" },
						{ "name": "bar", "type": "string", "value": "world" },
						{ "name": "foobar", "type": "number", "value": 10 },
					]
				}
				

Update Operator

MongoDB provides the $[<identifier>] operator for selecting specific fields in an array when combined with an arrayFilter.

So say we have the following payload received from our client-side application; it contains just the data we want to update and how to select it.


				{
					"updates": [
						{ "name": "foo", "value": "howdy" },
						{ "name": "bar", "value": "earth" }
					]
				}
				

What would our DB query need to look like then?


				db.users.updateOne({_id}, {
					$set: {
						"myArray.$[a0].value": "howdy",
						"myArray.$[a1].value": "earth"
					}
				}, {
					arrayFilters: [
						{"a0.name": "foo"},
						{"a1.name": "bar"}
					]
				});
				

We've left all of the fields, including any non "foo" or "bar" named array fields untouched while modifying only the two values we needed to.

You might have noticed the a0 and a1 indentifiers and might have asked why?!

The identifier must begin with a lowercase letter and contain only alphanumeric characters. - MongoDB Docs

So these are just a little trick to create identifiers that match the MongoDB requirements, a lowercase "a" paired with an index that can be incremented to make a unique identifier for each field.

We'll Do It Live

We've seen how we can construct the query, but we want to do it dynamically for each payload we receive. So we'll create a function that compiles the $set and arrayFilters as they resemble above. There's different ways to implement this, but the below example will work for demonstrating.


				const createAtomicArrayUpdate = (arrayToUpdate, getByFieldName, setFieldName, arrayOfUpdateObjects) => {
					let $set = {};
					let arrayFilters = [];
					arrayOfUpdateObjects.forEach(({[getByFieldName]: fieldNameValue, [setFieldName]: newValue}, index) => {
						const identifier = `a${index}`;
						$set[`${arrayToUpdate}.$[${identifier}].${setFieldName}`] = newValue;
						arrayFilters.push({[`${identifier}.${getByFieldName}`]: fieldNameValue});
					});
					return [$set, arrayFilters];
				};

				const [$set, arrayFilters] = createAtomicArrayUpdate('myArray', 'name', 'value', updates);

				db.users.updateOne({_id}, {$set}, {arrayFilters});
				

There's a lot of destructuring, computed property name, and template literal usage going on above so familiarize yourself with any you may not be familiar with or reach out to me with any questions!

By passing the names of the array and fields we wish to target & update we can construct our objects with template literals by combining all the pieces `${arrayToUpdate}.$[${identifier}].${setFieldName}`

The above won't work for every situation, but will hopefully give you a roadmap for constructing your own $set and arrayFilters to fit your atomic needs. For each atomic update you want to perform, generate a unique incremented identifier to filter by.