Deno で試すデータベースアクセス(Turso - Kysely 編)

Truso の動きが活発。
以前は CLI でしか、作ったテーブルを確認できなかったのが、管理画面も増えた。
無料枠も激増した。

そんな Turso は、Kysely をサードパーティの開発者ツールとして公式に記載している。
ここではクエリビルダーとして記載されているが、マイグレーションや ORM 寄りの機能も持っている。
(TypeScript 用のタイプセーフな SQLクエリ ビルダーということで、推論も優秀な様子。)

ということで、Turso(libsql) x Kysely を試してみる。

参考

実装

Kysely を使用したクエリ発行、マイグレーションを概ね Kysely の Installation にしたがって進める。

import

deno.json での import-map は次のように定義。

deno.json
1
2
3
4
5
6
7
8
9
10
11
{
"tasks": {
"dev": "deno run --watch main.ts"
},
"imports": {
"kysely": "npm:kysely@^0.25.0",
"@libsql/kysely-libsql": "npm:@libsql/kysely-libsql",
"std/": "https://deno.land/std@0.203.0/",
"cliffy": "https://deno.land/x/cliffy@v1.0.0-rc.3/command/mod.ts"
}
}

テーブル/型定義

./db/types.ts を作成。

/db/types.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import {
ColumnType,
Generated,
} from "kysely";

export interface Database {
user: userTable;
food: FoodTable;
}

export interface userTable {
id: Generated<number>;
name: string | null;
created_at: ColumnType<Date, string | undefined, never>;
}

export interface FoodTable {
id: Generated<number>;
user_id: number
name: string;
created_at: ColumnType<Date, string | undefined, never>;
}

コネクション作成

DBに接続をするがこれが簡単。

/db/db.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Database } from "./types.ts";
import { Kysely } from "kysely";
import { LibsqlDialect } from "@libsql/kysely-libsql";

const dialect = new LibsqlDialect({
url: Deno.env.get("TURSO_DATABASE_URL"),
authToken: Deno.env.get("TURSO_AUTH_TOKEN"),
});

export const client = new Kysely<Database>({
dialect,
log(event) {
if (event.level === "query") {
console.log("query:", event.query.sql);
console.log("parameters:", event.query.parameters);
}
},
});

urlや、トークンなどは接続先の turso のものを環境変数経由で適用する。

一旦接続確認。

クライアントが作成できたので、一旦クエリ実行してみる。
次のようにする。

main.ts
1
2
3
4
5
6
7
8
9
10
import "https://deno.land/std@0.203.0/dotenv/load.ts";
import { client } from "./db/client.ts";

const users = await client.selectFrom("user").selectAll().execute();
console.log(users);

users.map((u) => {
console.log(u.id);
console.log(u.name);
});

まだマイグレーションはしていないので、一旦手動で turso のDBにテーブル作成しておく実行しておく。

1
2
create table user(id INTEGER PRIMARY KEY, name text);
insert into user(name) values('田中');
1
2
3
4
5
6
$ deno run --allow-net --allow-read --allow-env ./main.ts
query: select * from "user"
parameters: []
[ { id: 1, name: "田中" } ]
1
田中

1 2 行目は、クライアント作成時の log オプションが効いている。

1
2
3
4
5
6
7
8
9
export const client = new Kysely<Database>({
dialect,
log(event) {
if (event.level === "query") {
console.log("query:", event.query.sql);
console.log("parameters:", event.query.parameters);
}
},
});

3 行目以降は、main.ts の記述された出力結果が出ている。
型推論がよく効いていて client.selectFrom("user").selectAll().execute() の実行結果にもよく出ている。

client.selectFrom("user").selectAll().execute() の推論結果
1
2
3
4
5
(method) SelectQueryBuilder<Database, "user", { id: number; name: string | null; created_at: Date; }>.execute(): Promise<{
id: number;
name: string | null;
created_at: Date;
}[]>

推論がよく効くことで候補もしっかりしており、実装も楽。

