Angular and Google Cloud Firestore: NoSQL Database

This tutorial will guide you through the process of integrating Angular with Google Cloud Firestore, a NoSQL database. We will cover everything from setting up Angular and creating a Firestore project to working with collections, documents, and real-time updates. Additionally, we will explore how to implement user authentication and secure your Firestore data.

angular google cloud firestore nosql database

Introduction

What is Angular?

Angular is a popular open-source framework for building web applications. It allows developers to create dynamic and scalable applications using TypeScript, HTML, and CSS. Angular provides a robust set of features, including data binding, dependency injection, and component-based architecture.

What is Google Cloud Firestore?

Google Cloud Firestore is a NoSQL document database that is part of the Google Cloud Platform. It offers a flexible data model, real-time updates, and automatic scaling, making it an ideal choice for building modern applications. Firestore stores data in documents, which are organized into collections and can be queried using a powerful query language.

Why use NoSQL databases?

NoSQL databases, such as Firestore, offer several advantages over traditional SQL databases. They provide a flexible data model that allows for easy schema changes and accommodate unstructured data. NoSQL databases also offer horizontal scalability, making it easier to handle large amounts of data and high traffic loads. Additionally, they support real-time updates, which are crucial for building responsive and collaborative applications.

Setting Up Angular and Google Cloud Firestore

Before we dive into working with Angular and Firestore, we need to set up our development environment. This involves installing Angular and creating a Firestore project.

Installing Angular

To install Angular, you need to have Node.js and npm (Node Package Manager) installed on your machine. If you don't have them installed, you can download and install them from the official Node.js website (https://nodejs.org). Once you have Node.js and npm installed, you can use the following command to install the Angular CLI:

npm install -g @angular/cli

After the installation is complete, you can create a new Angular project by running the following command:

ng new my-angular-app

This command will create a new directory called my-angular-app with a basic Angular project structure.

Creating a Google Cloud Firestore project

To create a Firestore project, you need to have a Google Cloud Platform (GCP) account. If you don't have one, you can create a free account at https://cloud.google.com/. Once you have a GCP account, you can create a new project by following these steps:

  1. Go to the GCP Console (https://console.cloud.google.com/).
  2. Click on the project dropdown menu and select "New Project".
  3. Enter a name for your project and click on the "Create" button.

Once your project is created, you can enable Firestore by going to the "Firestore" section in the GCP Console and clicking on the "Create database" button. Follow the prompts to set up your Firestore database.

Configuring Angular to connect to Firestore

To connect our Angular application to Firestore, we need to configure the necessary credentials and dependencies.

First, we need to install the Firebase JavaScript SDK by running the following command in the root directory of our Angular project:

npm install firebase

Next, we need to import the Firebase SDK and initialize it with our Firestore project credentials. Open the src/environments/environment.ts file and add the following code:

export const environment = {
  production: false,
  firebase: {
    apiKey: "YOUR_API_KEY",
    authDomain: "YOUR_AUTH_DOMAIN",
    projectId: "YOUR_PROJECT_ID",
    storageBucket: "YOUR_STORAGE_BUCKET",
    messagingSenderId: "YOUR_MESSAGING_SENDER_ID",
    appId: "YOUR_APP_ID"
  }
};

Replace the placeholders with your actual Firestore project credentials, which can be found in the "Project settings" section of the GCP Console.

Now, we need to import the necessary Firebase modules and initialize the Firebase app in our Angular application. Open the src/app/app.module.ts file and add the following code:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AngularFireModule } from '@angular/fire';
import { environment } from '../environments/environment';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    AngularFireModule.initializeApp(environment.firebase)
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

The AngularFireModule.initializeApp() method initializes the Firebase app with our Firestore project credentials.

With these configurations in place, our Angular application is now connected to Firestore, and we can start working with collections and documents.

Working with Firestore Collections

In Firestore, data is organized into collections, which are groups of documents. Each document contains a set of key-value pairs, where the keys are strings and the values can be various data types. Let's explore how to create a collection, add documents to it, and query the documents.

Creating a Firestore collection

