Creating and querying nested collections in Firebase
CRUD — Create, Read, Update, Delete — operations allow a developer to manage and access the data in their databases in a way that is appropriate to a persistent storage application. CRUD operations can be implemented both in relational database management systems, like PostgresQL and MySQL, as well as non-relational database management systems like MongoDB and Firebase.
Considering non-relational DBMS, the lack of a relational structure between the data can cause some confusion when performing CRUD operations, because the nested structure of the data complicates each call, particularly with databases that use a document-and-collection structure like Firebase.
In Firebase, the way in which data is initially stored is rather straightforward and not too different from performing a ‘create’ operation in a typical RDBMS. But when the need arises to create a relationship between two ‘collections’ (or ‘tables/models’ in a RDBMS), a developer is faced with the challenge of creating a separate collection with nested documents and collections of data that hold what is essentially a copy of the data from the collection one needs to link.
In the example above, we have an application that relies on data about users and events, but also has to manage the storage of restaurants associated with an event and the guests attending the event, which are comprised of the application users. In a typical RDBMS, we would be able to create a join table to help us associate the users, events, and restaurants, but in a non-relational database, the solution is to create two separate collections which both have references to the users collection and the events collection.
To create the linking collections, we would have to ensure that a document in the users collection stores a reference to its ‘id’ (‘V6tdJWgInCOM7R6QKhcC1TQdftf2’ in our example) that is added to the document at the moment it is set in the collection, see below:
firebase
.auth()
.createUserWithEmailAndPassword(email, password)
.then((response) => {
const uid = response.user.uid;
const data = {
id: uid,
email,
fullName,
phoneNumber
};
const usersRef = firebase.firestore().collection('users');
usersRef
.doc(uid)
.set(data)
The same would apply to the events collection, in which each document (or ‘event’) stores a reference to its own event id as well as a reference to the id of the current user, to capture the association with the user creating the event.
storeEvent = () => {
const currentUser = firebase.auth().currentUser.uid;
const eventsRef = firebase.firestore().collection('events');
const document = this.eventsRef.doc();
const documentId = document.id;
eventsRef
.doc(documentId)
.set({
name: this.state.name,
date: this.state.date,
eventStartTime: this.state.eventStartTime,
description: this.state.description,
votingDeadline: this.state.votingDeadline,
eventEndTime: this.state.eventEndTime,
eventCreated: timestamp,
userId: currentUser,
docId: documentId,
})
In this particular function and all throughout the application, when needing to capture the current user logged in to the app, Firebase’s handy currentUser method was used, as seen above.
Now, once we have the basic user and event collections outlined and set up, the collection which ties together the event and restaurants associated with the event must be defined.
Taking a look at the nested structure of the eventRestaurants collection, it becomes apparent that the complexity of creating associations in a non-relational database arises from the limitations that Firebase has on nesting documents within documents. Firebase is set up in a way that documents can only be contained within collections, thus imposing the developer with the necessity to create a nested eventRestaurants collection within the document, which then holds it own set of documents (the ‘restaurants’ in our example).
The initial document created within the eventRestaurants collection has an id identical to that of the event where we would like to add a reference to multiple restaurants. That document, ‘1FNrGPVqYH2swfwpPxGl’ here, then has a nested eventRestaurants collection, which holds a set of documents that have their own unique restaurant ids. When looking at a restaurant document, we can see that we are again adding a reference to the event id and the restaurant id itself. This is done to simplify the querying process later on in the application code.
storeRestaurant = (eventId, restaurant) => {
const eventRestaurantsRef =
firebase
.firestore()
.collection('eventRestaurants');
eventRestaurantsRef
.doc(eventId)
.collection('eventRestaurants')
.doc(restaurant.place_id)
.set({
name: restaurant.name,
photo: restaurant.photos[0].photo_reference,
address: restaurant.vicinity,
eventId: eventId,
votes: 0,
id: restaurant.place_id,
rating: restaurant.rating
})
Similarly, adding specific users or ‘guests’ to a particular event requires using a nested collection-document structure. In the application used as an example here, we are utilizing the user’s phone number, which is added to the database when a host user invites a friend by text, as the linking point between a specific user and an event, while also adding a reference to the event id within the nested document. That way, we can query a phone number from the eventGuests collection and get back a nested collection of the events a user is invited to. The documents within that collection are referenced by the event id.
setGuestList = (eventId, phoneNumber) => {
const eventGuestsRef = firebase
.firestore()
.collection('eventGuests');
eventGuestsRef
.doc(phoneNumber)
.collection('eventsInvitedTo')
.doc(eventId)
.set({
phoneNumber: phoneNumber,
eventId: eventId
})
Once we have successfully created all of the collections and nested collections for establishing links between the various users, events and restaurants, we can begin to query the data needed for our app. For example, if we were interested in retrieving all of the events that a particular user has hosted, we could do the following:
useEffect(() => {
if (!firebase.auth().currentUser) {
return;
}
const currentUser = firebase.auth().currentUser.uid;
const unsubscribe = firebase
.firestore()
.collection('events')
.where('userId', '==', currentUser)
.onSnapshot((snapshot) => {
const result = [];
snapshot.forEach((doc) => {
result.push(doc.data());
});
setEventsData(result);
});
return () => unsubscribe();
}, [firebase.auth().currentUser]);
In the section above, we are utilizing the ‘where’ query to filter our results to only include events associated with the current logged in user. The process of querying a non-nested collection is relatively direct.
Querying items in a nested collection would look something like this:
async fetchData() {
try {
if (!firebase.auth().currentUser) {
return;
}
const currentUser = await firebase.auth().currentUser.uid;
let userResult;
const userData = await firebase
.firestore()
.collection('users')
.where('id', '==', currentUser)
.get();
userData.docs.forEach((doc) => {
userResult = doc.data();
});
firebase
.firestore()
.collection('eventGuests')
.doc(userResult.phoneNumber)
.collection('eventsInvitedTo')
.onSnapshot(async (guestsData) => {
let guestsResult = [];
guestsData.docs.forEach((doc) => {
guestsResult.push(doc.data());
});
let eventsResult = [];
for (let i = 0; i < guestsResult.length; i++) {
const event = guestsResult[i];
const eventsInvitedTo = await firebase
.firestore()
.collection('events')
.where('docId', '==', event.eventId)
.get();
eventsInvitedTo.docs.forEach((doc) => {
eventsResult.push(doc.data());
});
this.setState({ eventsData: eventsResult });
}
});
The query ends up having a nested structure as well, where information from the result of the preceding call is then passed in to the next query and used to filter the results of the query to get what we need. In this example, we are first making a query to gather the phone number of the current user from the users collection; then, we query the eventGuests collection and filter it by the phone number we obtained in the previous call, which gives us all of the event ids that phone number is associated with. After that, we are able to make a query to the events collection and return an array of events with corresponding details, such as event name, description, time, etc.
Looking back at the complexity of creating and querying data when some sort of relationship between collections is necessary, it would seem that a RDBMS has many advantages over using a non-relational database like Firebase. However, it is important to note that non-relational databases are essential for scalability and management of large volumes of data, giving the developer better time and space efficiency.