Before arrays existed in Firestore, and when you needed to store an array of items in Firebase Realtime Database, you had to use maps.
array: {
item1: true,
item2: true
}
Maps are a bit awkward for storing array-like data, as you are storing the actual data in a key and a boolean in the value. The value doesn’t mean anything except when it comes to ordering, but you can query as if it were an array of items.
TL;DR#
Now that Firestore has OR
queries, maps can be just as powerful as arrays when you need to query array-like data. Maps can perform unlimited AND
queries as long as you don’t need sorting options, and arrays can generally sort without multiple indexes. There is always a give and take.
Maps as Arrays#
If you only need to query 1 to 30 items at a time, arrays are always the way to go. They don't mess up your in-time indexing, and they seem self-explanatory. In fact, I would suggest arrays for most cases, as Firestore has made arrays easier. However, maps are the way to go if you need to search for 11 to 40,000 items at a time. The only real problem is that sorting can be tricky.
Both arrays and maps allow you to store around 10,000 items. This could vary, as your document size is the only storage constraint at 1MB. You can have up to 40,000 indexes per document, so you can have a lot of where clauses if you don’t care about ordering.
If you were to use the same example from the Array Version, you would have something like this:
Classes / ClassID: {
data...
students: {
studentID1: true,
studentID2: true,
...
}
}
Students / StudentID: {
data...
classes: {
classID1: true,
classID2: true
}
}
There are pros and cons of arrays vs maps, but you can generally accomplish the same goals.
Add and Update#
Here, we are using batch to write to the students
and classes
collections simultaneously to maximize our querying opportunities. See Firestore Many-to-Many Arrays for more on this.
const batch = writeBatch(db);
// add class to student array
batch.set(doc(db, 'students', studentID), {
classes: {
[classID]: true
}
}, { merge: true });
// add student to classes array
batch.set(doc(db, 'classes', classID), {
students: {
[studentID]: true
}
}, { merge: true });
await batch.commit();
We don’t need any special array functions; we simply merge our data with the JSON object using maps.
Queries and Filters#
You don't need an index to query without sorting; it is extremely simple. You can also add another where clause just as easily.
Get all classes a student is taking#
query(
collection(db, 'classes'),
where(`students.${studentID}`, '==', true)
);
Get all students registered for a class#
query(
collection(db, 'students'),
where('classes.${classID)`, '==', true)
);
Get all active classes a student is enrolled in#
query(
collection(db, 'classes'),
where(`students.${studentID}`, '==', true),
where('status', '==', 'active')
);
Get all active students enrolled in a class#
query(
collection(db, 'students'),
where('classes.${classID)`, '==', true),
where('status', '==', 'active')
);
AND Queries#
Using maps makes creating complex AND
queries simple.
Get all classes that student X AND student Y are taking#
query(
collection(db, 'classes'),
where(`students.${student1ID}`, '==', true),
where(`students.${student2ID}`, '==', true)
);
And for chaining...
const students = [studentID, studentID2,... ];
query(
collection(db, 'classes'),
...students.map(student =>
where(`students.${student}`, '==', true)
)
);
Get all classes that student X OR student Y is taking#
query(
collection(db, 'classes'),
or(
where(`students.${student1ID}`, '==', true),
where(`students.${student2ID}`, '==', true)
)
);
If there are no AND queries with it, you can use a maximum of 30 OR
queries. Otherwise, it calculates a disjunctive normal form.
Sorting#
Get all classes a student is taking sorted by the class name#
You can't use orderBy()
when using maps like this unless you limit the name of your items (students or tags, i.e.) and index them beforehand. Every query would require a separate index, and we won’t know all the possible iterations. So, we have workarounds.
Method 1#
Put the field you want to sort by as the value in every single map. If you add a value, you need to copy the name value. If you update the name value, you need to update all map values. This is a pain, but it works...
{
name: 'biology',
classes: {
523k1s: 'biology',
392922: 'biology',
...
}
}
Then you can use orderBy()
, but without a where clause to get the desired result like so:
query(
collection(db, 'classes'),
orderBy(`students.${studentID}`)
);
OR
query(
collection(db, 'classes'),
where(`students.${studentID}`, '>', ' ')
);
Note: A space is the equivalent of 0
in ASCII.
Method 2#
This only works on one field at a time, so for multiple fields, you have to consider the document ID. Documents are sorted by default by their document ID. Basically, you need to create a compound index on the document ID.
const newDocumentID = doc(collection(db, 'classes')).id;
const compoundDocumentID = className + '__' + newDocumentID;
And use this when you create a new class document.
batch.set(doc(db, 'classes', compoundDocumentID), {
className,
students: {
[studentID]: true
}
}, { merge: true });
A default query would work and sort by the class name since it sorts by the document ID by default.
query(
collection(db, 'classes'),
where('students.${studentID)`, '==', true)
);
Note: You can't use serverTimestamp()
here if you want to sort by a date field (example: class start time), but theoretically, you could fix this in a Firebase Cloud Function by copying the document and creating a new one with the correct date.
Get all classes that student X AND student Y are taking sorted by the class name#
query(
collection(db, 'classes'),
where(`students.${studentID}`, '==', true),
where(`students.${studentID2}`, '==', true)
);
This will work for multiple array items as well.
J