To create a new collection in Firestore, we use the collection() method on the Firestore instance. This method takes the name of the collection as a parameter and returns a reference to the collection. We can then use this reference to add documents to the collection or query the documents.

Here's an example of creating a collection called "users":

import { Component } from '@angular/core';
import { AngularFirestore, AngularFirestoreCollection } from '@angular/fire/firestore';

interface User {
  name: string;
  age: number;
}

@Component({
  selector: 'app-root',
  template: `
    <h1>Firestore Collection Example</h1>
    <button (click)="addUser()">Add User</button>
    <ul>
      <li *ngFor="let user of users">{{ user.name }}</li>
    </ul>
  `
})
export class AppComponent {
  private usersCollection: AngularFirestoreCollection<User>;
  users: User[];

  constructor(private firestore: AngularFirestore) {
    this.usersCollection = this.firestore.collection<User>('users');
    this.usersCollection.valueChanges().subscribe(users => this.users = users);
  }

  addUser() {
    const newUser: User = {
      name: 'John Doe',
      age: 30
    };
    this.usersCollection.add(newUser);
  }
}

In this example, we define an interface User that represents the structure of a user document. The AppComponent class has a usersCollection property of type AngularFirestoreCollection<User>, which references the "users" collection in Firestore. We use the valueChanges() method to subscribe to changes in the collection and update the users array accordingly.

The addUser() method adds a new user document to the "users" collection using the add() method of the usersCollection reference.

Adding documents to a collection

Adding a document to a Firestore collection is straightforward. We create a JavaScript object that represents the document data and use the add() method on the collection reference to add the document.

In the previous example, the addUser() method adds a new user document to the "users" collection. The user document is represented by the newUser object, which has the properties name and age. The add() method creates a new document with an auto-generated ID and sets the document data to the newUser object.

Querying documents in a collection

Firestore provides a powerful query language that allows us to filter and sort documents in a collection. We can use the where() method to specify conditions for the query, the orderBy() method to sort the documents, and the limit() method to limit the number of documents returned.

Here's an example of querying the "users" collection to retrieve users older than 25 and sort them by age in descending order:

import { Component } from '@angular/core';
import { AngularFirestore, AngularFirestoreCollection, QueryFn } from '@angular/fire/firestore';

interface User {
  name: string;
  age: number;
}

@Component({
  selector: 'app-root',
  template: `
    <h1>Firestore Query Example</h1>
    <ul>
      <li *ngFor="let user of users">{{ user.name }}</li>
    </ul>
  `
})
export class AppComponent {
  private usersCollection: AngularFirestoreCollection<User>;
  users: User[];

  constructor(private firestore: AngularFirestore) {
    const queryFn: QueryFn = ref => ref.where('age', '>', 25).orderBy('age', 'desc');
    this.usersCollection = this.firestore.collection<User>('users', queryFn);
    this.usersCollection.valueChanges().subscribe(users => this.users = users);
  }
}

In this example, we define a queryFn function that specifies the conditions and sorting for the query. The where() method filters the documents based on the "age" field, the orderBy() method sorts the documents by the "age" field in descending order, and the collection() method takes the queryFn function as the second parameter.

The usersCollection reference now represents the result of the query, and the users array is updated with the query results.

Working with Firestore Documents

In Firestore, data is stored in documents, which are organized into collections. Each document is identified by a unique ID and contains a set of key-value pairs. Let's explore how to create a document, update its data, and delete it.

Creating a Firestore document

To create a new document in Firestore, we use the add() method on the collection reference. This method takes an object representing the document data and returns a promise that resolves to the document reference.

Here's an example of creating a new document in the "users" collection:

import { Component } from '@angular/core';
import { AngularFirestore, AngularFirestoreCollection } from '@angular/fire/firestore';

interface User {
  name: string;
  age: number;
}

@Component({
  selector: 'app-root',
  template: `
    <h1>Firestore Document Example</h1>
    <button (click)="addUser()">Add User</button>
  `
})
export class AppComponent {
  private usersCollection: AngularFirestoreCollection<User>;

  constructor(private firestore: AngularFirestore) {
    this.usersCollection = this.firestore.collection<User>('users');
  }

