When we design the architecture of our database, we usually end up with tables that relate to each other in some way. Managing such relationships is one of the crucial aspects of working with SQL databases. In this article, we explain the one-to-one relationship and handle it using Kysely with PostgreSQL and NestJS.
You can see the full code from this article in this repository.
Introducing the one-to-one relationship
When storing the information about addresses, we could add the address_street, address_city, and address_country to our users table. However, it might make sense to split it into a separate table called addresses and implement a one-to-one relationship. When we implement the one-to-one relationship, a particular row from the first table has one matching row from the second table and the other way around.
In the above situation, the addess is optional. The case of a one-to-one relationship that is optional can also be referred to as one-to-zero-or-one relationship.
While one-to-one might not be the most common type of relationship, there is a chance we might encounter it when working with various databases. When deciding on whether or not to create a one-to-one relationship, there are quite a few factors to take into consideration. I suggest reading this question on StackOverflow if you want to encounter various opinions to help you make your own choice.
Managing one-to-one relationships with Kysely
In the previous part of this series, we learned how to use Kysely to manage migrations. Let’s start by creating a new migration to add the addresses table and develop a relationship with the users.
1import { Kysely } from 'kysely';
2
3export async function up(database: Kysely<unknown>): Promise<void> {
4 await database.schema
5 .createTable('addresses')
6 .addColumn('id', 'serial', (column) => {
7 return column.primaryKey();
8 })
9 .addColumn('street', 'text')
10 .addColumn('city', 'text')
11 .addColumn('country', 'text')
12 .execute();
13
14 await database.schema
15 .alterTable('users')
16 .addColumn('address_id', 'integer', (column) => {
17 return column.unique().references('addresses.id');
18 })
19 .execute();
20}
21
22export async function down(database: Kysely<unknown>): Promise<void> {
23 await database.schema.dropTable('country').execute();
24 await database.schema.alterTable('users').dropColumn('address_id');
25}In the above code, we create the addresses table. We also add the address_id column to the existing users table as a foreign key that refers to the primary key of the addresses table. Thanks to that, PostgreSQL recognizes there is a connection between our tables.
Also, in our application, only one user can refer to a particular address. Because of that, we add the unique constraint to the address_id column. Thanks to that, trying to connect more than one user to the same row in the addresses table would throw an error.
We can now add the address Table to the TypeScript definition of our database.
1import { Generated } from 'kysely';
2
3export interface AddressesTable {
4 id: Generated<number>;
5 street: string | null;
6 city: string | null;
7 country: string | null;
8}Let’s also add the address_id to the definition of our users table.
1import { Generated } from 'kysely';
2
3export interface UsersTable {
4 id: Generated<number>;
5 email: string;
6 name: string;
7 password: string;
8 address_id: number | null;
9}The last step is to ensure that both tables are added to our Tables interface.
1import { ArticlesTable } from '../articles/articlesTable';
2import { Kysely } from 'kysely';
3import { UsersTable } from '../users/usersTable';
4import { AddressesTable } from '../users/addressesTable';
5
6interface Tables {
7 articles: ArticlesTable;
8 users: UsersTable;
9 addresses: AddressesTable;
10}
11
12export class Database extends Kysely<Tables> {}Inserting rows into two tables in a single query
We want to insert the user and the address into the database simultaneously. One way of doing that is to create a Common Table Expression Query using the WITH statement. With this approach, we can create both the address and the user in a single, atomic SQL query and either succeeds completely or fails as a whole. The address won’t be stored in the database if something goes wrong when inserting the user.
1import { Injectable } from '@nestjs/common';
2import { User } from './user.model';
3import { CreateUserDto } from './dto/createUser.dto';
4import { Database } from '../database/database';
5
6@Injectable()
7export class UsersRepository {
8 constructor(private readonly database: Database) {}
9
10 // ...
11
12 async createWithAddress(userData: CreateUserDto) {
13 const databaseResponse = await this.database
14 .with('created_address', (database) => {
15 return database
16 .insertInto('addresses')
17 .values({
18 street: userData.address?.street,
19 city: userData.address?.city,
20 country: userData.address?.country,
21 })
22 .returningAll();
23 })
24 .insertInto('users')
25 .values((expressionBuilder) => {
26 return {
27 password: userData.password,
28 email: userData.email,
29 name: userData.name,
30 address_id: expressionBuilder
31 .selectFrom('created_address')
32 .select('id'),
33 };
34 })
35 .returning((expressionBuilder) => {
36 return [
37 'id',
38 'email',
39 'name',
40 'password',
41 'address_id',
42 expressionBuilder
43 .selectFrom('created_address')
44 .select('street')
45 .as('address_street'),
46 expressionBuilder
47 .selectFrom('created_address')
48 .select('city')
49 .as('address_city'),
50 expressionBuilder
51 .selectFrom('created_address')
52 .select('country')
53 .as('address_country'),
54 ];
55 })
56 .executeTakeFirstOrThrow();
57
58 return new User(databaseResponse);
59 }
60}Thanks to using the with function from Kysely instead of writing the SQL query manually, our code is type-safe. For example, TypeScript would complain if we would make a typo in the following section of the code:
1address_id: expressionBuilder
2 .selectFrom('created_address')
3 .select('wrong_column_name')Let’s use the new createWithAddress method whenever the user’s signing-up data contains the address.
1import { Injectable } from '@nestjs/common';
2import { CreateUserDto } from './dto/createUser.dto';
3import { UsersRepository } from './users.repository';
4import { isRecord } from '../utils/isRecord';
5import { PostgresErrorCode } from '../database/postgresErrorCode.enum';
6import { UserAlreadyExistsException } from './exceptions/userAlreadyExists.exception';
7
8@Injectable()
9export class UsersService {
10 constructor(private readonly usersRepository: UsersRepository) {}
11
12 // ...
13
14 async create(user: CreateUserDto) {
15 try {
16 if (user.address) {
17 return await this.usersRepository.createWithAddress(user);
18 }
19 return await this.usersRepository.create(user);
20 } catch (error) {
21 if (isRecord(error) && error.code === PostgresErrorCode.UniqueViolation) {
22 throw new UserAlreadyExistsException(user.email);
23 }
24 throw error;
25 }
26 }
27}Above, we’re also checking if there was an error when creating a user because the provided email is occupied already. To do that, we created the PostgresErrorCode enum that contains various codes that PostgreSQL can emit when an error happens.
1export enum PostgresErrorCode {
2 UniqueViolation = '23505',
3}Error handling with PostgreSQL and Kysely is a topic that deserves a separate article.
Creating the models
Since our query now includes the address, we need to adjust our models.
1interface AddressModelData {
2 id: number;
3 street?: string | null;
4 city?: string | null;
5 country?: string | null;
6}
7export class Address {
8 id: number;
9 street: string | null;
10 city: string | null;
11 country: string | null;
12 constructor({
13 id,
14 street = null,
15 city = null,
16 country = null,
17 }: AddressModelData) {
18 this.id = id;
19 this.street = street;
20 this.city = city;
21 this.country = country;
22 }
23}We also need to use the above model in the User class.
1import { Exclude } from 'class-transformer';
2import { Address } from './address.model';
3
4interface UserModelData {
5 id: number;
6 email: string;
7 name: string;
8 password: string;
9 address_id?: number | null;
10 address_street?: string | null;
11 address_city?: string | null;
12 address_country?: string | null;
13}
14
15export class User {
16 id: number;
17 email: string;
18 name: string;
19 @Exclude({ toPlainOnly: true })
20 password: string;
21 address?: Address;
22 constructor({
23 id,
24 email,
25 name,
26 password,
27 address_id = null,
28 address_street = null,
29 address_country = null,
30 address_city = null,
31 }: UserModelData) {
32 this.id = id;
33 this.email = email;
34 this.name = name;
35 this.password = password;
36 if (address_id) {
37 this.address = new Address({
38 id: address_id,
39 street: address_street,
40 city: address_city,
41 country: address_country,
42 });
43 }
44 }
45}Thanks to all of the above, we can now sign up while providing the details of our address.
Joining the data from two tables
Our SQL queries can retrieve rows from multiple tables simultaneously and match them based on ids. To do that, we need to perform a join. The default type of join in SQL is the inner join.
1SELECT users.*,
2 addresses.street as address_street, addresses.city as address_city, addresses.country as address_country
3 FROM users
4 JOIN addresses ON users.address_id = addresses.id
5 WHERE email=$1The crucial thing about the inner join is that it returns records that have matching values in both tables. In our database structure, the address is optional. Because of that, our query would not return a user that does not have an address, even if the user with the particular email is in our database.
To fix the above issue, we need to perform an outer join. Outer joins preserve the rows that don’t have matching values. In our case, we need to do the left join that returns all records from the left table and the matched records from the right table. The left table is the users, and the right table is the addresses.
1import { Injectable } from '@nestjs/common';
2import { User } from './user.model';
3import { Database } from '../database/database';
4
5@Injectable()
6export class UsersRepository {
7 constructor(private readonly database: Database) {}
8
9 async getByEmail(email: string) {
10 const databaseResponse = await this.database
11 .selectFrom('users')
12 .where('email', '=', email)
13 .leftJoin('addresses', 'addresses.id', 'users.address_id')
14 .select([
15 'users.id as id',
16 'users.email as email',
17 'users.name as name',
18 'users.password as password',
19 'addresses.id as address_id',
20 'addresses.city as address_city',
21 'addresses.street as address_street',
22 'addresses.country as address_country',
23 ])
24 .executeTakeFirst();
25
26 if (databaseResponse) {
27 return new User(databaseResponse);
28 }
29 }
30
31 // ...
32}Thanks to using Kysely, the above code is type-safe. TypeScript would prevent us from making a typo in 'addresses.country as address_country', for example.
By using the left join, we ensure that our query works as expected for users that don’t have addresses.
Summary
In this article, we explained the one-to-one relationships using the example of users and addresses. We’ve also learned how to implement them when working with PostgreSQL and Kysely to generate SQL queries in a type-safe manner. While doing that, we used common table expressions to ensure we created both the user and the address in a single SQL query. We also had the chance to understand the difference between inner and outer joins. There is still much to learn when implementing relationships with Kysely and PostgreSQL, so stay tuned!