Expo SQLite + TypeORM

jgabriel1

José Gabriel M. Bezerra

Posted on December 17, 2020

Expo SQLite + TypeORM

Although Expo offers a SQLite local database connection API, it is not very practical to use, especially for larger projects with more complex database designs since all it has is an execute function where you can write raw queries to be ran.

With that in mind and having had some experience with TypeORM on the backend before, I wanted to see what it would be like to combine the two.

Luckily, TypeORM already offers an expo driver and it's setup looks very easy. Nevertheless, I still had some problems while trying it out.

This article's main goal is to be a walkthrough of the setup and things to keep in mind so that you don't go through the problems I did. Some of the tips are workarounds, some are optional and others are just necessary.


Before procceeding there are some caveats to be made:

  • this tutorial assumes that you already know the basics of React Native; 
  • it also assumes that you know how to create, run and publish an Expo project;
  • if the structure chosen for the project ever feels excessive, it's because I'm aiming to keep it organized and give it scalability;
  • if at any point you feel lost please refer to the repository. All files and topics named in this article, will be there in the form of commits;
  • lastly, this is all a result of my experience while I was first learning it, so if you find something that is just clearly wrong please message me or send it in the comments.

The Application

The application we'll be working on in this article is as simple as it can be. The main component will hold a state for a list of To-Dos. The items can be toggled on/off, deleted and there is an input to create a new item:

const TodoList: React.FC = () => {
  const [newTodo, setNewTodo] = useState('');
  const [todos, setTodos] = useState<TodoItem[]>([]);

  const handleCreateTodo = useCallback(async () => {
    setTodos(current => [...current, { text: newTodo, isToggled: false }]);

    setNewTodo('');
  }, [newTodo]);

  const handleToggleTodo = useCallback(async (index: number) => {
    setTodos(current =>
      current.map((todo, i) => {
        return index === i ? { ...todo, isToggled: !todo.isToggled } : todo;
      }),
    );
  }, []);

  const handleDeleteTodo = useCallback(async (index: number) => {
    setTodos(current => current.filter((_, i) => i !== index));
  }, []);

  return (
    <View style={styles.container}>
      <View style={styles.newTodoContainer}>
        <TextInput
          style={styles.newTodoInput}
          value={newTodo}
          onChangeText={setNewTodo}
        />
        <Button title="Create" onPress={handleCreateTodo} />
      </View>

      <View style={styles.todosContainer}>
        {todos.map((todo, index) => (
          <TouchableOpacity
            onPress={() => handleToggleTodo(index)}
            onLongPress={() => handleDeleteTodo(index)}
          >
            <Todo
              key={String(index)}
              text={todo.text}
              isToggled={todo.isToggled}
            />
          </TouchableOpacity>
        ))}
      </View>
    </View>
  );
};
Enter fullscreen mode Exit fullscreen mode

The version prior to the persistence layer implementation can be found in this branch of the article's repository.

With that being presented, the focus is to give this application a persistence layer that will save all To-Dos and their current state to a local in-device database.

P.S.: The Todo component is very trivial. All it does is present the text and add a line-through decoration in case it gets toggled.


Setting up TypeORM

The one important thing here is that, as of the making of this article, only versions prior to 0.2.28 of TypeORM can be used with Expo.

Basically there's an issue with a new feature introduced in version 0.2.29 which uses RegEx lookbehinds. This prevents you from being able to use the QueryBuilder object.

Since the Repository depends directly on it, that's basically the entire library functionality broken.

The engine that Expo uses to run JavaScript doesn't support these lookbehinds and that is what is causing the problem.

For more information, please refer to the main issue in the TypeORM repository.

EDIT: So as of February 8th, 2021 the 0.2.31 version of TypeORM is now available. This should fix the RegEx related problems when using the query builder / repository APIs.

If you want to use the latest version instead of 0.2.28, you can just remove the version specification "@0.2.28" from the following command.

Add typeorm and reflect-metadata

