May 6, 2019
  • Product
  • couchbase lite
  • Ionic Native

Build Secure Offline Apps with Ionic & Couchbase Lite

Ken Sodemann

Couchbase Lite is a full-featured NoSQL database that runs locally on mobile devices. The Offline Storage plugin, built and maintained by Ionic as part of Ionic Native, makes it easy to take advantage of the Couchbase Lite database to create your application using an offline-first architecture. This allows you to offer your users a fast and seamless experience regardless of their connectivity at the time.

New! View a live demo of Offline Storage here. Complete documentation available here.

In this article, I will demonstrate how to create an application supporting the full set of Create, Read, Update, and Delete (CRUD) operations. For simplicity, I will focus on the use of the database itself and will not get into more advanced topics such as data synchronization with a cloud-based API.

Demo Application

To demonstrate the power of the Offline Storage solution, I will use an application that displays different categories of tea. It allows the user to add new categories of tea, edit existing categories, and delete categories they no longer care about.

The complete source code for this application is available here.

Install Ionic Native

In order to use Ionic Native plugins, make sure you’re using the Ionic Enterprise Cordova CLI:

npm uninstall -g cordova
npm install -g @ionic-enterprise/cordova

Once you’ve installed the Ionic Enterprise Cordova CLI, you can register a native key, then install the Offline Storage plugin:

ionic enterprise register
ionic cordova plugin add @ionic-enterprise/offline-storage

NOTE: Ionic Native includes a reliable set of Native APIs & functionality that you can use in your Ionic app, quality controlled and maintained by the Ionic Team. Sign up here.

Initialize a Database

Create a Service

The first thing we will do is create a service, allowing us to abstract the data storage logic away from the rest of the pages and components in our application. Over time, this becomes easier to make changes to how data is stored without affecting the whole code base.

We will just be storing tea categories with this application so we will create a single service called TeaCategoriesService which will handle all of the CRUD operations. For now, it will only use a Couchbase Lite database for storage and retrieval, but could easily be updated to include cloud-based storage in the future. Within an Ionic application, run the generate service command:

ionic g service services/tea-categories/tea-categories
> ng generate service services/tea-categories/tea-categories
CREATE src/app/services/tea-categories/tea-categories.service.spec.ts (369 bytes)
CREATE src/app/services/tea-categories/tea-categories.service.ts (142 bytes)
[OK] Generated service!

Open the Database

The next step involves opening and initializing the database within TeaCategoriesService. The initializeDatabase() method below shows the basic steps required to open a database. The readyPromise is stored for use in other methods to ensure that the database has been initialized properly before we perform other operations.

import { Injectable } from '@angular/core';

import { Database, DatabaseConfiguration, IonicCBL, CordovaEngine } from 'ionic-enterprise-couchbase-lite';

@Injectable({
  providedIn: 'root'
})
export class TeaCategoriesService {
  private readyPromise: Promise<void>;
  private database: Database;

  constructor() {
    this.readyPromise = this.initializeDatabase();
  }

  private async initializeDatabase(): Promise<void> {
    return new Promise((resolve) => {
      IonicCBL.onReady(async () => {
        const config = new DatabaseConfiguration();
        config.setEncryptionKey('8e31f8f6-60bd-482a-9c70-69855dd02c38');
        this.database = new Database('teacategories', config);
        this.database.setEngine(new CordovaEngine({
          allResultsChunkSize: 9999 
        }));
        await this.database.open();
        console.log(“DB Name: “ + this.database.getName());
        console.log(“DB Path: “ + await this.database.getPath());
        resolve();
      });
    });
  }
}

Notice that no specific mobile platform has been mentioned in the code above. The plugin abstracts away those details from the Ionic web developer, providing a true cross-platform solution. To demonstrate this, build and run the application on a mobile device or in an emulator. Upon examination of the console log, you will see the iOS and Android-specific file paths to the newly created database:

On iOS:

DB Name: teacategories
DB Path: /var/mobile/Containers/Data/Application/EC31A8DD-863B-4894-BC64-B89A370377F9/
Library/Application Support/CouchbaseLite/teacategories.cblite2/

On Android:

DB Name: teacategories
DB Path: /data/user/0/io.ionic.cs_demo_couchbase_lite/files/teacategories.cblite2/

Storing Data

TeaCategory Model

A generic data model – TeaCategory – is used to communicate between the TeaCategoriesService and its consumers. This allows us to decouple the data from the actual storage mechanism, making the application more maintainable as we add features in the future.

export interface TeaCategory {
  id?: string;
  name: string;
  description: string;
}

The “id” property is optional since newly created objects will not have any ID until they are added to the database.

Back to the TeaCategoriesService

When adding a new document to the database, we create a MutableDocument object. As the name implies, this is a document object that can be changed. After the object is created, set the properties we are concerned with and save the document:

  private async add(category: TeaCategory): Promise<void> {
    await this.readyPromise;
    const doc = new MutableDocument()
      .setString('name', category.name)
      .setString('description', category.description);
    return this.database.save(doc);
  }

Notice that the id property is not set – it will be automatically assigned by the database.

TeaCategoryEditorPage

The TeaCategoryEditorPage needs to pass the appropriate data to the service, await the completion of the save, and then navigate back to the HomePage:

  async save() {
    await this.teaCategories.save({
      name: this.name,
      description: this.description
    });

    this.navController.back();
  }

Querying Documents

Now that we are able to create documents, we need to be able to display them on the app’s HomePage. In order to retrieve all of the documents from the database, we can build a query that returns the data we need, execute the query, and then unpack the data into the generic TeaCategory model that we use to pass data back and forth.

