ORMって一体なんなの?
はじめに
サンプルのコードにはTypeScriptを用いていますが、その他の言語でも内容については大きく変わりません 関係データベースでは、データベースに存在する任意のテーブル(リレーション)に対して、SQLを使って柔軟に操作を行うことができます。 code:sql
SELECT id, ordered_at FROM orders;
また、関係データベースでは、データを重複なくきちんと整合性を持って管理するために、正規化を行って、異なるテーブルに分割して管理することが特徴です。 例えば、以下は正規化されていない例です。過去の注文内容がordersという単一のテーブルで管理されています。
table:orders
id ordered_at item_id quantity price name
1 2024-04-02 1 2 100 商品A
1 2024-04-02 2 1 150 商品B
2 2024-05-03 1 2 100 商品A
2024-04-02と2024-05-03という2つの注文データがあります。もしitem_idが1の商品名を変更しようとした場合、item_id = 1のすべてのレコードのnameを更新する必要があります。
しかし、もしアプリケーションの実装や操作などでミスがあった場合、更新漏れが起きてしまう可能性があります。
これを防ぐためには、テーブルの正規化を行う必要があります。例えば、以下はテーブルの分割例です。(※以下の例ではorder_itemsのpriceがまだ重複していますが、実際には注文時点での料金を管理するテーブルを設けて管理した方がよいと思います)
table:orders
id ordered_at
1 2024-04-02
2 2024-05-03
table:order_items
id order_id item_id quantity price
1 1 1 2 100
2 1 2 1 150
3 2 1 2 100
table:items
id name
1 商品A
2 商品B
これらの関連する各テーブルはJOINをすることでまとめて取得することができます
code:sql
SELECT
o.id AS id,
o.ordered_at AS ordered_at,
i.name AS item_name,
oi.quantity * oi.price AS amount
FROM
orders AS o
INNER JOIN order_items AS oi ON o.id = oi.order_id
INNER JOIN items AS i ON i.id = oi.item_id
WHERE
o.id = 1;
まとめると、以下のような特徴があります
特定のテーブルの任意のカラムに対して、SQLを介して自由に抽出や計算などを行うことができる 重複を省き一貫性を維持してデータを管理するため、テーブルを正規化してデータを管理する
code:typescript
export class Order {
readonly id: OrderID;
// privateで宣言する (実装の詳細は隠蔽する)
// これがたとえ配列ではなくMap<OrderID, OrderItem>などに変わったとしても、外部からは直接操作することはできないので、変更による影響を抑えられます
private constructor(id: OrderID, items: Array<OrderItem>) {
this.id = id;
this.#items = items;
}
static create(id: OrderID, items: Array<OrderItem>) {
return new Order(id, items);
}
// CQSに従い、クエリメソッドとコマンドメソッドを分離する...
// クエリメソッド
sumOfAmount() {
// * たとえ#itemsの実装がArray<OrderItem>からMap<OrderId, OrderItem>などに変わったとしても外部には影響がない
// * 値の計算が必要な際は、そのデータを管理するオブジェクト自身に任せる
// * クエリメソッドには副作用を持たせない
return this.#items.reduce((sum, x) => sum.plus(x.amount()), new Money(0));
}
// 副作用はコマンドメソッドに隠蔽します
// 「契約による設計」という考えにおいては「不変条件」というものがあります
// これは、あるオブジェクトが生成されてから破棄されるまでの間、常に満たし続けていなければならない状態のことです
// メソッドによってオブジェクトの操作を隠蔽することで、外部からオブジェクトの状態が不用意に更新されてしまうことを防止できます
// これにより、不変条件が意図せずして破られてしまうことを防止でき、バグの発生を抑えられます
changeQuantity(orderItemID: OrderItemID, newQuantity: number) {
const item = this.#items.find((x) => x.id.equals(orderItemID));
assert(item);
item.changeQuantity(newQuantity);
}
}
まず、関係データベースにおけるデータの管理方法との大きな違いとして、注文の明細情報がOrderクラスのプロパティによって保持されています。正規化を行い、別々のリレーションで管理する関係データベースとはデータの管理方法が大きく異なります。 code:typescript
また、このitemsプロパティはプライベートで宣言されており、外部からはアクセスや操作をすることはできません。関係データベースでは任意のテーブルの任意のカラムに対してSQLで柔軟に操作をすることができましたが、逆にオブジェクト指向においては、こういった詳細は外部には隠蔽されます。 その代わり、オブジェクト指向においては、外部にはメソッドを公開し、それを介してのみ操作を許可します。例えば、以下は注文の合計金額を求めるためのメソッドの例です。 code:typescript
sumOfAmount() {
return this.#items.reduce((sum, x) => sum.plus(x.amount()), new Money(0));
}
どうしてこのように詳細を隠蔽し、操作を制限するのでしょうか?以下のようにプロパティを公開して、関係データベースにおけるSQLのようにユーザーに対して自由に操作を許可したほうが、一見は使いやすそうに思えます code:typescript
const sumOfAmount = order.items.reduce((sum, x) => sum.plus(new Money(x.price).multiply(x.quantity))), new Money(0));
まず、このように詳細を隠蔽することによるメリットとして、このオブジェクトに変更を行った際の外部への影響を抑えることができるという点が考えられそうです
例えば、Orderクラスにおける明細の管理をArrayからMapに変えたとします。
code:typescript
readonly itemByID: Map<OrderID, OrderItem>;
こうした場合、もしOrderクラスの詳細が外部に公開され自由に許可されていたとした場合、Orderクラスの内部で明細がArrayで管理されていることに依存していた外部のコード全てに影響が発生してしまいます
例えば、先程の注文の合計金額を求めるコードは以下のように修正する必要があります
code:typescript
const sumOfAmount = Array.from(order.itemByID.values()).reduce((sum, x) => sum.plus(new Money(x.price).multiply(x.quantity))), new Moeny(0));
以下のように、このような詳細がOrderクラスの内部に隠蔽されていれば、このような問題は回避することができます。
code:typescript
sumOfAmount() {
return this.#items.reduce((sum, x) => sum.plus(x.amount()), new Moeny(0));
}
たとえ注文明細の管理がArrayからMapに変わったとしても、その影響はOrderクラスの内部のみに留まり、Orderクラスの外部へは影響がありません。
code:typescript
sumOfAmount() {
return Object.values(this.#itemByID).reduce((sum, x) => sum.plus(x.amount()), new Moeny(0));
}
それ以外にも、このように詳細をオブジェクトの内部に隠蔽することには、意図せぬ不整合を防止することができるメリットもあります。
例えば、ある商品の注文数量を変更したいケースがあったとします。
以下は詳細を隠蔽せずにオブジェクトの利用者に対して自由に操作を許可した場合の例です。
code:typescript
let order: Order;
order.items0.quantity = 150; 最初はこの方法でもうまく動くかもしれません。
しかし、後になって、「ある商品を一度に注文可能な数量は100まで」という制限がアプリケーションに加わったとします。この場合、上記の自由に操作を許可する例の場合、数量を変更している全てのコードをチェックして修正をする必要があります。
契約による設計の考えにおいては、こういったあるオブジェクトが生成されてから破棄されるまでの間に常に満たし続けなければならない状態や条件のことを不変条件と呼びます。オブジェクトの詳細が外部に公開されていることによる問題は、オブジェクトの不変条件を維持することを仕組みとして強制することができないことにあると思います。あるオブジェクトの詳細を外部から隠蔽し、状態に関する操作をそのオブジェクトが持つメソッドのみに限定させることで、不変条件が意図せずして破られることを防止でき、結果としてバグの発生などを防止することができます。 また、この方法には以下のように料金や数量を自由に変更ができてしまうリスクもあります。
code:typescript
let order: Order;
// ...
order.items0.quantity = 50000; order.items0.price = new Money(1); もしこういった操作がオブジェクトの内部に隠蔽されていれば、そのオブジェクトの実装のみを修正するだけで済みます。
例えば、以下の場合、OrderItemクラスのchangeQuantityメソッドの内部に変更を加えるだけで対応ができます。
code:typescript
changeQuantity(orderItemID: OrderItemID, newQuantity: number) {
const item = this.#items.find((x) => x.id.equals(orderItemID));
assert(item);
item.changeQuantity(newQuantity);
}
code:typescript
export class OrderItem {
changeQuantity(newQuantity: number) {
if (newQuantity > 100) {
throw new UnacceptableQuantityError();
}
this.#quantity = newQuantity;
}
amount() {
return this.#price.multiply(this.#quantity);
}
}
このように、オブジェクト指向(オブジェクトモデル)においては関係データベース(リレーショナルモデル)とは異なり、外部からの操作などを意図的に制限します。あるデータの操作をそのデータを持つオブジェクト自身にのみ限定させることで、外部への変更の影響を抑えたり、意図せずして不変条件が破られることによる不整合が起きないような仕組みが実現されています。 例えば、以下のように振る舞いを持たないシンプルなデータ構造(いわゆるDTO)に対してマッピングをしたいだけであれば、ORMの必要性は低いでしょう。 code:typescript
// データを保持するのみで、特に振る舞いを持たない
export interface Order {
id: string;
orderedAt: Date;
}
export interface OrderItem {
id: string;
itemID: string;
quantity: number;
price: number;
itemName: string;
}
ライブラリによってはこういった構造のデータをそのまま返してくれるものもあると思います。
code:typescript
const ordersResult = await db.query('SELECT * FROM orders WHERE id = 1 LIMIT 1');
const order: Order = ordersResult.rows0; // データベースドライバーが問い合わせ結果をオブジェクトとしてそのまま返してくれる const orderItemsResult = await db.query(`
SELECT
oi.id AS id,
oi.quantity AS quantity,
oi.price AS price,
oi.item_id AS itemID,
i.name AS itemName
FROM order_items AS oi INNER JOIN items AS i ON oi.item_id = i.id WHERE oi.order_id = $1
const orderItems: Array<OrderItem> = orderItemsResult.rows;
code:typescript
const ordersResult = await db.query('SELECT * FROM orders WHERE id = 1 LIMIT 1');
const orderItemsResult = await db.query(`
SELECT
oi.id AS id,
oi.quantity AS quantity,
oi.price AS price,
oi.item_id AS itemID,
i.name AS itemName
FROM order_items AS oi INNER JOIN items AS i ON oi.item_id = i.id WHERE oi.order_id = $1
`, [orderResult.rows0.id]); // 複雑なマッピング処理が必要...
const order = Order.create(
new OrderID(ordersResult.rows0.id), orderItemsResult.rows.map((x) => {
return OrderItem.create(
new OrderItemID(x.id),
x.quantity,
new Money(x.price),
Item.create(x.itemID, x.itemName)
);
})
);
ORMはこういった課題などを解消することを目的としています。 ORMが問い合わせ結果をオブジェクトへマッピングしたり、オブジェクトの永続化を簡素化してくれることにより、手間を大きく省くことができます。 code:typescript
const order = await orm.find(Order, 1); // データベースへ問い合わせを行い、その結果をOrderオブジェクトにマッピングしてもらう
assert(order instanceof Order);
order.changeQuantity(orderID, 5); // ORMがオブジェクトへのマッピングの面倒を見てくれるため、利用者は通常通り、オブジェクトの操作に専念できる
await orm.save(order); // ORMが永続化に関して面倒を見てくれる
補足
以下のような機能は多くのORMが提供しており生産性を上げる上ではとても有用ではありますが、どちらかといえばこういった機能はORMとしては副次的な機能と考えられます SQLを書かなくてもデータベースを操作できるようにしてくれる そのため、例えば、以下のようにSQLを直接書くような形であったとしてもORMとしては成り立つと考えられます code:typescript
const creds = await orm.find(UserCredentials, SELECT * FROM user_credentials WHERE user_id = ?, userID);
assert(creds instanceof UserCredentials);
creds.regeneratePassword(passwordGenerator);
await orm.persist(creds, UPDATE user_credentials SET ... WHERE user_id = ?, creds.userID);