  addUser() {
    const newUser: User = {
      name: 'John Doe',
      age: 30
    };
    this.usersCollection.add(newUser).then(docRef => {
      console.log('Document ID:', docRef.id);
    });
  }
}

In this example, the addUser() method creates a new user document with the properties name and age. The add() method returns a promise that resolves to the document reference, which we can use to retrieve the ID of the newly created document.

Updating a Firestore document

To update the data of a Firestore document, we use the update() method on the document reference. This method takes an object representing the updated document data and applies the changes to the document.

Here's an example of updating the data of a document in the "users" collection:

import { Component } from '@angular/core';
import { AngularFirestore, AngularFirestoreCollection, DocumentReference } from '@angular/fire/firestore';

interface User {
  name: string;
  age: number;
}

@Component({
  selector: 'app-root',
  template: `
    <h1>Firestore Document Example</h1>
    <button (click)="updateUser()">Update User</button>
  `
})
export class AppComponent {
  private userDocument: DocumentReference<User>;

  constructor(private firestore: AngularFirestore) {
    this.userDocument = this.firestore.doc<User>('users/user1').ref;
  }

  updateUser() {
    const updatedData: User = {
      name: 'Jane Doe',
      age: 35
    };
    this.userDocument.update(updatedData);
  }
}

In this example, the updateUser() method updates the data of the document with the ID "user1" in the "users" collection. The update() method applies the changes specified in the updatedData object to the document.

Deleting a Firestore document

To delete a Firestore document, we use the delete() method on the document reference. This method removes the document and all of its data from Firestore.

Here's an example of deleting a document in the "users" collection:

import { Component } from '@angular/core';
import { AngularFirestore, AngularFirestoreCollection, DocumentReference } from '@angular/fire/firestore';

interface User {
  name: string;
  age: number;
}

@Component({
  selector: 'app-root',
  template: `
    <h1>Firestore Document Example</h1>
    <button (click)="deleteUser()">Delete User</button>
  `
})
export class AppComponent {
  private userDocument: DocumentReference<User>;

  constructor(private firestore: AngularFirestore) {
    this.userDocument = this.firestore.doc<User>('users/user1').ref;
  }

  deleteUser() {
    this.userDocument.delete();
  }
}

In this example, the deleteUser() method deletes the document with the ID "user1" in the "users" collection. The delete() method removes the document from Firestore.

Real-time Updates with Firestore

One of the key features of Firestore is its ability to provide real-time updates. This means that any changes made to the data in a Firestore collection or document are immediately reflected in all connected clients. Let's explore how to listen for real-time updates, handle data changes, and implement real-time collaboration.

Listening for real-time updates

To listen for real-time updates in a Firestore collection or document, we use the valueChanges() method on the collection or document reference. This method returns an Observable that emits the current data and any subsequent changes.

Here's an example of listening for real-time updates in the "users" collection:

import { Component } from '@angular/core';
import { AngularFirestore, AngularFirestoreCollection } from '@angular/fire/firestore';
import { Observable } from 'rxjs';

interface User {
  name: string;
  age: number;
}

@Component({
  selector: 'app-root',
  template: `
    <h1>Real-time Updates Example</h1>
    <ul>
      <li *ngFor="let user of users$ | async">{{ user.name }}</li>
    </ul>
  `
})
export class AppComponent {
  private usersCollection: AngularFirestoreCollection<User>;
  users$: Observable<User[]>;

  constructor(private firestore: AngularFirestore) {
    this.usersCollection = this.firestore.collection<User>('users');
    this.users$ = this.usersCollection.valueChanges();
  }
}

In this example, the users$ property is an Observable that emits an array of users. The async pipe in the template subscribes to the Observable and automatically updates the UI whenever the data changes.

Handling real-time data changes

When listening for real-time updates, it's important to handle data changes properly to avoid memory leaks and unnecessary processing. The valueChanges() method returns an Observable that needs to be unsubscribed when it's no longer needed.

To handle data changes, we can use the async pipe in the template, as shown in the previous example. The async pipe automatically subscribes to the Observable and unsubscribes when the component is destroyed.

