はじめに
この記事には
- 主観的な歪んだ美しさ
- 汚いコード
- 単体テストをしない
- それどころかテスト自体しない
- 時間のなさを理由に適当に書いたもの
が含まれます。
またD1を使う前提でコードを書いています。
また美しさと使いやすさは別であり、他のORMを批判する意図はありません
こんにちは、スクーリング中の10分休憩や昼休みにこれを書いてるS高3期生です
突然ですが、みなさん、ORM使いますか?
僕は使いません
昔は使ってたのですが、速度の遅さやJOINなどを使用した複雑なSQLが書きにくいことから最近は使っていません。
そのため長らく下のようなコードを書いていました
const result = await env.DB.prepare(`SELECT id, content FROM article`).bind().first()
ですがそのコードは美しくありません、文字列があります
下記のようにしたらどうでしょう
const result = await env.DB.prepare(Schema.GetArticles).bind().first()
上のコードで一応の美しさや再利用性が増し、管理がしやすくなりましたが、まだ美しくはありません
仕方がありません、上のような単純なSQLにはORMを使うことにしましょう。
美しいORMを求めて
ORMにもさまざまな種類がありますがdrizzle-orm/d1
を試してみましょう
const article = sqliteTable('article', {
id: integer('id').primaryKey(),
content: text('content')
})
const result = await db.select().from(article).all();
上のコードは美しいでしょうか、人によってはYes
でしょうが、僕にはNo
でした
テーブルの定義にテーブル名を明示的に入れる必要があり、カラムにはid: integer("id")
のようにidと明示的に名前を入れる必要があります
それは多くのORMがそうでしょう
やはり自分が美しいと思うORMは自分で作るしかないのでしょうか
ORMを作ってみる
import { Database, Table, id, text } from "./sql";
import { Database as DatabaseCore } from "bun:sqlite";
const database = new DatabaseCore("db.sqlite");
const user = Table({
id: id(),
email: text(),
password: text()
})
const article = Table({
like_id: id(),
user_id: user.id,
content: text(),
})
const db = Database({
user,
article,
}, {
execute: async (query, args)=> {
const statement = database.prepare(query)
return await statement.all(args)
}
})
db.user.insert({
email: "user@example.com",
password: "password",
})
console.log(await db.user.get(1))
console.log(await db.user.all())
上が今回作ったORMです
まだinsertとget, allしか使えませんがどうでしょう
個人的にはテーブル名やカラム名を明示的に指定する必要がない点が気に入っています
これはDatabase Class -> Table Class -> Column Class
という構造にして、Database Class
からTable Class
に値を渡すことでTable Class
がsqlの実行や、自分のテーブル名の取得ができるようにしています
また、本来ならarticle
のcontent
の取得をdbからしようと思ったらdb.tables.article.table.content
などのようになりますが、Proxy
を使うことでdb.article.content
になるようにしています
type DatabaseClassProxyResult<T> = { [key in keyof T]: T[key]; } & DatabaseClass<T>
export const DatabaseClassProxy = <T>(tables: DatabaseClass<T>): DatabaseClassProxyResult<T> => {
return new Proxy(tables, {
get(target, prop, receiver) {
if(prop in tables.tables) {
return Reflect.get(target.tables, prop, receiver);
}
return Reflect.get(target, prop, receiver);
},
}) as any
}
Proxy
を使うとx["a"]
のような時にget
関数が呼び出され、引数のtarget
にx
がprop
に"a"
が入ります
それを使って、db.tables.article
を短縮するため、db
にProxy
を適応して擬似的に{ [key in keyof T]: T[key]; } & DatabaseClass
を作り出しています
即興で作ったものですが、一応動くはずです
そしてtype safety
です
TODO APPを作ってみる
では早速テストも兼ねてTODOを作ってみましょう
今回はormがメインなのでフレームワークについては触れません、お好きなフレームワークを選択してください
ちなみに僕は今回自作のフレームワークを使っています
テーブルはtodoテーブルだけ作ります
const todo = Table({
id: id(),
content: text(),
create_at: text()
})
今はまだsyncなどのテーブルを自動で作成する機能はないので、事前に作成しています
また、まだテーブルの型にあまり意味はありません、そのためcreate_atの型がtextになっています
簡単ですね?
次にテーブルをdbに登録します
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const db = Database({
todo
}, {
async execute(query, args) {
return (await env.DB.prepare(query).bind(...args).all()).results
},
})
},
};
Database
関数の二つ目の引数でexecute
などの関数を渡しているのでplanetscale
やd1
などのdbに対応できるようにしています
あとは適当にCRUDのCとRを作っていきます
await api.post("/api/v1/todo", json<postHandlerArgs<{content: string}>>(async ({body})=> {
await db.todo.insert({
content: body.content
})
return {msg: "success"}
}))
await api.get("/api/v1/todo", json<getHandlerArgs<{}>>(async ()=> {
const res = await db.todo.all();
return {msg: "success", todo: res}
}))
await api.get("/api/v1/todo/:id", json<getHandlerArgs<{id: string}>>(async ({args})=> {
const res = await db.todo.get(args.id);
return {msg: "success", todo: res}
}))
良さそうですね
コードの全体像は以下のような形になっています
import { Database, Table, id, text } from "./sql";
import { getHeaders } from "./utils/header";
import { json } from "./utils/response";
import { Env, WorkerRoute, getHandlerArgs, postHandlerArgs } from "./worker-route";
const todo = Table({
id: id(),
content: text(),
create_at: text()
})
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const api = new WorkerRoute(request, env, ctx);
const db = Database({
todo
}, {
async execute(query, args) {
return (await env.DB.prepare(query).bind(...args).all()).results
},
})
await api.any("/**", async ({api})=> {
await api.getBody();
})
await api.get("/api/v1/todo", json<getHandlerArgs<{}>>(async ()=> {
const res = await db.todo.all();
return {msg: "success", todo: res}
}))
await api.post("/api/v1/todo", json<postHandlerArgs<{content: string}>>(async ({body})=> {
await db.todo.insert({
content: body.content
})
return {msg: "success"}
}))
await api.get("/api/v1/todo/:id", json<getHandlerArgs<{id: string}>>(async ({args})=> {
const res = await db.todo.get(args.id);
return {msg: "success", todo: res}
}))
api.default((body, res) => {
if (res.method === "OPTIONS")
return new Response("OK", getHeaders(request, { status: 200 }))
return new Response("Not Found.", getHeaders(request, { status: 404 }))
})
return api.final()
},
};
意外とすっきりしていていい感じなのでは?と個人的には思っています
終わりに
sqlの実行用に引数としてexecute関数を渡していますが、そうすることによって、様々な実行環境に対応できるのが個人的なお気に入りポイントでもあります
Bunの場合はこれ
async (query, args) {
const statement = database.prepare(query)
return await statement.all(args)
}
D1の場合はこれ
async (query, args) {
return (await env.DB.prepare(query).bind(...args).all()).results
}
と対応できます
数時間で作ったORMにしてはそこそこのものができたなという印象です
type safety
にできたのが個人的にはよかったです
タイプセーフなので補完が効くのが地味に便利です、SQL直書きだとシンタックスハイライトは使えたりしますが、補完が効かないので、そういう意味ではORMのほうが開発体験は良さそうです
下、今回作ったORMのコード