Firestore is Google’s serverless document database offering.
What does serverless mean?
Serverless means that we don’t have to worry about managing the servers on which the database runs. All the management is done automatically by Google. The database will scale up or down depending on the demand.
What’s a document database?
A document database is a non-relational database that stores data in a semi-structured format. A common format used by many document databases is JSON.
In a relational database, we usually create tables with a defined structure (columns and types). In a document database, it’s not necessary to specify the different columns.
Concepts
There are two concepts we need to be familiar with to model our Firestore database: documents
and collections
.
A document
is similar to a JSON object. Each top level attribute is called a field
. A document can’t be bigger than 1MB.
A collection
is a group of documents. We can think of it as a table without a schema, just a name.
A document
can point to a collection
, and documents
in that collection
can point to other collections
.
Creating a database
When we talk about creating a database, we are referring to making it possible to create collections
and documents
in Google Cloud. At the time of this writing, Firestore databases come in 2 flavors:
Native mode
- Recommended for mobile apps or browsersDatastore mode
- Recommended for back-end servers
Currently there is no way to create a database in datastore mode
using gcloud
cli, so we need to do it from the cloud console. If we want to create a database in native mode we can use:
1
gcloud firestore databases create
This command triggers some initial setup that might take a few minutes.
Once we choose the mode for a Google Cloud project, it can’t be changed. If we want to change it, we would need to create a new project and create another database there.
Writing data
In relational databases we need to define our tables and schemas in advance. That’s not the case on Firebase. Documents are created in collections. If a collection doesn’t exist, it will be created on the fly.
Clients exist for various programming languages. We’re going to use Golang in this article.
1
2
3
4
mkdir ~/project
cd ~/project
go mod init ncona.com/firestore
touch main.go
Let’s write some data (main.go
):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main
import (
"log"
"context"
"cloud.google.com/go/firestore"
"google.golang.org/api/option"
)
// Constants necessary to create the firestore client
const GcpCredentialsFile = "/tmp/my-key.json"
const ProjectId = "project-12345"
// When done with the client close it using:
// defer client.Close()
func createClient(ctx context.Context) *firestore.Client {
client, err := firestore.NewClient(ctx, ProjectId, option.WithCredentialsFile(GcpCredentialsFile))
if err != nil {
log.Fatalf("Failed to create client: %v", err)
}
return client
}
func main() {
ctx := context.Background()
client := createClient(ctx)
defer client.Close()
_, err := client.Collection("tacos").Doc("1").Set(ctx, map[string]interface{}{
"tortilla": "wheat",
"meat": "pork",
"salsa": "green",
})
if err != nil {
log.Printf("Error updating data: %s", err)
}
}
Note that GcpCredentialsFile
must be a valid service account key with permissions for Firestore (roles/datastore.user), and ProjectId
should be the id of the project where the database lives.
We can run our program with:
1
go run main.go
The Set
operation creates a new record or replaces an existing one. The Doc("1")
part of the command tells firestore to use 1
as a document identifier. There can’t be two documents with the same identifier in a collection.
It’s also possible to tell firestore to merge
an existing document instead of replacing it:
1
2
3
_, err := client.Collection("tacos").Doc("1").Set(ctx, map[string]interface{}{
"price": "2"
}, firestore.MergeAll)
Each document we write is represented by a path. For the document we just created, this will be the path:
1
/tacos/1
Retrieving data
Now that we have some data, let’s try reading it. Let’s create a new file:
1
touch read.go
With this content:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package main
import (
"context"
"fmt"
"log"
"cloud.google.com/go/firestore"
"google.golang.org/api/iterator"
"google.golang.org/api/option"
)
// Constants necessary to create the firestore client
const GcpCredentialsFile = "/tmp/my-key.json"
const ProjectId = "project-12345"
// When done with the client close it using:
// defer client.Close()
func createClient(ctx context.Context) *firestore.Client {
client, err := firestore.NewClient(ctx, ProjectId, option.WithCredentialsFile(GcpCredentialsFile))
if err != nil {
log.Fatalf("Failed to create client: %v", err)
}
return client
}
func main() {
ctx := context.Background()
client := createClient(ctx)
defer client.Close()
iter := client.Collection("tacos").Documents(ctx)
for {
doc, err := iter.Next()
// We are done iterating. Break
if err == iterator.Done {
break
}
if err != nil {
log.Fatalf("Failed to iterate: %v", err)
}
fmt.Println(doc.Data())
}
}
We can run this program with:
1
go run read.go
And the output will be something like:
1
map[meat:pork salsa:red tortilla:corn]
The Documents
command retrieves all the documents in the collection. This is most likely not what we want to do in most cases.
To get a single document se can use this code:
1
2
3
4
5
6
7
ref, err := client.Collection("tacos").Doc("1").Get(ctx)
if err != nil {
log.Print("Taco not found")
return
}
fmt.Println(ref.Data())
To query for documents matching a criteria:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func main() {
ctx := context.Background()
client := createClient(ctx)
defer client.Close()
iter := client.Collection("tacos").Where("meat", "==", "pork").Documents(ctx)
for {
doc, err := iter.Next()
if err == iterator.Done {
break
}
if err != nil {
return
}
fmt.Println(doc.Data())
}
}
In the example above, we used ==
, but we can also use:
<
<=
>
>=
!=
array-contains
array-contains-any
in
not-in
It’s also possible to use limit
to specify how many documents to return:
1
iter := client.Collection("tacos").Where("meat", "==", "pork").Limit(2).Documents(ctx)
Indexes
A very brief note about indexes, because they work differently than in other databases. There are two types of indexes in firestore, single-field indexes
and composite indexes
.
As the name suggests, single-field indexes
are indexes made of a single field. Firestore automatically creates single-field indexes
for all the fields in a document, so we don’t have to worry about creating these ourselves.
As opposed to single-field indexes
, composite indexes
are not created automatically. These are indexes formed by more than one field and need to be defined before they can be used.
Conclusion
This article scratched the surface of what can be done with Firestore
. There are many things about querying that I didn’t cover, as well as other interesting topics like transactions and arrays. I’m planning on using it in a project, so hopefully I’ll be writing about these topics in the future.
architecture
databases
gcp
golang
]