Type-safe SQL queries with Kysely and PostgreSQL
Object-Relational Mapping (ORM) libraries such as Prisma and TypeORM can help us produce code faster by avoiding writing SQL queries. They have a smaller…
Object-Relational Mapping (ORM) libraries such as Prisma and TypeORM can help us produce code faster by avoiding writing SQL queries. They have a smaller learning curve because we don’t need to learn a new language and dive deep into understanding how the database works. Unfortunately, ORM libraries often generate SQL queries that are far from optimal and take away control from us. However, writing raw SQL is not a perfect solution either because we don’t have the advantage of type safety that TypeScript provides when working with ORM libraries. Kysely is a query builder that provides a set of functions that create SQL queries. We still need to understand SQL, but Kysely integrates tightly with TypeScript and ensures we don’t make any typos along the way. In this article, we start a new NestJS project and learn how to integrate Kysely with PostgreSQL. Check out this repository if you want to see the full code from this article. Defining the database In this series, we rely on Docker Compose to create an instance of the PostgreSQL database. docker-compose.yml version: "3" services: postgres: container_name: nestjs-kysely-postgres image: postgres:15.3 ports: - "5432:5432" networks: - postgres volumes: - /data/postgres:/data/postgres env_file: - docker.env pgadmin: container_name: nestjs-kysely-pgadmin image: dpage/pgadmin4:7.5 networks: - postgres ports: - "8080:80" volumes: - /data/pgadmin:/root/.pgadmin env_file: - docker.env networks: postgres: driver: bridgeTo provide our Docker container with the necessary credentials, we must create the docker.env file. docker.env POSTGRES_USER=admin POSTGRES_PASSWORD=admin POSTGRES_DB=nestjs PGADMIN_DEFAULT_EMAIL=admin@admin.com PGADMIN_DEFAULT_PASSWORD=adminWe have to provide a similar set of variables for our NestJS application too. .env POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_USER=admin POSTGRES_PASSWORD=admin POSTGRES_DB=nestjsWe should validate the environment variables to prevent the NestJS application from starting if they are invalid. app.module.ts import { Module } from '@nestjs/common'; import { PostsModule } from './posts/posts.module'; import { ConfigModule } from '@nestjs/config'; import * as Joi from 'joi'; @Module({ imports: [ PostsModule, ConfigModule.forRoot({ validationSchema: Joi.object({ POSTGRES_HOST: Joi.string().required(), POSTGRES_PORT: Joi.number().required(), POSTGRES_USER: Joi.string().required(), POSTGRES_PASSWORD: Joi.string().required(), POSTGRES_DB: Joi.string().required(), }), }), ], }) export class AppModule {}To run our PostgreSQL database, we need to have Docker installed and run the docker compose up command. Managing migrations The first step is to create a SQL table we can work with. While Kysely provides migration functionalities, it does not ship with a command-line interface. Let’s follow the official documentation and create a function that runs our migrations.npm install dotenv runMigrations.ts import * as path from 'path'; import { Pool } from 'pg'; import { promises as fs } from 'fs'; import { Kysely, Migrator, PostgresDialect, FileMigrationProvider, } from 'kysely'; import { config } from 'dotenv'; import { ConfigService } from '@nestjs/config'; config(); const configService = new ConfigService(); async function migrateToLatest() { const database = new Kysely({ dialect: new PostgresDialect({ pool: new Pool({ host: configService.get('POSTGRES_HOST'), port: configService.get('POSTGRES_PORT'), user: configService.get('POSTGRES_USER'), password: configService.get('POSTGRES_PASSWORD'), database: configService.get('POSTGRES_DB'), }), }), }); const migrator = new Migrator({ db: database, provider: new FileMigrationProvider({ fs, path, migrationFolder: path.join(__dirname, 'migrations'), }), }); const { error, results } = await migrator.migrateToLatest(); results?.forEach((migrationResult) => { if (migrationResult.status === 'Success') { console.log( `migration "${migrationResult.migrationName}" was executed successfully`, ); } else if (migrationResult.status === 'Error') { console.error( `failed to execute migration "${migrationResult.migrationName}"`, ); } }); if (error) { console.error('Failed to migrate'); console.error(error); process.exit(1); } await database.destroy(); } migrateToLatest(); Above, we use the dotenv library to make sure the ConfigService has all of the environment variables loaded and ready to use. In our migrateToLatest function, we point to the migrations directory that should contain our migrations. Let’s use it to create our first table. migrations/20230806213313_add_articles_table.ts import { Kysely } from 'kysely'; export async function up(database: Kysely