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