Alternatively, we can manually subscribe to the Observable and unsubscribe in the component's ngOnDestroy() lifecycle hook. Here's an example:

import { Component, OnDestroy } from '@angular/core';
import { AngularFirestore, AngularFirestoreCollection } from '@angular/fire/firestore';
import { Subscription } from 'rxjs';

interface User {
  name: string;
  age: number;
}

@Component({
  selector: 'app-root',
  template: `
    <h1>Real-time Updates Example</h1>
    <ul>
      <li *ngFor="let user of users">{{ user.name }}</li>
    </ul>
  `
})
export class AppComponent implements OnDestroy {
  private usersCollection: AngularFirestoreCollection<User>;
  private usersSubscription: Subscription;
  users: User[];

  constructor(private firestore: AngularFirestore) {
    this.usersCollection = this.firestore.collection<User>('users');
    this.usersSubscription = this.usersCollection.valueChanges().subscribe(users => this.users = users);
  }

  ngOnDestroy() {
    this.usersSubscription.unsubscribe();
  }
}

In this example, we store the subscription in the usersSubscription property and unsubscribe in the ngOnDestroy() method. This ensures that the subscription is properly cleaned up when the component is destroyed.

Implementing real-time collaboration

Real-time updates in Firestore enable us to build collaborative applications where multiple users can edit the same data simultaneously. We can use Firestore's built-in concurrency control to handle conflicts and ensure data consistency.

To implement real-time collaboration, we need to set up Firestore security rules to allow read and write access to the data. We can also use Firestore transactions to handle conflicts when multiple users try to update the same document simultaneously.

Here's an example of implementing real-time collaboration using Firestore:

import { Component } from '@angular/core';
import { AngularFirestore, AngularFirestoreCollection } from '@angular/fire/firestore';
import { Observable } from 'rxjs';

interface Note {
  text: string;
  updatedBy: string;
  updatedAt: number;
}

@Component({
  selector: 'app-root',
  template: `
    <h1>Real-time Collaboration Example</h1>
    <div *ngFor="let note of notes$ | async">
      <input [(ngModel)]="note.text" [disabled]="note.updatedBy !== currentUser">
      <button (click)="updateNoteText(note)">Update</button>
    </div>
  `
})
export class AppComponent {
  private notesCollection: AngularFirestoreCollection<Note>;
  notes$: Observable<Note[]>;
  currentUser: string;

  constructor(private firestore: AngularFirestore) {
    this.notesCollection = this.firestore.collection<Note>('notes');
    this.notes$ = this.notesCollection.valueChanges();
    this.currentUser = 'user1'; // Replace with actual user ID
  }

  updateNoteText(note: Note) {
    const updatedNote: Note = {
      ...note,
      updatedBy: this.currentUser,
      updatedAt: Date.now()
    };
    this.notesCollection.doc(note.id).update(updatedNote);
  }
}

In this example, we have a collection of notes where each note has a text field, an updatedBy field to track the user who last updated the note, and an updatedAt field to track the timestamp of the last update.

The AppComponent class has a notesCollection property that references the "notes" collection in Firestore. The notes$ property is an Observable that emits an array of notes, which we can iterate over in the template. The currentUser property represents the ID of the current user.

The updateNoteText() method updates the text, updatedBy, and updatedAt fields of a note document. We use the update() method on the document reference to apply the changes.

By enabling read and write access to the "notes" collection in the Firestore security rules, multiple users can edit the same note simultaneously. Firestore's concurrency control ensures that conflicts are handled properly, and the changes made by each user are reflected in real-time.

Authentication and Security

Firestore provides built-in authentication and security features to protect your data and control access to it. In this section, we will explore how to implement user authentication, secure Firestore data, and manage user roles and permissions.

Implementing user authentication

To implement user authentication in Firestore, we can use Firebase Authentication, which is tightly integrated with Firestore. Firebase Authentication provides various authentication methods, including email/password, social sign-in (e.g., Google, Facebook), and custom authentication.

To get started with Firebase Authentication, we need to enable it in the Firebase project settings and configure the desired authentication methods. Once authentication is enabled, users can sign up and sign in to our application using the configured methods.

