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!
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:
yarn add redux-saga
)sagaMiddleware.run(sagas)
) in your appLet's get started!
Our first step is to understand how the Redux Saga flow is intended to work:
We will create this structure
app/constants/actionTypes.js
: Will store our action type constantsapp/actions/databaseActions.js
: Will hold our Redux action creatorsapp/reducers/database.js
: Will hold our database Redux reducerapp/sagas/database.js
: Will hold our database sagasapp/db/db-upgrade.json
: Our upgrade config file, which we created in the previous blog postapp/selectors/index.js
: Will hold our database reducer selection utlityNote: 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 :)
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";
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;
}
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.
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,
}));
}
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,
}));
}
Let's work in our sagas (app/sagas/database.js
)
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";
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);
}
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));
}
}
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));
}
}
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));
}
}
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!