$ yarn add typeorm@0.2.28 reflect-metadata
Enter fullscreen mode Exit fullscreen mode

Change tsconfig.json

Add these extra configuration lines to tsconfig.

The first two are on TypeORM documentation and allow you to use decorators to declare the entities.

The last one is to declare the properties without needing to create a constructor method.

{
  /* ...rest of tsconfig */

  "emitDecoratorMetadata": true,
  "experimentalDecorators": true,
  "strictPropertyInitialization": false
}
Enter fullscreen mode Exit fullscreen mode

Add Babel plugin for entities type inference

As is, the inference that TypeORM uses to map the declared properties types to an SQLite data type does not work. Without it you'll need to declare the type explicitly as an option in the @Column() decorator.

To make that work, you'll need to install, as a development dependency, a Babel plugin...

$ yarn add babel-plugin-transform-typescript-metadata -D
Enter fullscreen mode Exit fullscreen mode

and add it to babel.config.js.

// babel.config.js

 return {     
   presets: ['babel-preset-expo'],    
+  plugins: [
+    '@babel/transform-react-jsx-source',
+    'babel-plugin-transform-typescript-metadata',
+  ]
 };
Enter fullscreen mode Exit fullscreen mode

Import reflect-metadata

Before the declaration of the main App component in App.js, import reflect-metadata. This is the entrypoint of the application.

If you're following with the repository, you'll have App.js, if not, you might be used to App.tsx created by Expo. I just changed the main App code to inside the src/ folder and made App.js just forward it to Expo.

// App.js

import 'reflect-metadata';
import App from './src';

export default App;
Enter fullscreen mode Exit fullscreen mode

Installing Expo SQLite

expo-sqlite package needs to be installed using the Expo CLI:

$ expo install expo-sqlite
Enter fullscreen mode Exit fullscreen mode

Setup the database access code

To hold all the database access logic, we'll create a src/data folder.

Declare the To-Do model

For the sake of simplicity, we'll be using the primary id as a numeric value that will be generated incremently.

@Entity('todos')
export class TodoModel {
  @PrimaryGeneratedColumn('increment')
  id: number;

  @Column()
  text: string;

  @Column()
  is_toggled: boolean;
}
Enter fullscreen mode Exit fullscreen mode

Create a repository to encapsulate access logic

The repository class will recieve a connection type from TypeORM as a parameter:

export class TodosRepository {
  private ormRepository: Repository<TodoModel>;

  constructor(connection: Connection) {
    this.ormRepository = connection.getRepository(TodoModel);
  }

  public async getAll(): Promise<TodoModel[]> {
    const todos = await this.ormRepository.find();

    return todos;
  }

  public async create({ text }: ICreateTodoData): Promise<TodoModel> {
    const todo = this.ormRepository.create({
      text,
      is_toggled: false,
    });

    await this.ormRepository.save(todo);

    return todo;
  }

  public async toggle(id: number): Promise<void> {
    await this.ormRepository.query(
      `
      UPDATE
        todos
      SET
        is_toggled = ((is_toggled | 1) - (is_toggled & 1))
      WHERE
        id = ?;
      `,
      [id],
    );
  }

  public async delete(id: number): Promise<void> {
    await this.ormRepository.delete(id);
  }
}
Enter fullscreen mode Exit fullscreen mode

Optional: Create migrations

If you don't intend to use migrations, TypeORM has a synchronization feature that will update all tables according to the models.

If you do want to use migrations, you can start by generating the migration class through the CLI:

$ yarn typeorm migration:create -n CreateTodosTable -d ./src/data/migrations/
Enter fullscreen mode Exit fullscreen mode

then, insert the table creation and drop queries on up and down methods:

export class CreateTodosTable1608217149351 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.createTable(
      new Table({
        name: 'todos',
        columns: [
          {
            name: 'id',
            type: 'integer',
            isPrimary: true,
            isGenerated: true,
            generationStrategy: 'increment',
          },
          {
            name: 'text',
            type: 'text',
          },
          {
            name: 'is_toggled',
            type: 'boolean',
          },
        ],
      }),
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.dropTable('todos');
  }
}
Enter fullscreen mode Exit fullscreen mode