Here's an example of implementing user authentication using Firebase Authentication and Firestore:

import { Component } from '@angular/core';
import { AngularFireAuth } from '@angular/fire/auth';

@Component({
  selector: 'app-root',
  template: `
    <h1>User Authentication Example</h1>
    <div *ngIf="user$ | async as user">
      <p>Welcome, {{ user.email }}!</p>
      <button (click)="signOut()">Sign Out</button>
    </div>
    <div *ngIf="!(user$ | async)">
      <p>Please sign in to continue.</p>
      <button (click)="signInWithGoogle()">Sign In with Google</button>
    </div>
  `
})
export class AppComponent {
  user$ = this.auth.user;

  constructor(private auth: AngularFireAuth) {}

  signInWithGoogle() {
    this.auth.signInWithPopup(new firebase.auth.GoogleAuthProvider());
  }

  signOut() {
    this.auth.signOut();
  }
}

In this example, the user$ property is an Observable that emits the currently signed-in user. The async pipe in the template subscribes to the Observable and displays the user's email if they are signed in. If the user is not signed in, a sign-in button is displayed.

The signInWithGoogle() method initiates the Google sign-in flow using the signInWithPopup() method of the AngularFireAuth service. This method opens a popup window where the user can sign in with their Google account.

The signOut() method signs the user out by calling the signOut() method of the AngularFireAuth service.

Securing Firestore data

Firestore security rules allow us to control read and write access to our Firestore data. We can write rules that are based on the authenticated user's identity, such as their user ID or email address. Firestore security rules are written using a custom domain-specific language (DSL) that provides expressiveness and flexibility.

Here's an example of securing Firestore data using security rules:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      allow read, write: if request.auth.uid == userId;
    }
    match /notes/{noteId} {
      allow read: if isSignedIn();
      allow write: if isOwner(noteId) && isSignedIn();
    }
  }
  function isSignedIn() {
    return request.auth != null;
  }
  function isOwner(noteId) {
    return get(/databases/$(database)/documents/notes/$(noteId)).data.updatedBy == request.auth.uid;
  }
}

In this example, the security rules allow users to read and write their own user document in the "users" collection. For the "notes" collection, users can read any note, but can only write a note if they are the owner of the note. The ownership is determined by the isOwner() function, which checks if the authenticated user ID matches the updatedBy field of the note document.

The isSignedIn() function checks if the user is signed in by verifying the presence of the auth property in the request.

By writing security rules that enforce the desired access control policies, we can ensure that our Firestore data is secure and only accessible to authorized users.

Managing user roles and permissions

In addition to basic read and write access control, Firestore allows us to implement more complex user roles and permissions. We can use Firestore security rules in combination with custom user attributes to control access to specific parts of our data.

To manage user roles and permissions, we need to define custom user attributes in Firebase Authentication and use them in Firestore security rules. Custom user attributes can represent roles, groups, or any other additional information associated with the user.

Here's an example of managing user roles and permissions using custom user attributes and Firestore security rules:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /notes/{noteId} {
      allow read: if isSignedIn();
      allow write: if hasRole('admin') && isSignedIn();
    }
  }
  function isSignedIn() {
    return request.auth != null;
  }
  function hasRole(role) {
    return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.roles[role] == true;
  }
}

In this example, the security rules allow users with the "admin" role to write to the "notes" collection. The role is determined by the hasRole() function, which checks if the user has the specified role in their user document.

The hasRole() function retrieves the user document using the get() function and checks if the specified role is set to true in the roles field of the user document.

By defining custom user attributes and using them in Firestore security rules, we can implement fine-grained access control and manage user roles and permissions effectively.

Conclusion

In this tutorial, we explored how to integrate Angular with Google Cloud Firestore, a NoSQL database. We covered everything from setting up Angular and creating a Firestore project to working with collections, documents, and real-time updates. We also learned how to implement user authentication, secure Firestore data, and manage user roles and permissions.

Angular and Firestore provide a powerful combination for building modern and scalable applications. With their rich set of features and seamless integration, you can create robust and collaborative applications that meet the needs of software developers.