マイグレーションの実装

マイグレーションは、公式資料とともに、issueを参考に用意した。

https://github.com/kysely-org/kysely/issues/362

公式ドキュメントのサンプルでは、kysely が提供する FileMigrationProvider を使用している。

こちらが path と fs を求める。
これは、node:fs node:path で解決するが実行時エラーになる。

1
2
3
4
5
6
7
8
import { promises as fs } from 'node:fs'
import { path } from "node:path"

const provider = new FileMigrationProvider({
fs,
path,
migrationFolder: path.join(__dirname, './migrations'),
}),

参考にした、issueでは、FileMigrationProvider の代替を独自実装している。
これをそのまま適用したが、上手く動作できず、次のようにした。

/db/file_migration_provider.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { Migration, MigrationProvider } from "kysely";
import { join } from "std/path/mod.ts";
export class FileMigrationProviderOnDeno implements MigrationProvider {
#folder: string;

constructor(folder: string) {
this.#folder = folder;
}

async getMigrations(): Promise<Record<string, Migration>> {
const migrations: Record<string, Migration> = {};
let tmpPath = import.meta.resolve(this.#folder).split("file://")[1];
if (tmpPath.includes(":") && tmpPath.startsWith("/")) {
tmpPath = tmpPath.split(/^\//)[1];
}
const files = await Deno.readDir(tmpPath);

for await (const file of files) {
console.log(file.name);
console.log(join(import.meta.resolve(this.#folder), file.name));
migrations[file.name] = await import(
join(import.meta.resolve(this.#folder), file.name)
);
}

return migrations;
}
}

差異としては、import.meta を使用して、パスの解決した。
windows 環境でも使用可能なように調整した。
windows 10、debian(docker) で動作確認。

CLI ツール化するに当たり、次のようにした。
今回は、cliffy を使ってみた。
deno.land/x で☆3つのCLIフレームワークである。

/db/migrate.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
import "https://deno.land/std@0.203.0/dotenv/load.ts";
import { MigrationResult, Migrator } from "kysely";
import { client } from "./client.ts";
import { FileMigrationProviderOnDeno } from "./file_migration_provider.ts";
import { Command } from "cliffy";

const migrator = new Migrator({
db: client,
provider: new FileMigrationProviderOnDeno("./migrations")
});

const showReslut = async (
results: MigrationResult[] | undefined,
error: unknown
) => {
results?.forEach((it) => {
if (it.status === "Success") {
console.log(`migration "${it.migrationName}" was executed successfully`);
} else if (it.status === "Error") {
console.error(`failed to execute migration "${it.migrationName}"`);
}
});

if (error) {
console.error("failed to run `migrateToLatest`");
console.error(error);
}
};

const doMigrate = async () => {
const { results, error } = await migrator.migrateToLatest();
showReslut(results, error);
};

const doRollback = async () => {
const { results, error } = await migrator.migrateDown();
showReslut(results, error);
};

const getDateTimeString = () => {
const now = new Date();
const year = now.getFullYear();
const month = `${now.getMonth() + 1}`.padStart(2, "0");
const date = `${now.getDate()}`.padStart(2, "0");
const hour = `${now.getHours()}`.padStart(2, "0");
const minute = `${now.getMinutes()}`.padStart(2, "0");
const second = `${now.getSeconds()}`.padStart(2, "0");
return `${year}${month}${date}${hour}${minute}${second}`;
};

const doCreateMigration = async (fileName: string) => {
const kyselySkeletonText = `import { Kysely, sql } from "npm:kysely@^0.25.0";

export async function up(db: Kysely<any>): Promise<void> {
}

export async function down(db: Kysely<any>): Promise<void> {
}
`;

const path = import.meta
.resolve(`./migrations/${getDateTimeString()}${fileName}.ts`)
.split("file:///")[1];
Deno.writeTextFileSync(path, kyselySkeletonText, { create: true });
console.info(`created migration file: ${path}`);
};

const { cmd, options, args } = await new Command()
.name("kysely migration tool")
.command("migrate", "migrate to latest.")
.action(async () => {
console.log("migrate");
await doMigrate();
Deno.exit(0);
})
.command("rollback", "rollback 1 migration.")
.action(async () => {
await doRollback();
Deno.exit(0);
})
.command("create <name:string> [name:string]", "create migration.")
.action((_options: any, source: string, _destination?: string) => {
doCreateMigration(source);
Deno.exit(0);
})
.stopEarly()
.parse(Deno.args);

cmd.showHelp();

ツールが用意できたので、マイグレーションを進める。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ deno run --allow-env --allow-read --allow-net

Usage: kysely migration tool

Options:

-h, --help - Show this help.

Commands:

migrate - migrate to latest.
rollback - rollback 1 migration.
create <name> [name] - create migration.

$ deno run --allow-env --allow-read --allow-write --allow-net ./db/migrate.ts create create_user
created migration file: hogehoge/db/migrations/20231008035938create_user.ts

ソースを見ての通り、マイグレーションのテンプレートを書き込んでいる。

db/migrations/20231008035938create_user.ts
1
2
3
4
5
6
7
import { Kysely, sql } from "npm:kysely@^0.25.0";

export async function up(db: Kysely<any>): Promise<void> {
}

export async function down(db: Kysely<any>): Promise<void> {
}

これを次のように書き換える。

db/migrations/20231008035938create_user.ts(変更)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Kysely, sql } from "npm:kysely@^0.25.0";

export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable("user")
.addColumn("id", "integer", (col) => col.primaryKey())
.addColumn("name", "text", (col) => col.notNull())
.addColumn(
"created_at",
"text",
(col) => col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull(),
)
.execute();
}

export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable("user").execute();
}

続けてコマンドで、マイグレーション実行。

1
2
$ deno run --allow-env --allow-read --allow-net ./db/migrate.ts migrate
# 以下実行ログが並ぶ

turso 側のテーブルを表示すると次のようになっている。

1
2
3
4
5
6
7
→ .tables
kysely_migration
kysely_migration_lock
user

select * from user;
ID NAME CREATED AT

マイグレーションができた。

insert と select

先に実装した main.ts を書き換える。
CRUD すべて用意した。

main.ts(改修)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import "https://deno.land/std@0.203.0/dotenv/load.ts";
import { client } from "./db/client.ts";

console.log("\n[SELECT]");
console.log("select:",await client.selectFrom("user").selectAll().execute());

console.log("\n[INSERT]");
const insertResult = await client
.insertInto("user")
.values({
name: "山田",
})
.returning(['id', 'name'])
.executeTakeFirstOrThrow();
console.log(insertResult);
console.log("select:", await client.selectFrom("user").selectAll().execute());

console.log("\n[UPDATE]");
const updateResult = await client
.updateTable("user")
.set({
name: "鈴木",
})
.where("id", "=", insertResult.id)
.returning(['id', 'name'])
.execute();
console.log(updateResult);
console.log("select:",await client.selectFrom("user").selectAll().execute());

console.log("\n[DELETE]");
const deleteResult = await client.deleteFrom("user").execute();
console.log(deleteResult);
console.log("select:",await client.selectFrom("user").selectAll().execute());

実行結果は次の通り。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
$ deno run --allow-env --allow-read  --allow-write --allow-net ./main.ts

[SELECT]
query: select * from "user"
parameters: []
select: []

[INSERT]
query: insert into "user" ("name") values (?) returning "id", "name"
parameters: [ "山田" ]
{ id: 1, name: "山田" }
query: select * from "user"
parameters: []
select: [ { id: 1, name: "山田", created_at: "2023-10-07 19:31:31" } ]

[UPDATE]
query: update "user" set "name" = ? where "id" = ? returning "id", "name"
parameters: [ "鈴木", 1 ]
[ { id: 1, name: "鈴木" } ]
query: select * from "user"
parameters: []
select: [ { id: 1, name: "鈴木", created_at: "2023-10-07 19:31:31" } ]

[DELETE]
query: delete from "user"
parameters: []
[ DeleteResult {} ]
query: select * from "user"
parameters: []
select: []

CRUD すべてを確認できた。

リレーションも確認しておく

型だけ定義してある、food テーブルもマイグレーションを用意し、リレーションも確認する。

マイグレーションファイルは次の通り。

db/migrations/20231008043611create_food.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Kysely, sql } from "npm:kysely@^0.25.0";

export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable("food")
.addColumn("id", "integer", (col) => col.primaryKey())
.addColumn("user_id", "integer", (col) => col.notNull())
.addColumn("name", "text", (col) => col.notNull())
.addColumn(
"created_at",
"text",
(col) => col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull(),
)
.execute();
}

export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable("food").execute();
}

改めて main.ts を改修し、foodテーブルにもデータを登録する。

main.ts(改修)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import "https://deno.land/std@0.203.0/dotenv/load.ts";
import { client } from "./db/client.ts";

console.log("\n[SELECT]");
console.log("select:", await client.selectFrom("user").selectAll().execute());
console.log("select:", await client.selectFrom("food").selectAll().execute());

console.log("\n[INSERT]");

const transactionResult = await client.transaction().execute(async (trx) => {
const insertResult = await trx
.insertInto("user")
.values({
name: "山田",
})
.returning(["id"])
.executeTakeFirstOrThrow();

return await trx
.insertInto("food")
.values({
user_id: insertResult.id,
name: "りんご",
})
.returningAll()
.executeTakeFirstOrThrow();
});
console.log(transactionResult);

console.log("\n[SELECT] 単独");
console.log("select:", await client.selectFrom("user").selectAll().execute());
console.log("select:", await client.selectFrom("food").selectAll().execute());

console.log("\n[INSERT] JOIN");
console.log("select:", await client.selectFrom("user").innerJoin('food', 'food.user_id', 'user.id').selectAll().execute());
console.log("select:", await client.selectFrom("user").innerJoin('food as f', 'f.user_id', 'user.id').select(["user.id", "user.name", "f.id as food_id", "f.name as food_name"]).execute());

実行結果は次の通り。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
$ deno run --allow-env --allow-read  --allow-write --allow-net ./main.ts

[SELECT]
query: select * from "user"
parameters: []
select: []
query: select * from "food"
parameters: []
select: []

[INSERT]
query: insert into "user" ("name") values (?) returning "id"
parameters: [ "山田" ]
query: insert into "food" ("user_id", "name") values (?, ?) returning *
parameters: [ 1, "りんご" ]
{ id: 1, user_id: 1, name: "りんご", created_at: "2023-10-07 20:02:48" }

[SELECT] 単独
query: select * from "user"
parameters: []
select: [ { id: 1, name: "山田", created_at: "2023-10-07 20:02:47" } ]
query: select * from "food"
parameters: []
select: [
{ id: 1, user_id: 1, name: "りんご", created_at: "2023-10-07 20:02:48" }
]

[INSERT] JOIN
query: select * from "user" inner join "food" on "food"."user_id" = "user"."id"
parameters: []
select: [
{ id: 1, name: "山田", created_at: "2023-10-07 20:02:47", user_id: 1 }
]
query: select "user"."id", "user"."name", "f"."id" as "food_id", "f"."name" as "food_name" from "user" inner join "food" as "f" on "f"."user_id" = "user"."id"
parameters: []
select: [ { id: 1, name: "山田", food_id: 1, food_name: "りんご" } ]

トランザクション込みで、user から見て子になるfoodへのデータ登録と、select ができるのを確認できる。


Kysely を使って、Turso を Deno からざっくり使ってみた。

以前確認した Drizzle は、マイグレーションが上手く動かなかったりしたが、Kysely は少し自作すれば上手く動く。

今使うなら、Kysely が有力候補になってきそうである。
とても体験が良かった。

では。