TeaCategoryService: GetAll()

The bulk of the work is performed by the service, which returns a promise that resolves to an array of tea categories. This allows us to hide the details of the storage mechanism from the consumers of the service.

  async getAll(): Promise<Array<TeaCategory>> {
    await this.readyPromise;
    const query = QueryBuilder.select(
      SelectResult.property('name'),
      SelectResult.property('description'),
      SelectResult.expression(Meta.id)
    ).from(DataSource.database(this.database))
     .orderBy(Ordering.property('name'));

    const ret = await query.execute();
    const res = await ret.allResults();

    return res.map(t => ({
      id: t._id,
      name: t.name,
      description: t.description
    }));
  }

TeaCategoryEditorPage: Retrieve Results

The TeaCategoryEditorPage page just needs to await the results of the query.

  async ngOnInit() {
    this.categories = await this.teaCategories.getAll();
  }

Updating Tea Category Documents

In order to update the tea category documents, the TeaCategoryEditorPage needs to obtain the document to edit then needs to save the changes back to the database.

TeaCategoryService: Get Document

The get routine retrieves the document based on id and unpacks the document into the model we are using to represent the data.

  async get(id: string): Promise<TeaCategory> {
    await this.readyPromise;
    const d = await this.database.getDocument(id);
    const dict = d.toDictionary();

    return {
      id: d.getId(),
      name: dict.name,
      description: dict.description
    };
  }

We do not want developers that are using the TeaCategoryService to worry about whether they are performing an insert or an update. They can just pass along a TeaCategory object that needs to be saved and the service can figure out if the operation is an “add” or an “update” based on whether or not the object has an ID.

 async save(category: TeaCategory): Promise<void> {
    return category.id ? this.update(category) : this.add(category);
  }

  private async add(category: TeaCategory): Promise<void> {
    await this.readyPromise;
    const doc = new MutableDocument()
      .setString('name', category.name)
      .setString('description', category.description);

    return this.database.save(doc);
  }

  private async update(category: TeaCategory): Promise<void> {
    await this.readyPromise;
    const d = await this.database.getDocument(category.id);
    const md = new MutableDocument(d.getId(), d.getSequence(), d.getData());
    md.setString('name', category.name);
    md.setString('description', category.description);

    return this.database.teaCatgories.save(md);
  }

TeaCategoryEditorPage: Making Changes

Next, the TeaCategoryEditorPage can easily handle both adding new tea categories and making changes to existing tea categories:

  async ngOnInit() {
    const id = this.route.snapshot.paramMap.get('id');
    if (id) {
      this.title = 'Edit Tea Category';
      const category = await this.teaCategories.get(id);
      this.id = category.id;
      this.name = category.name;
      this.description = category.description;
    } else {
      this.title = 'Add New Tea Category';
    }
  }

  async save() {
    await this.teaCategories.save({
      id: this.id,
      name: this.name,
      description: this.description
    });

    this.navController.back();
  }

Responding to Changes

Our users can now add, update, and view tea categories, but the HomePage does not show the changes right away. Instead, it only shows the changes after the user closes the application and starts it up again.

Furthermore, if the application had a process that would get new tea categories from a cloud-based service then update the database accordingly, we would not see those changes either.

So, we need a way for the application to respond to changes in the database.

TeaCategoryService: Respond to Data Changes

The database allows us to add change listeners in order to respond to changes to tea category data. We will again use our TeaCategoryService to create an abstraction layer between the database and the rest of our code.

  onChange(cb: () => void) {
    this.readyPromise
      .then(() => this.database.addChangeListener(cb));
  }

HomePage: Detecting Database Changes

In the HomePage, we still need to fetch the tea categories on entry, but we will also fetch the tea categories each time that a change to the database is detected.

Move ngOnInit() logic to a private method

  private async fetchCategories(): Promise<void> {
    this.categories = await this.teaCategories.getAll();
  }

In ngOnInit(), call the method and then set it up to be called with each database change:

  ngOnInit() {
    this.fetchCategories();
    this.teaCategories.onChange(() => this.fetchCategories());
  }

Deleting a Document

The final CRUD operation is the deletion of documents.

TeaCategoryService: Delete

In order to delete a document, we first get the document using the ID and then tell the database to delete the document.

  async delete(id: string): Promise<void>{
    await this.readyPromise;
    const d = await this.database.getDocument(id);

    return this.database.deleteDocument(d);
  }

HomePage: Delete UI

The HomePage page’s responsibility here is to confirm that the user does intend to delete the category. If so, it hands the ID off to the service to do the actual work.

  async removeTeaCategory(id: string): Promise<void> {
    const alert = await this.alertController.create({
      header: 'Confirm Delete',
      message: 'Are you sure you want to permanently remove this category?',
      buttons: [
        { text: 'Yes', handler: () => this.teaCategories.delete(id) },
        { text: 'No', role: 'cancel' }
      ]
    });
    alert.present();
  }

After the user confirms the deletion, Android Oil is removed from the Tea Category list:

Conclusion

In this article, we explored the Ionic Native Offline Storage plugin’s complete offline experience by implementing the full set of CRUD operations available. We also explored best practices by architecting our application to separate data storage concerns into a separate service class. This shields the rest of our application from being concerned with details about how the data is stored and will allow us to easily expand our application in the future to use features such as synchronizing our offline data with a cloud-based data service. These are just some of the scenarios that can be supported in your application using the Offline Storage plugin.

If you are interested in exploring how Ionic Native can benefit your application development and aid you in delivering the best experience to your users, please contact one of our Solutions Architects (like me!) to schedule a demonstration.


Ken Sodemann