Create a React context for the connection

Another detail of TypeORM that has to work differently while in a React project is that it is not possible to access the global connection object. This has something to do with the way the bundler works.

The workaround - and at the same time the beauty of it - is using a React context to pass down the connection to each component that is going to use it.

For this project, since we'll be going to use the Repository pattern to abstract any database logic, the context will provide all available repositories.

In src/data/connection.tsx we'll start by creating the context and declaring the interface for the data that it'll provide:

interface DatabaseConnectionContextData {
  todosRepository: TodosRepository;
}

const DatabaseConnectionContext = createContext<DatabaseConnectionContextData>(
  {} as DatabaseConnectionContextData,
);
Enter fullscreen mode Exit fullscreen mode

Then, we'll create the connection provider component, which will hold the connection logic for the entire App, and the useDatabaseConnection hook, which will be called inside components that want to subscribe to the context:

export const DatabaseConnectionProvider: React.FC = ({ children }) => {
  const [connection, setConnection] = useState<Connection | null>(null);

  const connect = useCallback(async () => {
    // TODO
  }, []);

  useEffect(() => {
    if (!connection) {
      connect();
    }
  }, [connect, connection]);

  if (!connection) {
    return <ActivityIndicator />;
  }

  return (
    <DatabaseConnectionContext.Provider
      value={{
        todosRepository: new TodosRepository(connection),
      }}
    >
      {children}
    </DatabaseConnectionContext.Provider>
  );
};

export function useDatabaseConnection() {
  const context = useContext(DatabaseConnectionContext);

  return context;
}
Enter fullscreen mode Exit fullscreen mode

What this component does is basically try to connect to the database as soon as the App starts. When that happens, it passes down the todosRepository as the context value to the children components (which is the entire application).

Let's take a closer look at the connect function, since it will hold the configurations object:

const connect = useCallback(async () => {
    const createdConnection = await createConnection({
      type: 'expo',
      database: 'todos.db',
      driver: require('expo-sqlite'),
      entities: [TodoModel],

      //If you're not using migrations, you can delete these lines,
      //since the default is no migrations:
      migrations: [CreateTodosTable1608217149351],
      migrationsRun: true,

      // If you're not using migrations also set this to true
      synchronize: false,
    });

    setConnection(createdConnection);
  }, []);
Enter fullscreen mode Exit fullscreen mode

Provide the connection context to the App

Since it's a context, it needs to be provided in order to be used inside the app.

In src/index.tsx:

 const App: React.FC = () => {
   return (
~    <DatabaseConnectionProvider>
       <StatusBar style="auto" />
       <SafeAreaView style={{ flex: 1 }}>
         <TodoList />
       </SafeAreaView>
~    </DatabaseConnectionProvider>
   );
 };
Enter fullscreen mode Exit fullscreen mode

Implement the Persistence layer

Now that we've setup the database connection, all that is left to do is to implement it to our application.

Call useDatabaseConnection in the component

To access todosRepository, we'll call the custom hook in the main application component and destructure it:

// src/components/TodoList.tsx

const { todosRepository } = useDatabaseConnection();
Enter fullscreen mode Exit fullscreen mode

Now the component is wired to the database and can make changes on it through the repository.

Load stored To-Dos

Through a useEffect hook, the To-Dos can be loaded and stored in the already existing state:

// src/components/TodoList.tsx

const [todos, setTodos] = useState<TodoItem[]>([]);

// ...

useEffect(() => {
  todosRepository.getAll().then(setTodos);
}, []);
Enter fullscreen mode Exit fullscreen mode

Change all state altering callbacks

