# 主旨 本文將會介紹如何使用 [Typeorm](https://typeorm.io/) 用來連結 [Neon 平台](https://console.neon.tech/app/projects/delicate-silence-377852)所建制的 Postgresql DB ## 前置作業 0. 已安裝 nest cli 1. 已使用 nest cli 建立一個 空專案 ## 什麼是 TypeORM [TypeORM](https://typeorm.io/) 是一種支援 Typescript 型別宣告的 Nodejs [ORM](https://zh.wikipedia.org/zh-tw/%E5%AF%B9%E8%B1%A1%E5%85%B3%E7%B3%BB%E6%98%A0%E5%B0%84) 框架 將程式對於資料表紀錄的操作抽象化成為對於某個 Nodejs 記憶體物件的操作 讓對於資料庫的語法不被限制於特定資料庫 以及支援資料庫連接池的設定,有效簡化開發者對於資料庫的開發複雜度 ## 為何使用 TypeORM 1. 想把對資料庫紀錄的操作抽象化(?, 方便隔離測試(? 2. 支援較多資料庫 ORM 的標準操作 相對於 [Prisma](https://www.prisma.io/) 3. ~~潮, 主流~~ 4. ~~有人要我 demo~~ ## 使用概念 ### [DataSource](https://typeorm.io/data-source) TypeORM 處理資料庫連線的最基礎元件就是 DataSource 啟動應用程式時需要呼叫 initial 這個函是來建立資料庫連線池的連線 關閉應用程式時需要呼叫 destroy 這個函是來關閉資料庫連線池的連線 在 Nestjs 應用則有 Component Manager 可以做到這個管理,不必特別設定 ### [Entity](https://typeorm.io/entities) Entity 就是 TypeORM 裏面對來對應資料表的 Data Model 每個 Entity 對應到一個資料表 每個 Entity 物件的一個屬性對應資料表內的每個欄位 舉例來說 ```typescript= import { Entity, PrimaryGeneratedColumn, Column } from "typeorm" @Entity() export class User { @PrimaryGeneratedColumn() id: number @Column() firstName: string @Column() lastName: string @Column() isActive: boolean } ``` 對應到的表格如下 ``` +-------------+--------------+----------------------------+ | user | +-------------+--------------+----------------------------+ | id | int(11) | PRIMARY KEY AUTO_INCREMENT | | firstName | varchar(255) | | | lastName | varchar(255) | | | isActive | boolean | | +-------------+--------------+----------------------------+ ``` ### [Relation](https://typeorm.io/relations) 當把表格資訊做正規化 把一張表拆分成兩個表格 這時可以透過外部鍵把兩個表格做關聯 而這個關鏈也就是 TypeORM 所表示的 Relation ### [EntityManager](https://typeorm.io/working-with-entity-manager) 在程式運行時,可以透過 EntityManager 來操作所有的 Entity 物件 而資料庫事務性操作的單位也是以 EntityManager 為主 ### [Repository](https://typeorm.io/working-with-repository) 類似於 EntityManager, Repository 可以操作特定某個類別的 Entity 物件 在 Nestjs 程式中常使用的方式 ### [Migration](https://typeorm.io/migrations) typeORM 提供 cli 以及一個 Migration 的介面用來做資料庫遷移 預設如果 synchronized 設定成 true, 則會自動更新資料表為當下 Entity 格式 ## 在 Nestjs 中使用 TypeORM ### 0 Nestjs TypeORM module 重要的概念 #### 主要結構 ![](https://hackmd.io/_uploads/Bku0i7Vu3.png) 基本上在最大的 AppModule 會有一個用來設定 DB 連線資訊的 TypeORM forRoot 的設定 然後在個別有使用到 DataSource 或是 Entity 的 SubModule 需要個別引入 TypeORM forFeature 來引用每個使用到的 Entity Model 這樣才能讓 Nestjs 在建制整個 Module 時讀取到 Entity 的資訊 另外, 假設不想要把所有 entities 路徑都放在 forRoot 那個 TypeORM 裡的設定,需要額外設定 autoloadEntity 這個設定 #### 連線設定 以下是針對 TypeORM forRoot 內部所需要設定的連線設定 ```typescript= TypeOrmModule.forRootAsync({ useFactory(configService: ConfigService) { // 設定 ssl 連線 flag const IS_DB_SSL_MODE = configService.getOrThrow<boolean>( 'IS_DB_SSL_MODE', false, ); return { ssl: IS_DB_SSL_MODE, extra: { ssl: IS_DB_SSL_MODE ? { rejectUnauthorized: false } : null, poolSize: 5, // 連線池數量,影響會有幾個 connection pool 被建立 idleTimeoutMillis: 3600000, // idle 代表連線可以 idle 多久才被斷線 }, type: 'postgres', url: configService.getOrThrow('DB_URI', ''), synchronize: false, autoLoadEntities: true, // 自動 load 所有 **/*.entity.ts 檔案到 TypeORM 設定 }; }, inject: [ConfigService], }), ``` ##### ssl 設定 這個設定是決定是否要使用 ssl 連線,一般正式連線會啟用 ##### type 設定 這邊是決定要連線資料庫的類型,比如說: 這邊範例是 postgresql DB 所以貼入 'postgres' ##### url 設定 這個是使用 dsn 的方式做連線。格式為: ```postgresql://${db_username}:${db_password}@${db_hostname}:${db_port}/${db_name}``` ##### synchronized 設定 這個設定決定是否自動同步 entity 到資料庫 table 如果設定為 true ,每次程式啟動會自動更新 table。範例採用 migration 方式,所以設定為 false ##### autoLoadEntities 設定 這個設定決定是否自動載入 Entity 到 forRoot TypeORM 如果設定為 true 則不需要填入 entities 參數,TypeORM 會自動搜尋 ```**/*.entity.ts``` 的檔案 如果沒有填,則需要透過 entities 這個參數填入所有 entity ##### entities 設定 這個參數是用來設定需要載入的 entity,讓 TypeORM 理解要載入的 DataModel #### 引入 Entity 為了在每個有使用到的地方可以使用 TypeORM entity 我們需要在有使用到 Entity 的地方去載入該 entity 設定如下 ```typescript= @Module({ imports: [TypeOrmModule.forFeature([BookStoreEntity])], controllers: [BookController], providers: [BookStoreService], }) export class BookStoreModule {} ``` ### 1 安裝必要套件 ```shell= yarn add @nestjs/config yarn add @nestjs/typeorm typeorm pg yarn add joi ``` ### 2 設定連線資料 ```typescript= import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import * as Joi from 'joi'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, validationSchema: Joi.object({ DB_URI: Joi.string().required(), IS_DB_SSL_MODE: Joi.boolean().required(), }), }), TypeOrmModule.forRootAsync({ useFactory(configService: ConfigService) { const IS_DB_SSL_MODE = configService.getOrThrow<boolean>( 'IS_DB_SSL_MODE', false, ); return { ssl: IS_DB_SSL_MODE, extra: { ssl: IS_DB_SSL_MODE ? { rejectUnauthorized: false } : null, poolSize: 5, idleTimeoutMillis: 3600000, }, type: 'postgres', url: configService.getOrThrow('DB_URI', ''), synchronize: false, autoLoadEntity: true }; }, inject: [ConfigService], }), ], controllers: [AppController], providers: [AppService], }) export class AppModule {} ``` ### 3 定義 Entity 內容 ```typescript= import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; @Entity('book_store', { schema: 'public' }) export class BookStoreEntity { @PrimaryGeneratedColumn({ name: 'id', type: 'bigint', }) id: string; @CreateDateColumn({ name: 'created_at', type: 'time without time zone', }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at', type: 'time without time zone', }) updatedAt: Date; @Column({ name: 'author', type: 'varchar', length: '100', }) author: string; @Column({ name: 'name', type: 'varchar', length: '100', }) name: string; @Column({ name: 'publication', type: 'varchar', length: '100', }) publication: string; } ``` ### 4 設置資料庫轉移 #### 新增 migration 設定檔 ```typescript= import { ConfigService } from '@nestjs/config'; import { config } from 'dotenv'; import { DataSource } from 'typeorm'; config(); const configService = new ConfigService(); export default new DataSource({ type: 'postgres', url: configService.get<string>('DATABASE_URL', ''), migrations: ['src/migrations/*.ts'], }); ``` #### 新增 scripts ```jsonld= "typeorm": "ts-node ./node_modules/typeorm/cli", "schema:sync": "yarn run typeorm schema:sync -d src/typeorm.migration.ts", "schema:drop": "yarn run typeorm schema:drop -d src/typeorm.migration.ts", "schema:log": "yarn run typeorm schema:log -d src/typeorm.migration.ts", "typeorm:show": "yarn run typeorm migration:show -d src/typeorm.migration.ts", "typeorm:run-migrations": "yarn run typeorm migration:run -d src/typeorm.migration.ts", "typeorm:create-migration": "npm run typeorm -- migration:create src/migrations/$npm_config_name", "typeorm:generate-migration": "npm run typeorm -- migration:generate -d src/typeorm.migration.ts src/migrations/$npm_config_name", "typeorm:revert-migration": "yarn run typeorm migration:revert -d src/typeorm.migration.ts" ``` #### 新增 migration ```shell= npm run typeorm:create-migration --name=BOOK-STORE ``` ```typescript= import { MigrationInterface, QueryRunner, Table } from 'typeorm'; export class BOOKSTORE1687517670891 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise<void> { await queryRunner.createTable( new Table({ name: 'book_store', schema: 'public', columns: [ { name: 'id', type: 'bigint', generatedIdentity: 'ALWAYS', generationStrategy: 'increment', isGenerated: true, isPrimary: true, }, { name: 'created_at', type: 'time without time zone', isNullable: false, default: 'now()', }, { name: 'updated_at', type: 'time without time zone', isNullable: false, default: 'now()', }, { name: 'author', type: 'varchar', length: '100', isNullable: false, }, { name: 'name', type: 'varchar', length: '100', isNullable: false, }, { name: 'publication', type: 'varchar', length: '100', isNullable: false, }, ], }), ); } public async down(queryRunner: QueryRunner): Promise<void> { await queryRunner.dropTable('public.book_store', true, true, true); } } ``` #### 執行 script 新增 schema ```shell= yarn run typeorm:run-migrations ``` ### 5 實作 基礎操作 #### 設置 submodule 連線配置 ```typescript= import { Module } from '@nestjs/common'; import { BookController } from './book.controller'; import { BookStoreService } from './book-store.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BookStoreEntity } from './book-store.entity'; @Module({ imports: [TypeOrmModule.forFeature([BookStoreEntity])], controllers: [BookController], providers: [BookStoreService], }) export class BookStoreModule {} ``` #### 對 book_store entity 操作 ```typescript= import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { DataSource, Repository } from 'typeorm'; import { BookStoreDto } from '../dtos/book-store.dto'; import { BookStoreEntity } from './book-store.entity'; import { InjectRepository } from '@nestjs/typeorm'; @Injectable() export class BookStoreService { private logger = new Logger(BookStoreService.name); constructor( private readonly dataSource: DataSource, @InjectRepository(BookStoreEntity) private readonly bookStoreRepo: Repository<BookStoreEntity>, ) {} async getBookList(): Promise<BookStoreDto[]> { try { const result = await this.bookStoreRepo.find(); return result.map((book) => ({ id: book.id, name: book.name, author: book.author, publication: book.publication, createdAt: book.createdAt.getTime(), updatedAt: book.updatedAt.getTime(), })); } catch (error) { this.logger.error({ message: 'getBookList error' }, error); throw error; } } async getBookById(id: string): Promise<BookStoreDto> { try { const book = await this.bookStoreRepo.findOne({ where: { id: id, }, }); if (!book) { this.logger.error({ message: `book with id ${id} not found` }); throw new NotFoundException(`book with id ${id} not found`); } return { id: book.id, author: book.author, name: book.name, publication: book.publication, createdAt: book.createdAt.getTime(), updatedAt: book.updatedAt.getTime(), }; } catch (error) { this.logger.error({ message: 'getBookById error' }, error); throw error; } } async updateById( id: string, updateDto: Partial<BookStoreDto>, ): Promise<BookStoreDto> { try { const queryBuilder = this.dataSource .getRepository(BookStoreEntity) .createQueryBuilder('book_store'); const result = await queryBuilder .update<BookStoreEntity>(BookStoreEntity, updateDto) .where('book_store.id = :id', { id }) .returning([ 'id', 'author', 'name', 'publication', 'createdAt', 'updatedAt', ]) .updateEntity(true) .execute(); const model = result.raw[0] as BookStoreEntity; return { id: model.id, name: model.name, author: model.author, publication: model.publication, createdAt: model.createdAt.getTime(), updatedAt: model.updatedAt.getTime(), }; } catch (error) { this.logger.error({ message: 'updateById error' }, error); throw error; } } async insertBook(dto: BookStoreDto): Promise<BookStoreDto> { try { this.logger.log({ dto }); const data = new BookStoreEntity(); data.author = dto.author; data.name = dto.name; data.publication = dto.publication; const queryBuilder = this.dataSource .getRepository(BookStoreEntity) .createQueryBuilder('book_store'); const result = await queryBuilder .insert() .into(BookStoreEntity) .values([data]) .returning([ 'id', 'author', 'name', 'publication', 'createdAt', 'updatedAt', ]) .updateEntity(true) .execute(); const model = result.raw[0] as BookStoreDto; model.createdAt = new Date(result.raw[0]['created_at']).getTime(); model.updatedAt = new Date(result.raw[0]['updated_at']).getTime(); return { id: model.id, name: model.name, author: model.author, publication: model.publication, createdAt: model.createdAt, updatedAt: model.updatedAt, }; } catch (error) { this.logger.error({ message: 'insertBook error' }, error); throw error; } } async deleteById(id: string): Promise<BookStoreDto> { try { this.logger.log({ id }); const queryBuilder = this.dataSource .getRepository(BookStoreEntity) .createQueryBuilder('book_store'); const result = await queryBuilder .delete() .where('book_store.id = :id', { id }) .returning([ 'id', 'author', 'name', 'publication', 'createdAt', 'updatedAt', ]) .execute(); const model = result.raw[0] as BookStoreDto; model.createdAt = new Date(result.raw[0]['created_at']).getTime(); model.updatedAt = new Date(result.raw[0]['updated_at']).getTime(); return { id: model.id, name: model.name, author: model.author, publication: model.publication, createdAt: model.createdAt, updatedAt: model.updatedAt, }; } catch (error) { this.logger.error({ message: 'deleteById error' }, error); throw error; } } } ``` ## repository [https://github.com/nodejs-typescript-classroom/neon-sample.git](https://github.com/nodejs-typescript-classroom/neon-sample.git)