TypeORM은 NodeJS, Browser, Cordova, PhoneGap, Ionic, React Native, NativeScript, Expo 및 Electron 플랫폼에서 실행할 수 있는 ORM이며 TypeScript 및 JavaScript(ES2021)와 함께 사용할 수 있다. TypeORM의 목표는 항상 최신 JavaScript 기능을 지원하고 몇 개의 테이블이 있는 작은 응용 프로그램에서 여러 데이터베이스가 있는 대규모 엔터프라이즈 응용 프로그램에 이르기까지 데이터베이스를 사용하는 모든 종류의 응용 프로그램을 개발하는 데 도움이 되는 추가 기능을 제공하는 것이다.
TypeORM은 현재 존재하는 다른 모든 JavaScript ORM과 달리 Active Record 및 Data Mapper 패턴을 모두 지원한다. 즉, 고품질의 느슨하게 결합된 확장 가능하고 유지 관리 가능한 애플리케이션을 가장 생산적인 방식으로 작성할 수 있다.
컴파일러 옵션의 lib 섹션에서 es6을 사용 설정하거나, @types에서 es6-shim을 설치해야 할 수도 있다.
빠른 시작
TypeORM을 시작하는 가장 빠른 방법은 CLI 명령을 사용하여 시작 프로젝트를 생성하는 것이다. 빠른 시작은 NodeJS 애플리케이션에서 TypeORM을 사용하는 경우에만 동작한다. 다른 플랫폼을 사용하는 경우 단계별 가이드에 따라 진행해야 한다.
먼저, TypeORM을 전역 설치한다.:
npm install typeorm -g
그 다음 새 프로젝트를 만들고자 하는 디렉토리로 이동하여 명령을 실행한다:
typeorm init --name MyProject --database mysql
여기서 name은 프로젝트의 이름이고 database는 사용할 데이터베이스이다. 데이터베이스는 다음 중 하나일 수 있다: mysql, mariadb, postgres, cockroachdb, sqlite, mssql, oracle, mongodb, cordova, react-native, expo, nativescript.
이 명령은 MyProject 디렉토리에 다음의 파일들이 있는 새 프로젝트를 생성한다:
MyProject
├── src // place of your TypeScript code
│ ├── entity // place where your entities (database models) are stored
│ │ └── User.ts // sample entity
│ ├── migration // place where your migrations are stored
│ └── index.ts // start point of your application
├── .gitignore // standard gitignore file
├── ormconfig.json // ORM and database connection configuration
├── package.json // node module dependencies
├── README.md // simple readme file
└── tsconfig.json // TypeScript compiler options
기존 Node 프로젝트에서 typeorm init을 실행할 수도 있지만, 이미 가지고 있는 파일 중 일부를 무시할 수도 있기 때문에 주의해야한다.
다음 단계는 새 프로젝트 종속성을 설치하는 것이다:
cd MyProject
npm install
설치가 진행되는 동안 ormconfig.json파일을 편집하여 데이터베이스 연결 설정 옵션들을 입력한다:
특히, 대부분의 경우 host, username, password, database및 port 옵션만 설정하면 된다.
설정을 마치고 모든 node 모듈이 설치되면 애플리케이션을 실행할 수 있다:
npm start
애플리케이션이 성공적으로 실행되고 새 사용자를 데이터베이스에 추가해야 한다. 이 프로젝트로 계속 작업하거나 필요한 다른 모듈을 통합하고 더 많은 엔터티 생성을 시작할 수 있다.
typeorm init --name MyProject --database mysql --express 명령을 실행하여 Express가 설치된 고급 프로젝트를 생성할 수 있다.
typeorm init --name MyProject --database postgres --docker 명령을 실행하여 docker 작성 파일을 생성할 수 있다.
단계별 가이드
ORM에서 무엇을 기대하는가? 우선, 유지 관리가 어려운 SQL 쿼리를 많이 작성하지 않고도 데이터베이스 테이블을 생성하고 데이터를 검색 / 삽입 / 업데이트 / 삭제 할 것으로 기대한다. 이 가이드는 TypeORM을 처음부터 설정하고 ORM에서 기대하는 것을 수행하는 방법을 보여준다.
모델 생성
데이터베이스 작업은 테이블 생성에서 시작된다. TypeORM에게 데이터베이스 테이블을 생성하도록 지시하는 방법은 무엇인가? 답은 '모델을 통해서'이다. 앱의 모델은 데이터베이스 테이블입니다.
이제 id, name, description, filename, views 그리고 isPublished 열이 photo 테이블에 추가된다. 데이터베이스의 열 타입은 사용한 속성 유형에서 유추된다(예를 들어, number는 integer로, string은 varchar로, boolean은 bool로, 등). 그러나 @Column 데코레이터에 열 타입을 명시적으로 지정하여 데이터베이스가 지원하는 모든 열 타입을 사용할 수 있다.
열이 있는 데이터베이스 테이블을 생성했지만 한 가지가 남았다. 각 데이터베이스 테이블에는 기본 키가 있는 열이 있어야 한다.
기본 열 생성
각 엔터티에는 무조건 하나 이상 의 기본 키 열이 있어야 한다. 이것은 필수 요구 사항이다. 열을 기본 키로 만드려면 @PrimaryColumn 데코레이터를 사용해야 한다.
이제 id 열이 자동 생성(이를 자동 증가 열, auto-increment generated identity column 이라고 함)되기를 원한다고 가정해보자. 그렇게 하려면 @PrimaryColumn 데코레이터를 @PrimaryGeneratedColumn로 변경해야 한다.
다음으로 데이터 유형을 수정해보자. 기본적으로 문자열은 varchar(255)와 유사한 유형(데이터베이스 유형에 따라 다름)에 매핑되고, 숫자는 정수와 같은 유형으로 매핑된다(데이터베이스 유형에 따라 다름). 우리는 모든 열이 varchar 또는 정수로 제한되기를 원하지 않는다. 올바른 데이터 유형을 설정해보자:
열 타입은 데이터베이스에 따라 다르다. 데이터베이스가 지원하는 모든 열 타입을 설정할 수 있다. 지원 되는 열 타입에 대한 자세한 정보는 여기에서 찾을 수 있다.
데이터 베이스에 대한 연결 생성
이제 엔티티가 생성되면 index.ts(또는 app.ts처럼 원하는 것으로 부를 수 있음) 파일을 만들고 그곳에서 연결을 설정해 보자.
import"reflect-metadata";import { createConnection } from"typeorm";import { Photo } from"./entity/Photo";createConnection({ type:"mysql", host:"localhost", port:3306, username:"root", password:"admin", database:"test", entities: [ Photo ], synchronize:true, logging:false}).then(connection => {// here you can start to work with your entities}).catch(error =>console.log(error));
이 예시에서는 MySQL을 사용하고 있지만 지원되는 다른 데이터베이스를 사용할 수도 있다. 다른 데이터 베이스를 사용하려면 옵션의 type을 사용 중인 데이터베이스 타입으로 변경하기만 하면 된다(mysql, mariadb, postgres, cockroachdb, sqlite, mssql, oracle, cordova, nativescript, react-native, expo, or mongodb). 또한 호스트, 포트, 사용자 이름, 암호 및 데이터베이스 설정을 사용해야 한다.
이 연결에 대한 엔터티 목록에 Photo 엔터티를 추가했다. 연결에 사용 중인 각 엔터티가 여기에 나열되어야 한다.
synchronize를 설정하면 애플리케이션을 실행할 때마다 엔터티가 데이터베이스와 동기화된다.
디렉토리에서 모든 엔터티 불러오기
나중에 더 많은 엔터티를 만들 때 그것들을 설정에 추가해야 한다. 이것은 그다지 편리하지 않기 때문에 대신 모든 엔터티가 연결되고 연결에 사용될 전체 디렉토리를 설정할 수 있다:
import { createConnection } from"typeorm";createConnection({ type:"mysql", host:"localhost", port:3306, username:"root", password:"admin", database:"test", entities: [ __dirname +"/entity/*.js" ], synchronize:true,}).then(connection => {// here you can start to work with your entities}).catch(error =>console.log(error));
그러나 이러한 접근 방식에는 주의가 필요하다. ts-node를 사용하는 경우에는, .ts 파일에 대한 경로를 지정해야 하고outDir을 사용하는 경우에는, outDir 디렉토리 내의 .js 파일에 대한 경로를 지정해야 한다. outDir을 사용 중이고 엔터티를 제거하거나 이름을 변경할 때 outDir 디렉토리를 지우고 프로젝트를 다시 컴파일해야 한다. 왜냐하면 소스 .ts 파일을 제거할 때 컴파일된 .js 버전은 출력 디렉토리에서 제거되지 않고 여전히 outDir 디렉토리에 존재하여 TypeORM에 의해 로드되기 때문이다.
애플리케이션 실행
이제 index.ts를 실행하면 데이터베이스와의 연결이 초기화되고 photo에 대한 데이터베이스 테이블이 생성된다.
이제 코드를 리팩토링하여 EntityManager 대신 Repository를 사용해보자. 각 엔터티에는 엔터티에 대한 모든 작업을 처리하는 자체 리포지토리가 있다. 엔터티를 많이 다룰 때는 EntityManager보다 Repositories를 사용하는 것이 더 편리하다.
import { createConnection } from"typeorm";import { Photo } from"./entity/Photo";createConnection(/*...*/).then(async connection => {let photo =newPhoto();photo.name ="Me and Bears";photo.description ="I am near polar bears";photo.filename ="photo-with-bears.jpg";photo.views =1;photo.isPublished =true;let photoRepository =connection.getRepository(Photo);awaitphotoRepository.save(photo);console.log("Photo has been saved");let savedPhotos =awaitphotoRepository.find();console.log("All photos from the db: ", savedPhotos);}).catch(error =>console.log(error));
여기에서는 @OneToOne이라는 새로운 데코레이터를 사용하고 있다. 이를 통해 두 엔터티 간에 1:1 관계를 만들 수 있다. type => Photo는 우리가 관계를 만들고자 하는 엔터티의 클래스를 반환하는 함수다. 언어적 특성 때문에 클래스를 직접 사용하는 대신 클래스를 반환하는 함수를 사용해야 한다. () => Photo로 쓸 수도 있지만 코드 가독성을 높이기 위해 type => Photo를 관습적으로 사용한다. 타입 변수 자체에는 아무 것도 포함되지 않는다.
또한 @JoinColumn 데코레이터를 추가하여 관계의 한 쪽이 관계를 소유하게 됨을 나타낸다. 관계는 단방향 또는 양방향일 수 있지만 관계의 한 쪽만 소유될 수 있다. @JoinColumn 데코레이터를 사용하는 것은 관계의 소유하는 쪽에서 필요로 한다.
앱을 실행하면 새로 생성된 테이블이 표시되며 여기에는 photo 관계에 대한 외래 키가 있는 열이 포함된다:
import { createConnection } from"typeorm";import { Photo } from"./entity/Photo";import { PhotoMetadata } from"./entity/PhotoMetadata";createConnection(/*...*/).then(async connection => {// create a photolet photo =newPhoto();photo.name ="Me and Bears";photo.description ="I am near polar bears";photo.filename ="photo-with-bears.jpg";photo.views =1;photo.isPublished =true;// create a photo metadatalet metadata =newPhotoMetadata();metadata.height =640;metadata.width =480;metadata.compressed =true;metadata.comment ="cybershoot";metadata.orientation ="portrait";metadata.photo = photo; // this way we connect them// get entity repositorieslet photoRepository =connection.getRepository(Photo);let metadataRepository =connection.getRepository(PhotoMetadata);// first we should save a photoawaitphotoRepository.save(photo);// photo is saved. Now we need to save a photo metadataawaitmetadataRepository.save(metadata);// doneconsole.log("Metadata is saved, and the relation between metadata and photo is created in the database too");}).catch(error =>console.log(error));
관계의 반대측
관계는 단방향 또는 양방향일 수 있다. 현재 PhotoMetadata와 Photo 간의 관계는 단방향이다. 관계의 소유자는 PhotoMetadata이고 Photo는 PhotoMetadata에 대해 아무것도 모르는 상태다. 이로 인해 photo 측에서 PhotoMetadata에 액세스하는 것이 복잡해진다. 이 문제를 해결하려면 역 관계를 추가하여 PhotoMetadata와 Photo 간의 관계를 양방향으로 만들어야 한다. 엔터티를 수정해보자.
photo => photo.metadata는 관계의 반대측의 이름을 반환하는 함수다. 여기에서 Photo 클래스의 metadata 속성이 Photo 클래스에서 PhotoMetadata를 저장하는 위치임을 보여준다. photo의 속성을 반환하는 함수를 전달하는 대신 "metadata"와 같은 문자열을 @OneToOne 데코레이터에 전달할 수도 있다. 그러나 우리는 리팩토링을 더 쉽게 하기 위해 함수 타입 접근 방식을 사용했다.
@JoinColumn 데코레이터는 관계의 한 쪽에서만 사용해야한다. 이 데코레이터를 어느 쪽에 두든 그 쪽이 관계의 소유 측이 된다. 관계의 소유 측에는 데이터베이스에 외래 키가 있는 열이 있다.
관계와 함께 객체 로드
이제 단일 쿼리에서 photo와 phto metadata를 로드해보자. find* 메소드를 사용하거나 QueryBuilder 기능을 사용하는 두 가지 방법이 있다. 먼저 find* 메소드를 사용해보자. find* 메서드를 사용하면 FindOneOptions / FindManyOptions 인터페이스로 개체를 지정할 수 있게 된다.
여기에서 photos에는 데이터베이스의 photo 배열이 포함되고 각 photo에는 photo metadata가 포함된다. 이 문서에서 찾기 옵션에 대해 자세히 알아볼 수 있다.
Using find options is good and dead 찾기 옵션을 사용하는 것은 훌륭하고 간단하지만 더 복잡한 쿼리가 필요한 경우에는 QueryBuilder를 대신 사용해야 한다. QueryBuilder를 사용하면 보다 복잡한 쿼리를 우아한 방식으로 사용할 수 있다.
QueryBuilder를 사용하면 거의 모든 복잡한 SQL 쿼리를 만들고 실행할 수 있게 된다. QueryBuilder로 작업할 때 SQL 쿼리를 생성하는 것처럼 생각하자. 이 예에서 "photo" 및 "metadata"는 선택한 photo에 적용된 별칭이다. 별칭을 사용하여 선택한 데이터의 열 및 속성에 액세스한다.
Casecade를 사용하여 관련 객체 자동 저장
다른 개체가 저장될 때마다 관련 개체가 저장되기를 원하는 경우 관계에서 cascade 옵션을 설정할 수 있다. photo의 @OneToOne 데코레이터를 약간 변경해 보자.
cascade를 사용하면 photo를 따로 저장하지 않고도 metadata 객체를 따로 저장할 수 있게 된다. 이제 photo 객체를 간단히 저장할 수 있으며 metadata 객체는 cascade 옵션으로 인해 자동으로 저장된다.
createConnection(options).then(async connection => {// create photo objectlet photo =newPhoto();photo.name ="Me and Bears";photo.description ="I am near polar bears";photo.filename ="photo-with-bears.jpg";photo.isPublished =true;// create photo metadata objectlet metadata =newPhotoMetadata();metadata.height =640;metadata.width =480;metadata.compressed =true;metadata.comment ="cybershoot";metadata.orientation ="portrait";photo.metadata = metadata; // this way we connect them// get repositorylet photoRepository =connection.getRepository(Photo);// saving a photo also save the metadataawaitphotoRepository.save(photo);console.log("Photo is saved, photo metadata is saved too.")}).catch(error =>console.log(error));
이제 이전과 같이 metadata의 photo 속성 대신 photo의 metadata 속성을 설정한다. cascade 기능은 photo를 photo 측면에서 metadata에 연결하는 경우에만 작동한다. metadata 측면을 설정하면 metadata가 자동으로 저장되지 않는다.
N:1 또는 1:N 관계 생성
N:1/1:N 관계를 만들어 보자. photo에는 한 명의 author가 있고 각 author는 많은 photo를 가질 수 있다고 가정하고 우선 Author 클래스를 생성해 보자:
import { Entity, Column, PrimaryGeneratedColumn, OneToMany, JoinColumn } from"typeorm";import { Photo } from"./Photo";@Entity()exportclassAuthor { @PrimaryGeneratedColumn() id:number; @Column() name:string; @OneToMany(type => Photo, photo =>photo.author) // note: we will create author property in the Photo class below photos:Photo[];}
Author는 관계의 반대 측면을 포함한다. OneToMany는 항상 관계의 반대 측면이며 관계의 다른 측면에 ManyToOne 없이는 존재할 수 없습니다.
constoptions:ConnectionOptions= {// ... other options entities: [Photo, PhotoMetadata, Author, Album]};
이제 데이터베이스에 album과 photo를 삽입해 보자:
let connection =awaitcreateConnection(options);// create a few albumslet album1 =newAlbum();album1.name ="Bears";awaitconnection.manager.save(album1);let album2 =newAlbum();album2.name ="Me";awaitconnection.manager.save(album2);// create a few photoslet photo =newPhoto();photo.name ="Me and Bears";photo.description ="I am near polar bears";photo.filename ="photo-with-bears.jpg";photo.views =1photo.isPublished =truephoto.albums = [album1, album2];awaitconnection.manager.save(photo);// now our photo is saved and albums are attached to it// now lets load them:constloadedPhoto=await connection.getRepository(Photo).findOne(1, { relations: ["albums"] });
loadedPhoto는 다음과 같다:
{ id:1, name:"Me and Bears", description:"I am near polar bears", filename:"photo-with-bears.jpg", albums: [{ id:1, name:"Bears" }, { id:2, name:"Me" }]}
쿼리 빌더 사용
쿼리 빌더를 사용하여 거의 모든 복잡한 SQL 쿼리를 작성할 수 있다. 예를 들어 다음과 같이 할 수 있다:
let photos =await connection.getRepository(Photo) .createQueryBuilder("photo") // first argument is an alias. Alias is what you are selecting - photos. You must specify it.
.innerJoinAndSelect("photo.metadata","metadata").leftJoinAndSelect("photo.albums","album").where("photo.isPublished = true").andWhere("(photo.name = :photoName OR photo.name = :bearName)").orderBy("photo.id","DESC").skip(5).take(10).setParameters({ photoName:"My", bearName:"Mishka" }).getMany();
이 쿼리는 이름이 "My" 또는 "Mishka"인 게시된 모든 photo를 선택한다. 위치 5(pagination 오프셋)에서 결과를 선택하고 10개 결과(pagination 제한)만 선택한다. 선택 결과는 id의 내림차순으로 정렬된다. photo의 album들은 결합된 상태로 유지되고 해당 metadata는 내부 결합(inner join)된다.
애플리케이션에서 쿼리 빌더를 많이 사용할 것이다. 여기에서 쿼리 빌더에 대해 자세히 알 수 있다.