Change all the existing callbacks to now persist the changes to the database:

  const handleCreateTodo = useCallback(async () => {
+   const todo = await todosRepository.create({ text: newTodo });

~   setTodos(current => [...current, todo]);

    setNewTodo('');
~ }, [newTodo, todosRepository]);

  const handleToggleTodo = useCallback(
~   async (id: number) => {
+     await todosRepository.toggle(id);

      setTodos(current =>
~       current.map(todo => {
~         return todo.id === id
~           ? { ...todo, is_toggled: !todo.is_toggled }
~           : todo;
        }),
      );
    },
~   [todosRepository],
  );

  const handleDeleteTodo = useCallback(
~   async (id: number) => {
+     await todosRepository.delete(id);

~     setTodos(current => current.filter(todo => todo.id !== id));
    },
~   [todosRepository],
  );
Enter fullscreen mode Exit fullscreen mode

Change the declared interface

Now that we have the To-Dos ids available, they can be used to identify each item in the state list instead of the index:

  <View style={styles.todosContainer}>
~  {todos.map(todo => (
      <TouchableOpacity
~       key={String(todo.id)}
~       onPress={() => handleToggleTodo(todo.id)}
~       onLongPress={() => handleDeleteTodo(todo.id)}
      >
        <Todo text={todo.text} isToggled={todo.is_toggled} />
      </TouchableOpacity>
    ))}
  </View>
Enter fullscreen mode Exit fullscreen mode

Now the changes should persist in the database when you turn the application off and back on!

Great! We're done now, right?
Not yet!

Even though the app has been working just fine until now on development mode, it will not once it's published with expo publish or built into a standalone App.


Minification configuration

TypeORM relies a lot on the class and prop names to work and that's not really a concern on the backend, since the code is not really bundled or minified in that context.

However, in React Native, the code is usually minified in order to reduce the file size of the final application. That minification proccess will cause TypeORM problems.

For example, in the migration class we created earlier, the name CreateTodosTable1608217149351 will usually be converted to a t or any other single letter variable name. That's usually how the minification happens.

That will cause TypeORM to indicate an invalid migration name, since it's not able to grab the timestamp from it anymore.

If you wanna emulate that still in development mode, you can run it with the --minify tag. Your application will not be able to start correctly since it will throw the above error.

$ expo start --minify
Enter fullscreen mode Exit fullscreen mode

To avoid that, we need to change the minification configurations in Metro Bundler. This post will give us a configuration file and how to enable it.

I'll admit I didn't look more in depth into it on how to do a finer tunning to change only what's neccessary. I think it's enough that we know what the problem is and that this is solving it.

Create a metro.config.js in the project root folder:

module.exports = {
  transformer: {
   minifierConfig: {
     keep_classnames: true, // FIX typeorm
     keep_fnames: true, // FIX typeorm
     mangle: {
       keep_classnames: true, // FIX typeorm
       keep_fnames: true, // FIX typeorm
     },
     output: {
       ascii_only: true,
       quote_style: 3,
       wrap_iife: true,
     },
     sourceMap: {
       includeSources: false,
     },
     toplevel: false,
     compress: {
       reduce_funcs: false,
     },
   },
 },
};
Enter fullscreen mode Exit fullscreen mode

In app.json add the following property inside "expo":

"packagerOpts": {
  "config": "metro.config.js"
}
Enter fullscreen mode Exit fullscreen mode

Now the application is ready to be published!

Final comments

Most of the problems addressed in this article stem form the fact that TypeORM's main focus is to be a library that will run on Node and we're trying to run it in a different platform (React Native).

Nevertheless, in my opinion it is still the best option would you ever want to use a local relational database in the device for a complex business implementation and happened to be using Expo.

If this article helped you in any way, please tell me in the comments. If you have any concerns you can comment as well, in the meantime please refer to the repository.

Thanks!

💖 💪 🙅 🚩
jgabriel1
José Gabriel M. Bezerra

Posted on December 17, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Expo SQLite + TypeORM
reactnative Expo SQLite + TypeORM

December 17, 2020