LoginSignup
3
1

Typescriptで美しいORMを作りたい

Last updated at Posted at 2023-12-23

はじめに

この記事には

  • 主観的な歪んだ美しさ
  • 汚いコード
  • 単体テストをしない
  • それどころかテスト自体しない
  • 時間のなさを理由に適当に書いたもの

が含まれます。

また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の実行や、自分のテーブル名の取得ができるようにしています

また、本来ならarticlecontentの取得を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関数が呼び出され、引数のtargetxprop"a"が入ります
それを使って、db.tables.articleを短縮するため、dbProxyを適応して擬似的に{ [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などの関数を渡しているのでplanetscaled1などの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のコード

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1