Building an SQLite Ionic App with Capacitor Last update: 2020-11-17

Building an SQLite Ionic App with Capacitor

If you need to store more data than a few keys, you should pick the SQLite Ionic integration that you can easily use with Capacitor to add powerful SQL functionalities to your app!

In this tutorial we will integrate the Capacitor community SQLite plugin and build a powerful app that first of all loads some seed data from a JSON dump, and then allows to work with that data right inside your app.

sqlite-ionic-app-capacitor

We will not build a 100% synchronisation functionality but this could be the start of your next Ionic SQLite app with remote database sync for sure!

Setting up the SQLite Ionic App

As always we start with a blank app and then install the Capacitor plugin to access the device SQLite database. We also need an additional page and service for the tutorial and finally you should add the native platform that you plan to use, since you need to test the functionality on a real device:

ionic start devdacticSql blank --type=angular --capacitor
cd ./devdacticSql

# Install the Capacitor plugin
npm install @capacitor-community/sqlite

# Add some app logic
ionic g service services/database
ionic g page pages/details

# Add the native platforms
ionic build
npx cap add ios
npx cap add android

At the time writing the plugin did not have a web implementation, but hopefully this might change in the future!

To use the plugin, you also need to add it to the main activity of your Android project inside the android/app/src/main/java/io/ionic/starter/MainActivity.java:

package io.ionic.starter;
import com.getcapacitor.community.database.sqlite.CapacitorSQLite;

import android.os.Bundle;

import com.getcapacitor.BridgeActivity;
import com.getcapacitor.Plugin;

import java.util.ArrayList;

public class MainActivity extends BridgeActivity {
  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    // Initializes the Bridge
    this.init(savedInstanceState, new ArrayList<Class<? extends Plugin>>() {{
      // Additional plugins you've installed go here
      add(CapacitorSQLite.class);
    }});
  }
}

Since we want to download some SQL data for our app on start, we also need to make an HTTP request and therefore import the module as usually inside our app/app.module.ts:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';

import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';

