リンクラフトでエンジニアとして働いている渡辺です。リンクラフトアドベントカレンダーの10日目を担当します。
前回 Next.js+TypeScript 環境での TypeORM について説明しました。
今回は TypeORM を使っていてあれ?と思ったところを解説していきます。
基本的な設定は前回と一緒ですが、Next.js ではなく TypeScript を直接動かしていきます。
各バージョンは以下の通りです。
- TypeScript v5
- TypeORM v0.3.20
- Node.js v22.11.0
- MySQL v8.3
プロジェクトの準備
まずプロジェクトを作成します。
mkdir typeorm_test && cd $_
npm init
続けて必要なモジュールを追加します。
npm install typeorm reflect-metadata mysql dotenv ts-node tsx typescript @types/node
設定ファイル
各設定ファイルを作成します。
DATABASE_TYPE=mysql
DATABASE_HOST=localhost
DATABASE_PORT=3306
DATABASE_USERNAME=test_user
DATABASE_PASSWORD=hogehoge
DATABASE_NAME=test_db2
マイグレーションをあたらしくやり直すので、DB(DATABASE_NAME)は新しいものを用意してください。
tsconfig.json
は前回のものをコピーしてください。
package.json
も前回と同じように編集します。
DB まわりの設定
Entity を作成します。
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { Table2 } from './Table2.js';
@Entity('table1')
export class Table1 {
@PrimaryGeneratedColumn()
id!: number;
@Column({
type: 'int',
})
column1!: number;
@OneToMany(() => Table2, (table2) => table2.table1, {
createForeignKeyConstraints: false,
persistence: false,
})
table2!: Table2[];
}
import {
Column,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Table1 } from './Table1.js';
import { Table3 } from './Table3.js';
@Entity('table2')
export class Table2 {
@PrimaryGeneratedColumn()
id!: number;
@Column({
type: 'int',
})
table1_id!: number;
@Column({
type: 'int',
})
column2!: number;
@ManyToOne(() => Table1, (table1) => table1.table2, {
createForeignKeyConstraints: false,
persistence: false,
})
@JoinColumn({ name: 'table1_id', referencedColumnName: 'id' })
table1!: Table1;
@OneToMany(() => Table3, (table3) => table3.table2, {
createForeignKeyConstraints: false,
persistence: false,
})
table3!: Table3[];
}
import {
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Table2 } from './Table2.js';
@Entity('table3')
export class Table3 {
@PrimaryGeneratedColumn()
id!: number;
@Column({
type: 'int',
})
table2_id!: number;
@Column({
type: 'int',
})
column3!: number;
@ManyToOne(() => Table2, (table2) => table2.table3, {
createForeignKeyConstraints: false,
persistence: false,
})
@JoinColumn({ name: 'table2_id', referencedColumnName: 'id' })
table2!: Table2;
}
datasource の作成を行います。
getOptions.ts
、migrationDataSource.ts
は前回と同じになります。
import { DataSource, DataSourceOptions } from 'typeorm';
import dotenv from 'dotenv';
import getOptions from './getOptions';
import { Table1 } from '../entities/Table1';
import { Table2 } from '../entities/Table2';
import { Table3 } from '../entities/Table3';
dotenv.config();
let options = getOptions();
export default async function executionDataSource() {
const datasource = new DataSource({
...options,
entities: [Table1, Table2, Table3],
});
if (!datasource.isInitialized) {
await datasource.initialize();
}
return datasource;
}
マイグレーションの作成と適用を行います。
npm run migration:generate --name=InitialSchema
npm run migration:run
シーダーは以下のようにします。
import { exit } from 'process';
import datasource from '../datasources/migrationDataSource';
import { Table1 } from '../entities/Table1';
import { EntityManager } from 'typeorm';
const data = [
{ id: 1, column1: 10 },
{ id: 2, column1: 20 },
];
(async () => {
if (!datasource.isInitialized) {
await datasource.initialize();
}
await datasource.transaction(async (entityManager: EntityManager) => {
const repo = entityManager.getRepository(Table1);
await repo
.createQueryBuilder()
.insert()
.into(Table1)
.values(data)
.execute();
});
exit(0);
})();
import { exit } from 'process';
import datasource from '../datasources/migrationDataSource';
import { Table2 } from '../entities/Table2';
import { EntityManager } from 'typeorm';
const data = [
{ id: 1, table1_id: 1, column2: 10 },
{ id: 2, table1_id: 1, column2: 20 },
{ id: 3, table1_id: 2, column2: 30 },
{ id: 4, table1_id: 2, column2: 40 },
];
(async () => {
if (!datasource.isInitialized) {
await datasource.initialize();
}
await datasource.transaction(async (entityManager: EntityManager) => {
const repo = entityManager.getRepository(Table2);
await repo
.createQueryBuilder()
.insert()
.into(Table2)
.values(data)
.execute();
});
exit(0);
})();
import { exit } from 'process';
import datasource from '../datasources/migrationDataSource';
import { Table3 } from '../entities/Table3';
import { EntityManager } from 'typeorm';
const data = [
{ id: 1, table2_id: 1, column3: 10 },
{ id: 2, table2_id: 2, column3: 20 },
{ id: 3, table2_id: 3, column3: 30 },
{ id: 4, table2_id: 4, column3: 40 },
];
(async () => {
if (!datasource.isInitialized) {
await datasource.initialize();
}
await datasource.transaction(async (entityManager: EntityManager) => {
const repo = entityManager.getRepository(Table3);
await repo
.createQueryBuilder()
.insert()
.into(Table3)
.values(data)
.execute();
});
exit(0);
})();
シーダーの適用を行います。
npm run seeder --src=src/models/seeders/01_table1_seeder.ts
npm run seeder --src=src/models/seeders/02_table2_seeder.ts
npm run seeder --src=src/models/seeders/03_table3_seeder.ts
実際の動作
以下のファイルを作成し、「※」の部分にコードを書いていきます。
import executionDataSource from '@/models/datasources/executionDataSource';
import { Table1 } from '@/models/entities/Table1';
import { Table2 } from '@/models/entities/Table2';
import { exit } from 'process';
(async () => {
// ※
exit(0);
})();
実行は以下で行います。
npx tsx src/app/test.ts
JOIN の動作
まず JOIN の動作の確認です。
以下のようなコードを書きます。
const repo = (await executionDataSource()).getRepository(Table1);
const builder = repo
.createQueryBuilder('table1')
.select(['table1.column1', 'table2.column2', 'table3.column3'])
.leftJoin('table1.table2', 'table2')
.leftJoin('table2.table3', 'table3');
const data = await builder.getMany();
for (let datum1 of data) {
let ret = '';
ret += `column1: ${datum1.column1}, `;
if (datum1.table2) {
for (let datum2 of datum1.table2) {
ret += `column2: ${datum2.column2}`;
if (datum2.table3) {
for (let datum3 of datum2.table3) {
ret += `, column3: ${datum3.column3}`;
}
}
ret += ' / ';
}
}
console.log(ret);
}
実行結果は以下のようになります。
column1: 10, column2: 20, column3: 20 / column2: 10, column3: 10 /
column1: 20, column2: 40, column3: 40 / column2: 30, column3: 30 /
続いて以下のように変更します。
- .select(['table1.column1', 'table2.column2', 'table3.column3'])
+ .select(['table1.column1', 'table3.column3'])
実行結果は以下のようになります。
column1: 10,
column1: 20,
Table2 以下のデータが取れていないことがわかります。
TypeORM で getMany(もしくは getOne)をした場合、Entity の型でデータを取ってきますが、孫のリレーションからデータを取得する場合は子のリレーションについて何かしらデータを指定しなければなりません。
以下のように変更します。
- .select(['table1.column1', 'table3.column3'])
+ .select(['table1.column1', 'table2.id', 'table3.column3'])
実行結果は以下のようになります。
column1: 10, column2: undefined, column3: 20 / column2: undefined, column3: 10 /
column1: 20, column2: undefined, column3: 40 / column2: undefined, column3: 30 /
孫のリレーションまでデータが取得できていることがわかります。
子のリレーションのデータは不要なので指定したくないという場合は、getMany ではなく getRawmany(getOne なら getRawOne)を指定することもできます。
- .select(['table1.column1', 'table2.id', 'table3.column3'])
+ .select(['table1.column1', 'table3.column3'])
- const data = await builder.getMany();
+ const data = await builder.getRawMany();
- for (let datum1 of data) {
- let ret = '';
- ret += `column1: ${datum1.column1}, `;
- if (datum1.table2) {
- for (let datum2 of datum1.table2) {
- ret += `column2: ${datum2.column2}`;
- if (datum2.table3) {
- for (let datum3 of datum2.table3) {
- ret += `, column3: ${datum3.column3}`;
- }
- }
- ret += ' / ';
- }
- }
-
- console.log(ret);
- }
+ console.log(data);
結果は以下のようになります。
[
RowDataPacket { table1_column1: 10, table3_column3: 20 },
RowDataPacket { table1_column1: 10, table3_column3: 10 },
RowDataPacket { table1_column1: 20, table3_column3: 40 },
RowDataPacket { table1_column1: 20, table3_column3: 30 }
]
getRawMany の場合 Entity の階層ではなく、通常の SQL の結果と同じように 1 行ずつのデータとして取ってこれます。
集計の動作
続いて集計の動作の確認です。
以下のようなコードを書きます。
const repo = (await executionDataSource()).getRepository(Table2);
const builder = repo
.createQueryBuilder('table2')
.select(['SUM(table2.column2) as sum_column2'])
.groupBy('table2.table1_id');
const data = await builder.getMany();
console.log(data);
結果は以下のようになります。
[]
getMany は Entity 単位でデータを取得しますが、Entity には集計後のカラムの定義が存在しないためデータを取得できません。
集計関数を利用したデータを取得するためには getRawMany を利用します。
- const data = await builder.getMany();
+ const data = await builder.getRawMany();
結果は以下のようになります。
[
RowDataPacket { sum_column2: '30' },
RowDataPacket { sum_column2: '70' }
]
グルーピングをして集計関数を利用しない場合の動作を見てみます。
const repo = (await executionDataSource()).getRepository(Table2);
const builder = repo
.createQueryBuilder('table2')
.select(['table2.table1_id'])
.groupBy('table2.table1_id');
const data = await builder.getMany();
console.log(data);
すると以下のようなエラーが発生します。
グルーピングあるいは集計していないカラムが選択されていると怒られます。
QueryFailedError: ER_WRONG_FIELD_WITH_GROUP: Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'test_db2.table2.id' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by
次のような SQL が発行されていることがわかります。
'SELECT `table2`.`table1_id` AS `table2_table1_id`, `table2`.`id` AS `table2_id` FROM `table2` `table2` GROUP BY `table2`.`table1_id`'
取得するのは「table2.table1_id」だけのはずなのに「table2.id」まで取得しています。
TypeORM ではグルーピングをおこなうと指定したカラム以外に加えて ID(PK?)が勝手に追加される"場合"があります。
getRawMany であればちゃんと取得できます。
- const data = await builder.getMany();
+ const data = await builder.getRawMany();
結果は以下のようになります。
[
RowDataPacket { table2_table1_id: 1 },
RowDataPacket { table2_table1_id: 2 }
]
グルーピングを行う場合は getRawMany を使用する必要があるということになります。
以上のように TypeORM ではその仕様によって意図しない動作が発生する場合があるので、基本的には getMany ではなく getRawMany を使用するのが無難かもしれません。
一緒に働く仲間を募集中です!
リンクラフト株式会社では、組織拡大に伴い積極的な採用活動を行っています。
少しでも興味がある方はぜひご連絡ください。
▽ 会社ホームページ
https://lincraft.co.jp/
▽Instagram
https://www.instagram.com/lincraft.inc/
▽ ご応募はこちらより
https://lincraft.co.jp/recruit
※カジュアル面談も受付中です。ご希望の方は HP のお問い合わせフォームよりご連絡ください。