Integrating Slonik with Express.js

Gajus Kuizinas - Sep 29 '23 - - Dev Community

What is Slonik?

For those uninitiated, Slonik is a battle-tested SQL query building and execution library for Node.js. Its primary goal is to allow you to write and compose SQL queries in a safe and convenient way. Now, let's see how it pairs with Express.js.

Install the Necessary Packages

First things first, you'll need to install the necessary packages:

npm install slonik express
Enter fullscreen mode Exit fullscreen mode

Basic App Setup

Let's create a basic Express app:

import express from 'express';

const app = express()
const port = 3000

app.get('/', (req, res) => {
  res.send('Hello, World!')
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})
Enter fullscreen mode Exit fullscreen mode

Running this app will start a server on port 3000. If you visit http://localhost:3000, you should see the message "Hello, World!".

Connecting to a Database

Slonik manages database connections using connection pools. A connection pool manages a set of database connections, which can be used and reused, minimizing the overhead of creating new connections.

Here is an example of how to create a Slonik connection pool:

import { createPool } from 'slonik';

const main = async () => {
  await createPool('postgres://...');
};

main();
Enter fullscreen mode Exit fullscreen mode

Note: Your connection URI goes in place of postgres://....

You may have noticed that we are awaiting for the result of createPool. This is because Slonik needs to query the database in order to setup the connection pool. This requires us to slightly modify our app.

Using Slonik with Express.js

Let's see how we can use Slonik with Express.js:

import express from 'express';
import { createPool } from 'slonik';

const main = async () => {
  const pool = await createPool('postgres://...');

  const app = express();
  const port = 3000;

  app.get('/', (req, res) => {
    res.send('Hello, World!')
  });

  app.listen(port, () => {
    console.log(`Example app listening on port ${port}`)
  });
};

void main();
Enter fullscreen mode Exit fullscreen mode

We've added a few lines to our app. First, we created a connection pool using the createPool function. Then, we added a main function that creates the connection pool and starts the Express.js server. The only reason we need to do this is because we need to await the createPool function. Since we can't use await at the top level, we need to wrap it in a function.

Integrating Slonik with an Express.js Route

With the pool ready, it's straightforward to execute queries within your Express routes:

app.get('/users', async (req, res) => {
  const users = await pool.any(sql.unsafe`SELECT * FROM users`);

  res.json(users);
});
Enter fullscreen mode Exit fullscreen mode

Here, we're fetching all users from the database and sending them as a JSON response.

Because we are using a connection pool to manage connections, we don't need to worry about opening and closing connections. Slonik handles this for us. For most use cases, this is the recommended way to execute queries. However, if you need more control over the connection, you can use the transaction or connect methods.

Transactions

Slonik provides a transaction method that allows you to execute queries within the same connection and a single transaction:

await pool.connect(async (connection) => {
  await connection.query(sql.unsafe`INSERT INTO logs (message) VALUES ('foo')`);
  await connection.query(sql.unsafe`INSERT INTO logs (message) VALUES ('bar')`);
});
Enter fullscreen mode Exit fullscreen mode

With this, both insertions either succeed together or fail together.

Using Transactions with Express.js

Let's see how we can use transactions with Express.js. First, we'll create a route that inserts a user into the database:

app.post('/users', async (req, res) => {
  const { name } = req.body;

  await pool.query(sql.unsafe`INSERT INTO users (name) VALUES (${name})`);

  res.json({ message: 'User created!' });
});
Enter fullscreen mode Exit fullscreen mode

If it is just a single query, we don't need to use a transaction. However, let's say we want to log the creation of the user in the database:

app.post('/users', async (req, res) => {
  const { name } = req.body;

  await pool.transaction(async (transaction) => {
    await transaction.query(sql.unsafe`INSERT INTO users (name) VALUES (${name})`);
    await transaction.query(sql.unsafe`INSERT INTO logs (message) VALUES (${name} was created!)`);
  });

  res.json({ message: 'User created!' });
});
Enter fullscreen mode Exit fullscreen mode

Here, we are using the transaction method to execute two queries within the same connection and a single transaction. This ensures that both queries either succeed together or fail together. If either query fails, the transaction is rolled back and the error is thrown.

Optimizing Transactions

This section is tangential to the main topic of this article, but I think it's important to mention.

One key principle when working with transactions in relational databases is to keep them as concise as possible. Transactions that extend for long durations can lead to a host of issues.

Extended transaction durations can block other operations, increasing contention for resources. This can, in turn, escalate to more severe problems such as deadlocks. In a deadlock scenario, two or more transactions wait indefinitely for each other to release locks.

Such problems can significantly degrade the performance of the system. Especially in situations with high concurrency, bottlenecks can emerge, stifling the smooth operation of the database.

Moreover, there's the risk of long-running transactions exhausting connection pool resources. When this happens, it can result in application slowdowns, as various parts of the application are queued, waiting for available connections.

In light of these potential pitfalls, it becomes paramount to ensure that transactions are designed to encapsulate only the absolutely necessary operations. They should be optimized for both speed and efficiency. By adhering to these principles, you not only uphold the integrity of the transaction but also ensure the responsiveness and scalability of the broader application and database system.

