A React Native SQLite Database Upgrade Strategy with Redux Saga

Who needs promises!

Previously I wrote about a possible approach to upgrading your SQLite database in React Native. It was fun sharing my approach to it, but recently I have moved on to using Redux Saga, and while my previous post did not make use of Redux at all, I thought it would be great to share how I use this upgrade logic with Redux Saga, for those of you who like to get fancy with your Redux integrations.

I’ve enjoyed working with Redux Saga so much that if I can help it, I will leave promises behind in Redux!

Prerequesites

If you have not read my previous post, I suggest that you do, as it will give you a good overview of the upgrade logic I will apply here.

You should also know how to use Redux, else you might get really confused!

I will not go into the same details on this post as I did on the previous one when it comes to the upgrade logic. Here I am focusing on using what we did before with Redux Saga, and jumping right into implementation. I will make these assumptions before starting:

  1. You have some basic Redux Saga experience
  2. You have installed Redux Saga in your application (i.e. yarn add redux-saga)
  3. You have already configured Redux and Redux Saga to run (i.e. sagaMiddleware.run(sagas)) in your app

Let’s get started!

Overall Database Saga Design

Our first step is to understand how the Redux Saga flow is intended to work:

  1. App requests that the database is opened via a Redux action creator
  2. Database open saga runs
  3. Database is upgraded if needed via an upgrade saga
  4. Database instance is saved in the Redux store
  5. Any database related errors are saved in the Redux store
  6. Database instances is closed, and instance is removed from the store when the app requests for the database to close (typically when the app itself is closed)

Folder Structure

We will create this structure

  • app/constants/actionTypes.js : Will store our action type constants
  • app/actions/databaseActions.js : Will hold our Redux action creators
  • app/reducers/database.js: Will hold our database Redux reducer
  • app/sagas/database.js : Will hold our database sagas
  • app/db/db-upgrade.json : Our upgrade config file, which we created in the previous blog post
  • app/selectors/index.js : Will hold our database reducer selection utlity

Note: The structure above only covers the items related to the database upgrade work I am focusing on in this post. You should also don’t forget to have configured all other Redux and Redux Saga related files to the app, which is the typical setup I assume you already know 🙂

Groundwork: Add action types

Create an /constants/actionTypes.js file to store your Redux action types. Let’s add these five:

export const DB_OPEN = 'database/OPEN';
export const DB_CLOSE = 'database/CLOSE';
export const DB_ERROR = 'database/ERROR';
export const DB_SET_INSTANCE = 'database/SET_INSTANCE';
export const DB_CLEAR_INSTANCE = 'database/CLEAR_INSTANCE';

Groundwork: Add Reducers

We will have a simple database reducer which will simply be in charge of storing the database instance, any errors we may get, and a flag to tell us if the database is ready. Place this in app/reducers/database.js

import { DB_CLEAR_INSTANCE, DB_ERROR, DB_SET_INSTANCE } from '../constants/actionTypes';

export function database(state = { database: null, error: null, isReady: false }, action) {
  let { type, payload } = action;

  switch (type) {
    case DB_SET_INSTANCE:
      return { ...state, database: payload, isReady: true };
    case DB_CLEAR_INSTANCE:
      return { ...state, database: null, isReady: false };
    case DB_ERROR:
      return { ...state, error: payload };
  }
  return state;
}

Groundwork: Add database selector

Create an index.js file at app/selectors and add the code below:

export const getDatabaseState = state => state.database;

With the help of Redux Saga’s select effect. We will be able to grab our database reducer and reference its stored fields. This will be useful in checking the database state and will let us use the database instance wherever we may need it in our sagas.

Groundwork: Add the runSqlQuery function

This function will be in charge of making a single query to our react-native-sqlite-storage instance. This is not a saga, but will be used by our sagas to retreive data. Since we want to use the power of Redux Saga’s effects to pause saga execution, we should return the promise result here. Pretty straight-forward logic, and I am able to use Redux Saga effects since this function will return a promise.

This function should be stored at app/sagas/database.js since it will be used by our sagas only.

export function runSqlQuery(db, query, params = []) {
  return db
    .executeSql(query, params)
    .then(results => ({
      success: true,
      error: false,
      results: results[0]
    }))
    .catch(error => ({
      success: false,
      error
    }));
}

Groundwork: Add the runSQLBatch function

react-native-sqlite-storage‘s sqlBatch method enables me to run a batch of SQLite statements at once. Same setup as the runSqlQuery function except I can provide arrays of statements.

You may also store at app/sagas/database.

export function runSqlBatch(db, statements) {
  return db
    .sqlBatch(statements)
    .then(() => ({
      success: true,
      error: false
    }))
    .catch(error => ({
      success: false,
      error
    }));
}

Creating the sagas

Let’s work in our sagas (app/sagas/database.js)

Adding necessary imports

Here are the imports you will need:

import SQLite from 'react-native-sqlite-storage';
import { put, call, fork, select, takeEvery } from 'redux-saga/effects';
import { DB_CLOSE, DB_OPEN } from '../../constants/actionTypes';
import { clearDatabaseInstance, setDatabaseError, setDatabaseInstance } from '../../actions/database';
import { getDatabaseState } from '../../selectors/index';
import dbUpgrade from '../../data/db-upgrade.json';

