The complexity of our database queries grows together with our application. Due to that, the time necessary to complete the queries. A common way to address this problem is using indexes. In this article, we explore indexes both through MikroORM and SQL queries.
The idea behind indexes
Across the last few articles, we’ve defined a table where we keep posts. Among others, it contains the author_id field.
We might need a routine query to look for posts written by a specific author.
1SELECT * FROM post_entity WHERE author_id = 1;We must be aware that the above query needs to scan the entire post_entity table to find matching entities. Sometimes iterating a table from cover to cover might not be good enough performance-wise. We can deal with this issue by creating an index.
The goal of the index is to make our queries faster by creating a data structure that organizes a table using a particular column.
1CREATE INDEX post_entity_author_id_index ON post_entity (author_id);The above command creates an index using the author_id column of the post_entity table. We can imagine the index as key and value pairs. In our case, the keys are the author ids, and the values point to particular posts.
In real life, the data structures used by PostgreSQL for indexing are more elaborate to maximze the performance. By default, PostgreSQL uses the B-tree data structure when creating indexes where each leaf contains a pointer to a particular table row.
Thanks to the sorted data structure, we can quickly find all posts written by a particular author. However, besides the noticeable advantage when fetching data, there are some crucial downsides.
Every time we insert or update data, PostgreSQL also needs to update the indexes. While indexes can speed up our SELECT queries, they slow down our inserts and updates. Besides the performance, indexes create data structures that need additional space.
Creating indexes with MikroORM
To create an index using MikroORM, we need to use the @Index() decorator.
1import {
2 Entity,
3 PrimaryKey,
4 ManyToOne,
5 Index,
6} from '@mikro-orm/core';
7import User from '../users/user.entity';
8import WithSoftDelete from '../utils/withSoftDelete';
9
10@Entity()
11@WithSoftDelete()
12class PostEntity {
13 @PrimaryKey()
14 id: number;
15
16 @ManyToOne()
17 @Index()
18 author: User;
19
20 // ...
21}
22
23export default PostEntity;Adding the above to our schema and running npx mikro-orm migration:create results in the following migration:
1import { Migration } from '@mikro-orm/migrations';
2
3export class Migration20220614231701 extends Migration {
4
5 async up(): Promise<void> {
6 this.addSql('create index "post_entity_author_id_index" on "post_entity" ("author_id");');
7 }
8
9 async down(): Promise<void> {
10 this.addSql('drop index "post_entity_author_id_index";');
11 }
12
13}We can use the name option in the @Index() decorator to change the auto-generated name of the index to something else.
Multi-column indexes
Sometimes we might notice that we often make queries with multiple conditions. For example, let’s look for posts authored by a certain user and deleted during the last month.
1SELECT * FROM post_entity WHERE author_id = 1
2 AND deleted_at > NOW() - interval '1 month'We can easily create a multi-column index using an SQL query.
1CREATE INDEX post_entity_author_id_deleted_at_index
2 ON post_entity (author_id, deleted_at);We can achieve the same thing with MikroORM by using the @Index() decorator with the properties argument.
1import {
2 Entity,
3 Property,
4 PrimaryKey,
5 ManyToOne,
6 Collection,
7 ManyToMany,
8 Index,
9} from '@mikro-orm/core';
10import User from '../users/user.entity';
11import Category from '../categories/category.entity';
12import WithSoftDelete from '../utils/withSoftDelete';
13
14@Entity()
15@WithSoftDelete()
16@Index({ properties: ['author', 'deletedAt'] })
17class PostEntity {
18 @PrimaryKey()
19 id: number;
20
21 @Property()
22 title: string;
23
24 @Property()
25 content: string;
26
27 @ManyToOne()
28 @Index()
29 author: User;
30
31 @ManyToMany(() => Category)
32 categories: Collection<Category>;
33
34 @Index()
35 @Property({ nullable: true, type: 'timestamptz' })
36 deletedAt?: Date;
37}
38
39export default PostEntity;Creating the above index and running npx mikro-orm migration:create generates the following migration:
1import { Migration } from '@mikro-orm/migrations';
2
3export class Migration20220615230639 extends Migration {
4
5 async up(): Promise<void> {
6 this.addSql('create index "post_entity_author_id_deleted_at_index" on "post_entity" ("author_id", "deleted_at");');
7 }
8
9 async down(): Promise<void> {
10 this.addSql('drop index "post_entity_author_id_deleted_at_index";');
11 }
12
13}Unique indexes
In one of the previous articles, we’ve defined a table for a user.
One of the columns of the above table is email, which we’ve declared as unique.
1CONSTRAINT user_email_unique UNIQUE (email)Whenever we define a unique constraint, PostgreSQL automatically creates a unique index to enforce the constraint.
1CREATE UNIQUE INDEX user_email_unique ON "user" (email);We don’t need to manualy create indexes on unique columns, PostgtreSQL does that for us when we define the constraint.
Remember that PostgreSQL also creates the unique constraint and index for primary keys. Because of that, every table has at least one index if it contains a primary key.
Creating unique indexes with MikroORM
To create a unique constraint and index with MikroORM, we can use unique: true along with the @Property() decorator.
1import {
2 Entity,
3 Property,
4 PrimaryKey,
5} from '@mikro-orm/core';
6
7@Entity()
8class User {
9 @PrimaryKey()
10 id: number;
11
12 @Property({ unique: true })
13 email: string;
14
15 // ...
16}
17
18export default User;An alternative approach to the above is using the @Unique() decorator.
1import {
2 Entity,
3 Property,
4 PrimaryKey,
5 Unique
6} from '@mikro-orm/core';
7
8@Entity()
9class User {
10 @PrimaryKey()
11 id: number;
12
13 @Property()
14 @Unique()
15 email: string;
16
17 // ...
18}
19
20export default User;Defining the above schema and running npx mikro-orm migration:create causes the following migration to be created:
1import { Migration } from '@mikro-orm/migrations';
2
3export class Migration20220615013946 extends Migration {
4
5 async up(): Promise<void> {
6 this.addSql('alter table "user" add constraint "user_email_unique" unique ("email");');
7 }
8
9 async down(): Promise<void> {
10 this.addSql('alter table "user" drop constraint "user_email_unique";');
11 }
12
13}Types of indexes
So far, all the indexes we’ve created in this article used the B-tree data structure. While it fits most of the cases, there are some other options. For example, we can use the expression property of the @Index() decorator to provide an SQL query used to create the index.
Generalized Inverted Indexes (GIN)
GIN indexes fit best where the values contain more than one key. A good example would be the array data type. However, they can also come in handy when implementing text searching.
1@Property()
2@Index({
3 expression:
4 'CREATE INDEX post_entity_title_index ON post_entity USING GIN (title)',
5})
6title: string;Please notice that GIN indexes might not work out of the box.
Hash indexes
Hash index uses the hash table data structure and might come in handy in some specific use-cases.
1@Property()
2@Index({
3 expression:
4 'CREATE INDEX post_entity_title_index ON post_entity USING hash (title)',
5})
6title: string;Block Range Indexes (BRIN)
The Block Range Indexes can come in handy when used with data types that have a linear sort order.
1@Property()
2@Index({
3 expression:
4 'CREATE INDEX post_entity_title_index ON post_entity USING BRIN (title)',
5})
6title: string;Generalized Search Tree (GIST)
The GIST indexes can be helpful when indexing geometric data and implementing text search. In some cases, it might be preferable over GIN.
1@Property()
2@Index({
3 expression:
4 'CREATE INDEX post_entity_title_index ON post_entity USING GIN (title)',
5})
6title: string;Summary
In this article, we’ve gone through indexes and how they can affect the performance of our queries. The above includes improving the performance and, in some cases, making it worse. We’ve also learned how to create indexes through SQL queries and MikroORM. Besides regular indexes, we’ve also created multi-column indexes and used index types other than B-tree. All of the above gives us a solid introduction to how indexes work and what are their pros and cons.