import { HttpClientModule } from '@angular/common/http';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule,
    HttpClientModule],
  providers: [
    StatusBar,
    SplashScreen,
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

The last setup part is changing the routing, so we can have a list of data from our local SQLite database displayed and a details page to show some more information. Go ahead and change the routing inside the app/app-routing.module.ts to this:

import { NgModule } from '@angular/core';
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';

const routes: Routes = [
  {
    path: '',
    loadChildren: () => import('./home/home.module').then( m => m.HomePageModule)
  },
  {
    path: 'product/:id',
    loadChildren: () => import('./pages/details/details.module').then( m => m.DetailsPageModule)
  },
];

@NgModule({
  imports: [
    RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
  ],
  exports: [RouterModule]
})
export class AppRoutingModule { }

Now we are able to navigate to a details page with an ID, so we will be able to easily retrieve the data with a query later.

Preparing a Database Export as JSON

The cool feature of the plugin is that it allows you to import a JSON dump of your data. There are also Cordova plugins that allow a SQL import, but since your database is usually behind an API anyway you could create this on the server and host the file for seeding your apps database somewhere.

The file specifies some general information like the name, and then the tables which can also directly hold some values. In our case we define two tables called vendors and products, where each product has the vendorid as foreign key.

When you want to sync your tables later you also need to include a last_modified column, but more on the whole import and sync inside the plugin docs.

I’ve hosted the following file for testing here: https://devdactic.fra1.digitaloceanspaces.com/tutorial/db.json

Feel free to otherwise simply create the file in your app and load it directly from the assets folder instead:

{
  "database": "product-db",
  "version": 1,
  "encrypted": false,
  "mode": "full",
  "tables": [
    {
      "name": "vendors",
      "schema": [
        { "column": "id", "value": "INTEGER PRIMARY KEY NOT NULL" },
        { "column": "company_name", "value": "TEXT NOT NULL" },
        { "column": "company_info", "value": "TEXT NOT NULL" },
        { "column": "last_modified", "value": "INTEGER DEFAULT (strftime('%s', 'now'))" }
      ],
      "values": [
        [1, "Devdactic", "The main blog of Simon Grimm", 1587310030],
        [2, "Ionic Academy", "The online school to learn Ionic", 1590388125],
        [3, "Ionic Company", "Your favourite cross platform framework", 1590383895]
      ]
    },
    {
      "name": "products",
      "schema": [
        { "column": "id", "value": "INTEGER PRIMARY KEY NOT NULL" },
        { "column": "name", "value": "TEXT NOT NULL" },
        { "column": "currency", "value": "TEXT" },
        { "column": "value", "value": "INTEGER" },
        { "column": "vendorid", "value": "INTEGER" },
        { "column": "last_modified", "value": "INTEGER DEFAULT (strftime('%s', 'now'))" },
        {
          "foreignkey": "vendorid",
          "value": "REFERENCES vendors(id)"
        }
      ],
      "values": [
        [1, "Devdactic Fan Hat", "EUR", 9, 1, 1604396241],
        [2, "Ionic Academy Membership", "USD", 25, 2, 1604296241],
        [3, "Ionic Sticker Swag", "USD", 4, 3, 1594196241],
        [4, "Practical Ionic Book", "USD", 79, 1, 1603396241]
      ]
    }
  ]
}

Now our app can pull in that data and seed the initial SQLite database!

Building a Database Service

Before we dive into the pages, let’s create the logic for the app upfront.

Before you can actually use the database, you should check whether you are running on Android, since you need to request permissions in that case!

I’ve also added a bit of logic to store the name of the database (retrieved from the previous JSON file) inside Capacitor Storage, and also keep track whether we have already synced the initial data.

This is just a very rough idea in here, it really depends on your case: Do you want to get the latest data on every app start? Do you just need the remote data once to seed the app? Think about what you need inside your app and then build out the logic for your needs.

So within the setupDatabase we either start the download and import, or if we already did it before we can directly open the database (based on the name we stored).

Additionally I added a BehaviorSubject so we can subscribe to the database stead and not perform any queries before it’s ready.

Now go ahead and change the app/services/database.service.ts to:

import { Injectable } from '@angular/core';
import { Plugins } from '@capacitor/core';
import '@capacitor-community/sqlite';
import { AlertController } from '@ionic/angular';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, from, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';

import { JsonSQLite } from '@capacitor-community/sqlite';
const { CapacitorSQLite, Device, Storage } = Plugins;

const DB_SETUP_KEY = 'first_db_setup';
const DB_NAME_KEY = 'db_name';

@Injectable({
  providedIn: 'root'
})
export class DatabaseService {
  dbReady = new BehaviorSubject(false);
  dbName = '';

  constructor(private http: HttpClient, private alertCtrl: AlertController) { }

  async init(): Promise<void> {
    const info = await Device.getInfo();

    if (info.platform === 'android') {
      try {
        const sqlite = CapacitorSQLite as any;
        await sqlite.requestPermissions();
        this.setupDatabase();
      } catch (e) {
        const alert = await this.alertCtrl.create({
          header: 'No DB access',
          message: 'This app can't work without Database access.',
          buttons: ['OK']
        });
        await alert.present();
      }
    } else {
      this.setupDatabase();
    }
  }

  private async setupDatabase() {
    const dbSetupDone = await Storage.get({ key: DB_SETUP_KEY });

    if (!dbSetupDone.value) {
      this.downloadDatabase();
    } else {
      this.dbName = (await Storage.get({ key: DB_NAME_KEY })).value;
      await CapacitorSQLite.open({ database: this.dbName });
      this.dbReady.next(true);
    }
  }

  // Potentially build this out to an update logic:
  // Sync your data on every app start and update the device DB
  private downloadDatabase(update = false) {
    this.http.get('https://devdactic.fra1.digitaloceanspaces.com/tutorial/db.json').subscribe(async (jsonExport: JsonSQLite) => {
      const jsonstring = JSON.stringify(jsonExport);
      const isValid = await CapacitorSQLite.isJsonValid({ jsonstring });

      if (isValid.result) {
        this.dbName = jsonExport.database;
        await Storage.set({ key: DB_NAME_KEY, value: this.dbName });
        await CapacitorSQLite.importFromJson({ jsonstring });
        await Storage.set({ key: DB_SETUP_KEY, value: '1' });

        // Your potential logic to detect offline changes later
        if (!update) {
          await CapacitorSQLite.createSyncTable();
        } else {
          await CapacitorSQLite.setSyncDate({ syncdate: '' + new Date().getTime() })
        }
        this.dbReady.next(true);
      }
    });
  }
}

Note: As a reader pointed out, it might be required to call this to open the database additionally:

await CapacitorSQLite.createConnection({ database: DB_NAME })

Inside the downloadDatabase we download the JSON file from the beginning, make sure the JSON is valid for the import and then import the whole data into our SQLite database (or better, the database will actually be created with that name).

We also store the database name and make sure we don’t run through this again, and if you plan to sync new data from your app back to a remote server, you can now create a sync table with the plugin:

The table will have a timestamp of the current sync, and you can later create a partial export with all the changes that happened since the last sync. Once you then sync the data again, simply call setSyncDate and you can work offline again.

Now we also need some real functionality inside our app to query the data. This is just a simple example of a few queries to read out the data, get a specific product and populate the vendor fields (using left join) or add a dummy product.

The call to getProductList is wrapped inside the our ready state, since otherwise the first page of the app would call this function before the database is ready, which is exactly what we want to prevent. Only when the ready state changes the function would return the value from the query statemen!

Now continue with the app/services/database.service.ts and add:

ngetProductList() {
  return this.dbReady.pipe(
    switchMap(isReady => {
      if (!isReady) {
        return of({ values: [] });
      } else {
        const statement = 'SELECT * FROM products;';
        return from(CapacitorSQLite.query({ statement, values: [] }));
      }
    })
  )
}

async getProductById(id) {
  const statement = `SELECT * FROM products LEFT JOIN vendors ON vendors.id=products.vendorid WHERE products.id=${id} ;`;
  return (await CapacitorSQLite.query({ statement, values: [] })).values[0];
}

getDatabaseExport(mode) {
  return CapacitorSQLite.exportToJson({ jsonexportmode: mode });
}

addDummyProduct(name) {
  const randomValue = Math.floor(Math.random() * 100) + 1;
  const randomVendor = Math.floor(Math.random() * 3) + 1
  const statement = `INSERT INTO products (name, currency, value, vendorid) VALUES ('${name}','EUR', ${randomValue}, ${randomVendor});`;
  return CapacitorSQLite.execute({ statements: statement });
}

deleteProduct(productId) {
  const statement = `DELETE FROM products WHERE id = ${productId};`;
  return CapacitorSQLite.execute({ statements: statement });
}

// For testing only..
async deleteDatabase() {
  const dbName = await Storage.get({ key: DB_NAME_KEY });
  await Storage.set({ key: DB_SETUP_KEY, value: null });
  return CapacitorSQLite.deleteDatabase({ database: dbName.value });
}

For testing we also add a delete function, and the getDatabaseExport helps to create a JSON export of the database. The mode in this case is either partial or full, which means only the items since the last sync or a full export of all data insite the SQLite database.

Next step is to make sure the init is called right in the beginning of our app, so we can change the app/app.component.ts to show a loading while we import the data and the database is not yet ready:

import { Component } from '@angular/core';
import { LoadingController, Platform } from '@ionic/angular';
import { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';
import { DatabaseService } from './services/database.service';

@Component({
  selector: 'app-root',
  templateUrl: 'app.component.html',
  styleUrls: ['app.component.scss']
})
export class AppComponent {
  constructor(
    private platform: Platform,
    private splashScreen: SplashScreen,
    private statusBar: StatusBar,
    private databaseService: DatabaseService,
    private loadingCtrl: LoadingController
  ) {
    this.initializeApp();
  }

  async initializeApp() {
    this.platform.ready().then(async () => {
      const loading = await this.loadingCtrl.create();
      await loading.present();
      this.databaseService.init();
      this.databaseService.dbReady.subscribe(isReady => {
        if (isReady) {
          loading.dismiss();
          this.statusBar.styleDefault();
          this.splashScreen.hide();
        }
      });
    });
  }
}

I’ve noticed that the first page loads anyway already, so you could even add a guard to your page to prevent access until the database is ready, which would be the best solution I guess.

Otherwise you always need to be careful that your app is not starting any query while the database is still locked or not opened yet.

Building the SQLite Ionic APP Logic

We’ve prepared everything for our app so we can now start the logic to query the database with our previously defined functions.

Our first page should load all the products, and offer some functionality so we can create an export, add a dummy product or delete the database.

Most of this is just calling our service, so simply go ahead and change the src/home/home.page.ts to:

import { Component } from '@angular/core';
import { DatabaseService } from '../services/database.service';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage {
  products = [];
  export = null;
  newProduct = 'My cool product';

  constructor(private databaseService: DatabaseService) {
    this.loadProducts();
  }

  loadProducts() {
    this.databaseService.getProductList().subscribe(res => {
      this.products = res.values;
    });
  }

  // Mode is either "partial" or "full"
  async createExport(mode) {
    const dataExport = await this.databaseService.getDatabaseExport(mode);
    this.export = dataExport.export;
  }

  async addProduct() {
    await this.databaseService.addDummyProduct(this.newProduct);
    this.newProduct = '';
    this.loadProducts();
  }

  async deleteProduct(product) {
    await this.databaseService.deleteProduct(product.id);
    this.products = this.products.filter(p => p != product);
  }

  // For testing..
  deleteDatabase() {
    this.databaseService.deleteDatabase();
  }
}

Now we can iterate the data and use sliding buttons to easily add the delete functionality right away. Additionally we add the correct routerLink for each item, so we open the details page with the ID of that product.

Continue by changing the src/home/home.page.html to:

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>
      My SQL App
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>

  <ion-item>
    <ion-input [(ngModel)]="newProduct"></ion-input>
  </ion-item>
  <ion-button expand="full" (click)="addProduct()">Add dummy product</ion-button>

  <ion-list>
    <ion-list>
      <ion-item-sliding *ngFor="let p of products">
        <ion-item [routerLink]="['product', p.id]">
          <ion-label>
            {{ p.name }}
            <p>{{ p.value | currency:p.currency }}</p>
          </ion-label>
        </ion-item>
        <ion-item-options side="end">
          <ion-item-option (click)="deleteProduct(p)" color="danger">Delete</ion-item-option>
        </ion-item-options>
      </ion-item-sliding>
    </ion-list>

  </ion-list>

  <ion-button expand="full" (click)="createExport('full')">Create Full JSON Export</ion-button>
  <ion-button expand="full" (click)="createExport('partial')">Create Partial JSON Export</ion-button>
  <ion-button expand="full" (click)="deleteDatabase()" color="danger">Delete Database</ion-button>

  <ion-card *ngIf="export">
    <ion-card-content>
      {{ export | json}}
    </ion-card-content>
  </ion-card>

</ion-content>

Now you should already see your app in action, and for debugging purpose I recommend to run the Capacitor live reload on a device like:

ionic cap run ios --livereload --external

Creating a Simple Details Page

Since we can already enter the details page, it’s now time to load the ID from the navigation and use it to query our database again. This is just basic Angular, so simply change the pages/details/details.page.ts to:

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { DatabaseService } from '../../services/database.service';

@Component({
  selector: 'app-details',
  templateUrl: './details.page.html',
  styleUrls: ['./details.page.scss'],
})
export class DetailsPage implements OnInit {
  product = null;

  constructor(private route: ActivatedRoute, private databaseService: DatabaseService) { }

  async ngOnInit() {
    const id = this.route.snapshot.paramMap.get('id');
    this.product = await this.databaseService.getProductById(id);
  }

}

You can log the product and see that after the left join all the vendor information is present as well (SQL magic, kinda) so we just need a simple view to display all of that information to confirm our SQLite Ionic app works as expected.

Therefore apply the last change to the pages/details/details.page.html now:

<ion-header>
  <ion-toolbar color="primary">
    <ion-buttons slot="start">
      <ion-back-button defaultHref="/"></ion-back-button>
    </ion-buttons>
    <ion-title>Product</ion-title>
  </ion-toolbar>
</ion-header>

<ion-content>
  <ion-card *ngIf="product">
    <ion-card-header>
      <ion-card-title>{{ product.name }}</ion-card-title>
    </ion-card-header>
    <ion-card-content>
      <h2>{{ product.company_name}}</h2>
      {{ product.company_info }}
    </ion-card-content>
  </ion-card>
</ion-content>

Now you can add some dummy products and see how the database changes - but wouldn’t you like to see the real data that is stored on the device as well?

Debugging

Debugging the SQLite database of your app is a bit more complicated, but it’s a great way to confirm all the data is in place or spot any problems with your implementation.

Simply follow the SQLite debugging section of my tutorial here and you should be able to access your database for both iOS and Android quite quickly.

Note: The Capacitor SQLite plugin will add a specific name (SQLite.db) to your database file, so if your database name is “product-db” (like in my JSON), the file on your device would be called “product-dbSQLite.db”.

Keep that in mind especially for the Android DB which you directly extract from the device using the ADB shell.

Conclusion

Working with SQLite inside your Ionic app is a great way to use existing data you might already have to populate the apps database and with the sync capabilities, it’s even possible to completely work offline and then transfer the changes back to the server at a later point of time!

You can also find a video version of this tutorial with even more information about the whole process below.