Home
> Firestore Many-to-Many: Arrays

Firestore Many-to-Many: Arrays

11 min read

Jonathan Gamble

jdgamble555 on Sunday, March 24, 2024 (last modified on Friday, March 29, 2024)

In a normal SQL database, you have tables, each of which can hold many items. In a NoSQL database, you have the same ability with collections. However, because SQL's expressive language is not available in NoSQL, you have to rethink how you model the connections.

TL;DR#

Firestore Arrays are perfect when the connections between two many-to-many collections are limited. While there could be unlimited students and unlimited classes, one student would only have a maximum of a few dozen classes, and one class would have a maximum of a few hundred, although a thousand wouldn't change the data model. A post on a blog would only have a small number of tags, while there could be infinite posts or infinite blogs. Arrays are perfect when the connection items are limited in scope.

Many-to-Many#

Not all many-to-many situations are possible in Firestore without using aggregation techniques. However, when you have a many-to-many situation involving only a few dozen items to connect to, arrays are your best bet.

Classes and Students#

The beauty of this example is that there is a limited number of students a class can have and a limited number of classes a student can take. Even if that number were as high as 1000, it would never be as high as 10,000, which is theoretically the most information you want to have in one Firestore document. A document has a limit of 1MB. Even though there are unlimited students and classes, the connections between them will be limited in scope.

Model#

	Classes / ClassID: {
  data...
  students: [
    studentID1,
    studentID2,
    ...
  ]
}
Students / StudentID: {
  data...
  classes: [
    classID1,
    classID2
  ]
}

Here you save the students in an array on a class document and the classes in an array on the students document. You honestly don't need both arrays, and you can choose either, but you need both collections. However, as you can see, using both makes it much easier to query in any situation. Here we will use both with atomic batching.

Add and Delete a Record#

Note: For these examples, assume db is a Firestore database reference. Your studentID and classID will be the variables representing those respective values.

A student registers for a class#

	updateDoc(doc(db, 'classes', classID), {
  students: arrayUnion(studentID)
});

OR

	updateDoc(doc(db, 'students', studentID), {
  classes: arrayUnion(classID)
});

A student drops a class#

	updateDoc(doc(db, 'classes', classID), {
  students: arrayRemove(studentID)
});

OR

	updateDoc(doc(db, 'students', studentID), {
  classes: arrayRemove(classID)
});

Batch#

In order to use batching, you should use set with merge set to true.

	const batch = writeBatch(db);

// add class to students array
batch.set(doc(db, 'students', studentID), {
  classes: arrayUnion(classID)
}, { merge: true });

// add student to classes array
batch.set(doc(db, 'classes', classID), {
  students: arrayUnion(studentID)
}, { merge: true });

await batch.commit();

Query Records#

You can generally only query in one direction. This will explain why you need batching.

Get all classes a student is taking#

	query(
  collection(db, 'classes'), 
  where('students', 'array-contains', studentID)
);

If we query the classes collection by the studentID, we get exactly what we want: all the classes a student belongs to with individual class data.

However, we can’t effectively query in the other direction. Because we are only storing the classID on the students document, we would have to query every individual class document to get the class information. It is much simpler to query the classes array to get the data. Since Firestore charges by reads, querying the classes collection is the cheapest way. It also doesn’t require extra aggregation to save more information about each class in the student documents.

Get all students registered for a class#

	 query(
   collection(db, 'students'), 
   where('classes', 'array-contains', classID)
 );

Again, we can easily query the students collection since we ultimately want to get the student data. Querying in the other direction would require more reads.

Filtering and Ordering#

There are some limits to filtering arrays, but you can generally get what you want if you model correctly.

Get all active classes a student is enrolled in#

It is easy to add multiple where clauses if you want to filter the data.

	query(
  collection(db, 'classes'),
  where('students', 'array-contains', studentID),
  where('status', '==', 'active')
);

The default sorts will be by the document ID without orderBy().

Get all active students enrolled in a class#

	query(
  collection(db, 'students'),
  where('classes', 'array-contains', classID),
  where('status', '==', 'active')
);

You could pretty much add whatever query Firestore normally allows without needing an index.

Sorting#

Once you want to sort the results, you need to create an index. This can be one step more complicated. The index will be created automatically by clicking the link in the console.

Get all students taking a class sorted by their name#

	query(
  collection(db, 'students'),
  where('classes', 'array-contains', classID),
  orderBy('name')
);

You will need to create an index for every additional where clause you add when you change the default sorting nature.

	query(
  collection(db, 'students'),
  where('classes', 'array-contains', classID),
  where('status', '==', 'active'),
  orderBy('name')
);

The alternative would be to sort on the front end, which, as mentioned earlier, would require you to read more documents, cost you more money, and take up additional query time.

Get all classes a student is taking sorted by the class name#

	query(
  collection(db, 'classes'),
  where('students', 'array-contains', studentID),
  where('status', '==', 'active'),
  orderBy('name')
);

The pattern is the same for the inverse query. Every additional filter requires a new index when changing the order.

OR Queries#

If you want to search for one class OR another, you can use array-contains-any.

Get all students taking EITHER class X OR class Y#

	 query(
   collection(db, 'students'), 
   where('classes', 'array-contains-any', [class1ID, class2ID])
 );

You can combine up to 30 classes here.

Get all classes EITHER student X OR student Y is taking#

	 query(
   collection(db, 'classes'), 
   where('students', 'array-contains-any', [student1ID, student2ID])
 );

And, of course, the same goes the other way.

AND Queries#

Firestore, unfortunately, does not support AND queries out-of-the-box. However, you can get the desired result by using Map Types or a hacked array-contains-all method. It is on my Firebase Wish List, so please make a feature request.

Limits#

  • You can use at most one array-contains clause per disjunction (or group). You can't combine array-contains with array-contains-any in the same disjunction.

J


Related Posts

🔥 Authentication

🔥 Counting Documents

🔥 Data Modeling

🔥 Framework Setup

🔥Under Construction

Newsletter

Get updates and the latest coding blog posts!

© 2024 Code.Build