Add watchers to database.js

We will watch for 2 action types, one for opening the database and one for closing:

export function* watchDatabaseOpenRequest() {
  yield takeEvery(DB_OPEN, open);
}

export function* watchDatabaseCloseRequest() {
  yield takeEvery(DB_CLOSE, close);
}

export default function* root() {
  yield fork(watchDatabaseOpenRequest);
  yield fork(watchDatabaseCloseRequest);
}

Add the open saga

Below is the open function, similar to the original open function in my previous post, except it is now converted to a saga, and I have separated the get version query as well as the query methods themselves. They will all now be separate sagas called using Redux Saga’s call effect. Later I will add these sagas (getVersion and dbUpgrade)

Notice the select effect, here we use the selector we created at app/selectors/index.js. We get the database instance reference and see if it has been defined. If not defined we know that the database is closed and we should open it. Once we make the necessary upgrades, if any, we set the instance of the opened database by using Redux Saga’s put effect to call the setDatabaseInstance action creator. Our reducer will be triggered here and the instance will be saved.

export function* open() {
  try {

    const { database } = yield select(getDatabaseState);

    if (!database) {

      const db = yield call(SQLite.openDatabase, {
        name: 'my-existing-data.db',
        createFromLocation: '~data/my-existing-data.db'
      });

      const version = yield call(getVersion, db);

      if (version < dbUpgrade.version) {
        yield call(upgradeFrom, db, version);
      }

      yield put(setDatabaseInstance(db));

    } else {
      console.warn('Database already open, ignoring open request.');
    }
  } catch (error) {
    yield put(setDatabaseError(error));
  }
}

Add the getVersion saga

This is the version logic from my previous post now as its own saga. You will notice the runSqlQuery function is used here via the call effect. Since we use the call effect, the function will not continue running until we get a result from runSqlQuery. Any errors will be set to our Redux store, in the database reducer, by using the put effect. The put effect allows us to call action creators.

export function* getVersion(db) {
  try {
    const { success, error, results } = yield call(runSqlQuery, db, `SELECT max(version) FROM ${DATABASE_VERSION}`);

    if (success) {
      return results.rows.item(0)['max(version)'];
    } else {
      yield put(setDatabaseError(error));
    }

  } catch (error) {
    yield put(setDatabaseError(error));
  }
}

Add the upgrade saga

Here is the same upgrade logic as my previous post. I run through the entire logic on that post. Once the correct upgrade scripts are loaded, we call the runSqlBatch saga to run all our scripts, and as we did before, any database errors we encounter we simply put to our Redux store.

export function* upgradeFrom(db, previousVersion) {
  try {
    let statements = [];
    let version = dbUpgrade.version - (dbUpgrade.version - previousVersion) + 1;
    let length = Object.keys(dbUpgrade.upgrades).length;

    for (let i = 0; i < length; i += 1) {
      let upgrade = dbUpgrade.upgrades[`to_v${version}`];

      if (upgrade) {
        statements = [...statements, ...upgrade];
      } else {
        break;
      }

      version++;
    }

    statements = [...statements, ...[['REPLACE into version (version) VALUES (?);', [dbUpgrade.version]]]];

    if (__DEV__) {
      console.warn(
        `Database Upgrade Needed. Will upgrade from version ${previousVersion} to ${
          dbUpgrade.version
        } with statements:`,
        statements
      );
    }

    const { error } = yield call(runSqlBatch, db, statements);

    if (error) {
      yield put(setDatabaseError(error));
    }
  } catch (error) {
    yield put(setDatabaseError(error));
  }
}

Add the close saga

The close saga will first select the database reducer and check if there is an instance saved into it. If so, it will call its close method, once that is done we clear the database instance from our Redux store by putting the clearDatabaseInstance action creator. And again, errors are put to the reducer as well with setDatabaseError action creator.

export function* close() {
  const { database } = yield select(getDatabaseState);

  if (database) {
    try {
      yield call(database.close);
      yield put(clearDatabaseInstance());
    } catch (error) {
      yield put(setDatabaseError(error));
    }
  }
}

I hope this quick run-through of this setup in Redux Saga has helped you get an idea of how you can use Redux Saga to create more scaleable and complex interactions as well as an easy to set up upgrade logic for your SQLite database. Till next time, keep coding!

Spread the love
embpdaniel

embpdaniel

I am a full-stack React, React Native, Wordpress, Node.js, and Firebase developer with over 15 years experience developing applications for the web and mobile. I am a top-rated freelancer on Upwork, and have worked independently for over 8 years.

Here's some additional posts you might like

thoughts on "A React Native SQLite Database Upgrade Strategy with Redux Saga"

  1. Mehrdad says:

    You don’t even mention the transaction object
    does this even work?!!

  2. embpdaniel says:

    Good point @Mehrdad, from the react-native-sqlite-storage source code, it looks like transactions promises using this signature:

    `transaction(scope: (tx: Transaction) => void): Promise;`

    Since it returns a promise, it SHOULD work just fine by doing something like:

    “`
    const result = yield call([db, db.transaction], (tx) => ….)
    “`

Leave a Reply

Your email address will not be published. Required fields are marked *