Create an app fast with Firebase, even if you’re intimidated by NoSQL databases—the translation of queries to SQL is easier than you think.
SQL databases are quite different than NoSQL databases, so you must think a little differently when modeling your data. However, most translations from SQL code to NoSQL are identical or easier. Here, we use the Firebase Modular API, but the firebase-admin
API will be the same.
Firestore is a document database that doesn’t allow joins. You can simulate them by duplicating your data. Most regular queries, however, work as expected. The lack of a schema allows you to get up and going extremely quickly. You need to learn the data modeling patterns.
Firestore is a NoSQL document database.
You create a table when you create a document. A table does not exist without a document.
There is nothing to alter.
To drop a table, you must delete all documents in a collection. This can also be done in the Firebase Console.
There is no schema, but you can declare constraints using Firebase Rules.
You will usually see camel case field names instead of snake case.
Each document has a unique path. The documentId()
of the document is the primary key. In the collection posts/2xelskel
, the 2xelskel
is the document ID. You can use any alphanumeric string, as well as _
characters. Otherwise, it will be automatically generated.
You can insert data in SQL by declaring each field and value.
INSERT INTO posts (title, content, created_by, created_at)
VALUES (
'My First Post',
'This is the content of my first post.',
1,
NOW()
);
addDoc(
collection(db, 'posts'),
{
title: 'My First Post',
content: 'This is the content of my first post.',
createdBy: 1,
createdAt: serverTimestamp()
}
});
📝 Notice the serverTimestamp()
function, which will use the date on the server. Since Firestore can be safely run on the client, this would be secured in Firestore Rules.
Updating is basically the same.
UPDATE posts
SET title = 'Updated Post Title',
content = 'This is the updated content of the post.',
updated_at = NOW()
WHERE post_id = 1;
updateDoc(
collection(db, 'posts', '1'),
{
title = 'Updated Post Title',
content = 'This is the updated content of the post.',
updatedAt: serverTimestamp()
}
});
📝 Notice the document ID is 1
in this case. The ID will probably be a uniquely generated string in a real application.
You can upsert a document with setDoc
.
INSERT INTO posts (id, title, content, created_by, created_at, updated_at)
VALUES (1, 'Upserted Post', 'This is the upserted content.', 1, NOW(), NOW())
ON CONFLICT (id)
DO UPDATE SET
title = EXCLUDED.title,
content = EXCLUDED.content,
author_id = EXCLUDED.created_by,
updated_at = NOW();
📝 This will vary between SQL languages.
setDoc(
doc(db, 'posts', '1'),
{
title: 'Upserted Post',
content: 'This is the upserted content.',
createdBy: 1,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp()
}, { merge: true }
});
📝 setDoc
behaves like addDoc
without the { merge: true }
parameter.
In Firestore, you can only select all fields from a document unless you use the REST API with your own code.
SELECT * FROM posts;
Select all posts.
query(
collection(db, 'posts')
);
In SQL, you SELECT
first.
SELECT EXISTS (
SELECT 1
FROM posts
WHERE id = 1
);
const postSnap = await getDoc(doc(db, 'posts', '1'));
const exists = postSnap.exists();
In Firestore, you must read the document to check its existence.
Of course, we limit our selection to one or more in actual use cases. We don’t want all documents in a collection or all rows in a table.
SELECT * FROM posts LIMIT 1;
query(
collection(db, 'posts'),
limit(1)
);
There is nothing surprising here.
Firestore queries use AND
by default, but you can also add OR
queries.
SELECT * FROM posts
WHERE
(a = 1 AND c = 3) OR
(a = 1 AND d = 4) OR
(b = 2 AND c = 3) OR
(b = 2 AND d = 4);
query(
collection(db, 'posts'),
or(
and(where('a', '==', 1), where('c', '==', 3)),
and(where('a', '==', 1), where('d', '==', 4)),
and(where('b', '==', 2), where('c', '==', 3)),
and(where('b', '==', 2), where('d', '==', 4))
)
);
⚠️ Unlike in SQL, each query has a strict limit of 30 disjunctions.
You can search for IN
as well in Firestore.
SELECT *
FROM posts
WHERE created_by IN (1, 2, 3);
query(
collection(db, 'posts'),
where('created_by', 'in', [1, 2, 3])
)
Keep in mind this is a simplification of:
query(
collection(db, 'posts'),
or(
where('created_by', '==', 1),
where('created_by', '==', 2),
where('created_by', '==', 3)
)
);
The same goes for SQL. IN
is just several OR
clauses on the same field, condensed.
You can order your data similar to SQL.
SELECT * FROM posts
ORDER BY created_by ASC, created_at DESC;
query(
collection(db, 'posts'),
orderBy('createdBy'),
orderBy('createdAt', 'desc')
);
📝 The default sort order is asc
.
📝 An orderBy
also sorts data, so the field must exist. Otherwise, it is filtered out.
⚠️ Changing orderBy
fields requires a composite index for each query. If you change the field, you need another index.
You can have inequalities on multiple fields.
SELECT * FROM cities
WHERE population > 1000000
AND density < 10000;
query(
collection(db, 'cities'),
where('population', '>', 1000000),
where('density', '<', 10000),
);
📝 Order index fields by equalities followed by most selective range or inequality field.
⚠️ Order fields in decreasing order of query constraint selectivity.
⚠️ Queries with range or inequality filters on document fields and only equality constraints on the document key (__name__)
aren’t supported.
⚠️ Cloud Firestore limits the number of range or inequality fields to 10. This prevents queries from becoming too expensive to run.
You can only have a !=
on one field in Firestore per query.
SELECT * FROM cities
WHERE capital != false;
query(
collection(db, 'cities'),
where("capital", "!=", false)
);
⚠️ A !=
query clause might match many documents in a collection. To control the number of results returned, use a limit clause.
📝 Not-equal queries exclude documents where the given field does not exist.
📝 For multiple not equals on the same field, use not-in
.
You don’t need to increment columns in SQL until your database gets big. Either way, the code is simple.
UPDATE posts
SET views = views + 1
WHERE id = 1;
updateDoc(
doc(db, 'posts', '1'),
{
views: increment(1)
}
});
📝 This is more useful in NoSQL databases. You can also use increment(-1)
for decrementing.
Counting can get complex in Firestore, but generally, you want to count on the server.
SELECT COUNT(*) AS post_count
FROM posts
WHERE created_by = 1;
const countSnap = await getCountFromServer(
query(
collection(db, 'posts')
)
);
const postCount = countSnap.data().count;
📝 You need two separate calls to count a query and return the query results.
You can also do sum
and average
aggregations.
📝 min
and max
are not supported.
See Aggregation Queries.
You can’t sort by a count directly, so you must first count in Firestore.
Let’s say we want to get all users and them by post_count
, or the number of posts they have.
SELECT users.*, COUNT(posts.id) AS post_count
FROM users
JOIN posts ON users.id = posts.author_id
GROUP BY users.id
ORDER BY post_count DESC;
In Firestore, we must create the post_count
with each post created in the user’s document. We can do this in a batch
transaction.
const userRef = doc(db, 'users/1');
const postRef = doc(collection(db, 'posts'));
const batch = writeBatch(db);
batch.set(userRef, {
postCount: increment(1)
}, { merge: true });
batch.set(postRef, {
createdBy: auth.currentUser,
createdAt: serverTimestamp(),
title: 'Some post',
content: 'post content here'
});
batch.commit();
}
And finally, we can sort the documents later.
query(
collection(db, 'users'),
orderBy('postCount', 'desc')
);
You can secure this in Firestore Rules or create a Firebase Trigger Function on onCreate
that automatically increments and decrements the user document.
Counters get extremely complicated in Firestore. See Four Ways to Count in Firestore for niche cases.
The only way to keep something unique is to set it as the document ID in Firestore. If you want a unique field, you must put it in two places in a transaction.
For uniqueness, we can save a username in the users
collection on the user document and in a usernames
collection in a single transaction. We can use Firestore Rules to secure this.
SELECT DISTINCT username
FROM users
ORDER BY username ASC;
const batch = writeBatch();
batch.set(
doc(`usernames/${username}`),
{
uid,
username
}
);
batch.set(
doc(`users/${uid}`),
{
username
},
{ merge: true }
);
await batch.commit();
If you update the username, also consider removing the old username in the usernames
collection first.
query(
collection(db, 'usernames'),
orderBy('usernames')
);
If you want to search for all countries users belong to, you must also store a unique countries
collection.
SELECT country, COUNT(*) as user_count
FROM users
GROUP BY country
ORDER BY user_count DESC;
const batch = writeBatch();
batch.set(
doc(`countries/${country}`),
{
uid,
country
userCount: increment(1)
}
);
batch.set(
doc(`users/${uid}`),
{
country
},
{ merge: true }
);
await batch.commit();
You could also create the countries using a Firebase Trigger Function. You should also consider keeping a count of the number of users in each country. This could come in handy if you want to search for the most popular country your users belong to.
query(
collection(db, 'countries'),
orderBy('userCount', 'desc')
);
🖋️ The common theme you start to see is that Firestore requires you to create your database for the queries instead of vice versa.
There is no LIKE
equivalent in Firestore.
SELECT country
FROM users
WHERE country LIKE 'Uni%';
However, there is a startsWith
hack you can use.
export const startsWith = (
fieldName: string,
term: string
) => {
return [
orderBy(fieldName),
startAt(term),
endAt(term + '~')
];
};
This function can be used to find a string that starts with a term. It only works in parent objects and does not work with arrays or objects.
query(
collection(db, 'users'),
...startsWith('country', 'Uni')
);
Use an external database or store your search index inside Firestore if you want Fuzzy Searching capabilities. You can do this with Vectors or a custom function like Soundex.
You can easily do an inner join in Firestore by querying two different collections, but this will cost you an extra document read and could potentially slow down your app.
The class example is adding user information to a post.
SELECT
p.id AS post_id,
p.title AS post_title,
p.content AS post_content,
jsonb_build_object(
'id', u.id,
'name', u.name,
'email', u.email
) AS created_by
FROM posts p
JOIN users u ON p.created_by = u.id;
You could easily do this without JSON, but I wanted an equivalent object.
addDoc(
doc(db, 'posts', 'post-id'),
{
title: 'some title',
content: 'some content',
createdBy: {
uid: auth.currentUser.uid,
displayName: 'auth.currentUser.displayName,
username: 'get username from user document or custom claim'
}
}
});
This could be done in many ways, but the query is easy.
getDoc(
doc(db, 'posts', 'post-id-here')
);
Again, you need to consider when a user updates their username
, displayName
or other profile information. You should create a Firebase Trigger Function to handle it.
Here, we have posts and tags. Each post can have many tags, but you won’t have an infinite number of tags on a post. Firestore allows you to store data as arrays and search based on the array. An array works perfectly in this case.
In SQL, we may have a tags table with the user_id
.
SELECT p.id AS post_id, p.title, p.content, t.tag
FROM posts p
LEFT JOIN tags t ON p.id = t.post_id
ORDER BY p.id;
Or, we may have a junction table post_tag
.
SELECT p.id AS post_id, p.title, p.content, t.name AS tag_name
FROM posts p
JOIN post_tag pt ON p.id = pt.post_id
JOIN tags t ON pt.tag_id = t.id
ORDER BY p.id;
In Firestore, we must create the tags in two different places, similar to the unique index.
const batch = writeBatch(db);
const tags = ['tag1', 'tag2'];
// add tags array when adding post
batch.set(
doc(db, 'posts', 'some-post-id'), {
tags,
title: 'some title',
content: 'some content',
createdBy: auth.currentUser.uid,
createdAt: serverTimestamp()
});
// create a tag document
tags.forEach(tag => {
batch.set(doc(db, 'tags', tag), {
postCount: increment(1)
}, { merge: true });
});
await batch.commit();
🖋️ We must also remember to decrement the postCount
on tags when a post is modified or deleted. This should be done with Firebase Functions, as Firestore Rules would get too complex for multiple tags. Firestore Rules also do not have loops.
query(
collection(db, 'posts')
);
The tags will already be attached to the post document.
Firestore does not handle complex queries, but there are workarounds for most use cases. This post only scratches the surface.
A NoSQL database is quite different from an SQL database. You must think about your queries before you even create a document. Once you learn the patterns, you can create any structure.
🌐 I am still a firm believer that all data is relational!
But, Firebase allows you to create an app faster than any database platform I have used. Firestore is amazing in this aspect.
Jonathan Gamble has been an avid web programmer for more than 20 years. He has been building web applications as a hobby since he was 16 years old, and he received a post-bachelor’s in Computer Science from Oregon State. His real passions are language learning and playing rock piano, but he never gets away from coding. Read more from him at https://code.build/.