In short, keep your transactions short and sweet.

Reserving a Connection

Warning: Reserving a connection from the pool is an advanced feature. It is recommended to use transactions instead.

connect method allows you to reserve a connection from the pool and execute queries on it.

It is important to note that this is a rare use case, esp. in the context of services that produce responses to user requests. Most of the time, you should use connection from the connection pool or transactions instead. However, there are some cases where you may need to reserve a connection from the pool. For example, you may want to set time zone for the connection:

app.get('/events', async (req, res) => {
  const events = await pool.connect(async (connection) => {
    await connection.query(sql.unsafe`SET LOCAL timezone = 'America/New_York'`);

    return await connection.any(sql.unsafe`SELECT event_name, event_time FROM events`);
  });

  res.json(events);
});
Enter fullscreen mode Exit fullscreen mode

Here, we are reserving a connection from the pool and executing two queries on it. The first query sets the time zone for the connection, and the second query fetches all events from the database. Once the connection is released, it is returned to the pool.

Passing a Connection to a Function

A common a question I am asked is how to pass a connection to a function. For example, let's say you have a function that fetches all users from the database:

const getUsers = async () => {
  return await connection.any(sql.unsafe`SELECT * FROM users`);
};

app.get('/users', async (req, res) => {
  const users = await getUsers();

  res.json(users);
});
Enter fullscreen mode Exit fullscreen mode

The simple solution is to pass the connection as an argument:

const getUsers = async (pool: DatabasePool) => {
  return await pool.any(sql.unsafe`SELECT * FROM users`);
};

app.get('/users', async (req, res) => {
  const users = await getUsers(pool);

  res.json(users);
});
Enter fullscreen mode Exit fullscreen mode

I realize that this is a very simple example, but this is true also even if your function that requires access to the database handle is nested deep within a call stack. You must an instance of the connection pool down to every function in the chain.

Anti-Patterns: AsyncLocalStorage

AsyncLocalStorage is a core module introduced in Node.js that provides a mechanism to store and retrieve data based on the current execution context. It might be tempting to use this to pass a connection to a function:

import express from 'express';
import { createPool } from 'slonik';
import { AsyncLocalStorage } from 'async_hooks';

const asyncLocalStorage = new AsyncLocalStorage();

const main = async () => {
  const pool = createPool('postgres://...');

  const app = express();
  const port = 3000;

  app.use((req, res, next) => {
    pool.connect((err, connection) => {
      if (err) {
        return next(err);
      }
      asyncLocalStorage.run(connection, next);
    });
  });

  app.get('/users', async (req, res, next) => {
    try {
      const users = await fetchUsersFromDb();
      res.json(users);
    } catch (error) {
      next(error);
    }
  });

  async function fetchUsersFromDb() {
    const connection = asyncLocalStorage.getStore();
    const users = await connection.any('SELECT * FROM users');
    return users;
  }

  app.listen(port, () => {
    console.log(`Example app listening on port ${port}`);
  });
};
Enter fullscreen mode Exit fullscreen mode

I strongly recommend against this. It is not a good idea both in terms of performance and design. If you need to pass a connection to a function, you should pass it as an argument.

Refer to the Passing a Connection to a Function section for more information.

Anti-Patterns: wrapping the entire request in a transaction

Another anti-pattern I see is wrapping the entire request in a transaction:

app.post('/users', async (req, res) => {
  await pool.transaction(async (transaction) => {
    // do something that is part of the request, but not part of the transaction
    await foo();

    await transaction.query(sql.unsafe`INSERT INTO users (name) VALUES ('foo')`);

    res.json(
      await transaction.one(sql.unsafe`SELECT * FROM users WHERE name = 'foo'`)
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

This is not a good idea. Transactions should be as short as possible. If you need to do something that is not part of the transaction, you should do it outside of the transaction.

app.post('/users', async (req, res) => {
  await foo();

  await pool.transaction(async (transaction) => {
    await transaction.query(sql.unsafe`INSERT INTO users (name) VALUES ('foo')`);

    res.json(
      await transaction.one(sql.unsafe`SELECT * FROM users WHERE name = 'foo'`)
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

Refer to the Optimizing Transactions section for more information.

Anti-Patterns: wrapping the entire request in a connection

Another anti-pattern I see is wrapping the entire request in a connection:

app.post('/users', async (req, res) => {
  await pool.connect(async (connection) => {
    res.json(await foo());
  });
});
Enter fullscreen mode Exit fullscreen mode

This is not a good idea. Implementing this pattern means that your application will be able to handle only as many concurrent requests as there are connections in the connection pool. If you have a connection pool with 10 connections, your application will be able to handle only 10 concurrent requests.

Instead, you should pass an instance of the connection pool to the function that needs access to the database handle and use the connection pool to execute queries and transactions.

Refer to the Passing a Connection to a Function section for more information.

Async Middleware

Express.js does not support asynchronous routes out of the box. For the purpose of keeping the examples simple, we used await in our routes. You can use express-promise-router or express-async-handler to support asynchronous routes in your application.

Conclusion

By now, you should have a good grasp on integrating Slonik with Express.js in TypeScript. If you have any questions, feel free to reach out to me on Twitter or leave an issue on GitHub.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .