🛠️

Prisma で シュッ として Atlas で カチッ とするテーブル設計

2023/09/30に公開

はじめに

Prisma を開発支援ツールとして使い Atlas を運用ツールとして使うのはどうだろうという思いつきのネタ記事です。

半日くらいちょろちょろと触ってみたくらいなので情報は不足しているかと思います。ちょっとしたアイデアくらいの認識でお願いします。

なぜこんなことを?
  • Prisma 使ってみよ〜。お、便利じゃん。ドキュメントも読みやすい
  • データはとりあえず論理削除で設計しとこ(アンチパターン?まあ一旦置いておいて。)
  • 論理削除にするけど重複チェックもしたい。制約に条件追加したいな
  • え、Prisma でこれどうやってやるの?ドキュメント読んても書いてなさそうだし ChatGPT に聞いてもできないって言ってる
  • 自前でマイグレーションしてから取り込んでください。か。なるほど
  • そうすれば反映されるのか?反映はされなかった。
  • そういえば Atlas っていうツールもあったよな。
  • 使ってみよ
  • コマンドで定義取り込めるからユニーク制約自前でマイグレーションかけたやつ取り込んでみよ。
  • お、表現できるじゃん。
  • でもちょっと hcl ファイルの見通しが悪いな …
  • ん、ちょっと待てよ。 Prisma と組み合わせて使ってみては … ???

宣言的っていいですよね。

代表的なものでいうと Kubernetes のデリバリーを管理する ArgoCD があるかと思います。
( IaC も宣言的といえるのですかね。Terraform とか。)

我々開発者が完成形を定義してあげればよしなにそうなるように調整してくれます。
これは SQL におけるマイグレーションにおいても活用したいものです。
.up.sql .down.sql ファイルを作成して適応していくのもよいですがドキュメントを整備しておかないとこれを適用するとテーブルってどうなるんだっけ?などとなります。
マイグレーションにおいても宣言的に管理できるのはメリットがあるかと思います。

今回はそんなマイグレーションツールである Prisma と Atlas に出会い
使いやすかったし使いづらかったので組み合わせてみようと思いたったので記事にしてみました。

Prisma とは

https://www.prisma.io/

Node.js 製のツールです。
公式ドキュメントによると次世代の Node.js および TypeScript ORM とのことです。
コード自動生成が魅力的で利用している方が多いかと思いますが今回は開発支援用のマイグレーションツールとして利用します。

Atlas とは

https://atlasgo.io/

Go 製のデータベーススキーマをコードで管理するツールです。
HCL JSON SQL 形式でスキーマ定義が作成できます。

準備

uname -a
Darwin MacBook-Pro-7.local 22.6.0 Darwin Kernel Version 22.6.0: Wed Jul  5 22:22:52 PDT 2023; root:xnu-8796.141.3~6/RELEASE_ARM64_T8103 arm64
docker version
Client:
 Cloud integration: v1.0.35-desktop+001
 Version:           24.0.5
 API version:       1.43
 Go version:        go1.20.6
 Git commit:        ced0996
 Built:             Fri Jul 21 20:32:30 2023
 OS/Arch:           darwin/arm64
 Context:           desktop-linux

Server: Docker Desktop 4.22.1 (118664)
 Engine:
  Version:          24.0.5
  API version:      1.43 (minimum version 1.12)
  Go version:       go1.20.6
  Git commit:       a61e2b4
  Built:            Fri Jul 21 20:35:38 2023
  OS/Arch:          linux/arm64
  Experimental:     false
 containerd:
  Version:          1.6.21
  GitCommit:        3dce8eb055cbb6872793272b4f20ed16117344f8
 runc:
  Version:          1.1.7
  GitCommit:        v1.1.7-0-g860f061
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

Prisma は Bun を利用してインストールします。

curl -fsSL https://bun.sh/install | bash
bun install prisma
bun run prisma init
bun --version
1.0.3
prisma version
prisma                  : 5.3.1
@prisma/client          : Not found
Current platform        : darwin-arm64
Query Engine (Node-API) : libquery-engine 61e140623197a131c2a6189271ffee05a7aa9a59 (at node_modules/@prisma/engines/libquery_engine-darwin-arm64.dylib.node)
Schema Engine           : schema-engine-cli 61e140623197a131c2a6189271ffee05a7aa9a59 (at node_modules/@prisma/engines/schema-engine-darwin-arm64)
Schema Wasm             : @prisma/prisma-schema-wasm 5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59
Default Engines Hash    : 61e140623197a131c2a6189271ffee05a7aa9a59
Studio                  : 0.494.0

Atras は以下のコマンドでインストールします。

curl -sSf https://atlasgo.sh | sh
atlas version
atlas version v0.14.2-75e99bd-canary
https://github.com/ariga/atlas/releases/latest

Prisma 所感

  • 公式ドキュメントが見やすい
  • shema.prisma の定義が簡単
  • Prisma Studio というアプリが使える

https://www.prisma.io/studio

  • VSCode のプラグインが用意されている

https://marketplace.visualstudio.com/items?itemName=Prisma.prisma

  • プラグインを使うと補完が効くのでコーディングしやすい
  • index をよしなにマイグレーションしてくれるので命名をしなくてよい

Atlsa 所感

  • Prisma で宣言できなかった条件付きのユニーク制約が定義できる
  • Terraform を使っているのでちょっと馴染みがある
  • 本番適用とかのガイドがいろいろ整っていそう

https://atlasgo.io/guides

  • schema.hclschema.prisma と比べると見通しが悪い

使い方

  1. schema.prisma ファイルを編集する
  2. prisma db push にてマイグレーションを実行する
  3. atlas schema inspect を実行し schema.hcl に反映させる
  4. schema.hcl を調整しPrisma で定義できないものをこちらで追加する
  5. atlas schema apply にてマイグレーションを実行する
  6. prisma db pull にて schema.prisma を調整する

いざ実践

今回試しに作ってみたテーブルは以下のようなものとなります。
アプリケーション側でもろもろ担保する想定で割と緩めに定義してます。
アカウントを復活させる想定で論理削除としています。
(このテーブル定義が適切かどうかの議論は一旦置いておいてください。)

0. schema.prisma ファイルを用意する

prisma init コマンドにて schema.prisma ファイルを用意します。
今回はコード自動生成は利用しないので generator は削除します。

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

- generator client {
-   provider = "prisma-client-js"
- }

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

また、 .env ファイルにデータベースへの接続情報を記載します。
今回は Docker にて PostgreSQL コンテナを用意しています。

DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable

1. schema.prisma ファイルを編集する

テーブル定義を schema.prisma に反映します。

schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model accounts {
  id         String     @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  created_at DateTime   @default(now()) @db.Timestamptz(3)
  updated_at DateTime   @default(now()) @db.Timestamptz(3)
  deleted    Boolean    @default(false)
  emails     emails?
  passwords  passwords?
  profiles   profiles?
}

model passwords {
  id         String   @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  value      Bytes
  created_at DateTime @default(now()) @db.Timestamptz(3)
  updated_at DateTime @default(now()) @db.Timestamptz(3)
  account_id String   @unique @db.Uuid
  account    accounts @relation(fields: [account_id], references: [id])

  @@index([account_id])
  @@index([value])
}

model emails {
  id         String   @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  value      String   @unique
  created_at DateTime @default(now()) @db.Timestamptz(3)
  updated_at DateTime @default(now()) @db.Timestamptz(3)
  deleted    Boolean  @default(false)
  account_id String   @unique @db.Uuid
  account    accounts @relation(fields: [account_id], references: [id])

  @@index([account_id])
  @@index([value])
}

model profiles {
  id          String   @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
  first_name  String
  family_name String
  created_at  DateTime @default(now()) @db.Timestamptz(3)
  updated_at  DateTime @default(now()) @db.Timestamptz(3)
  account_id  String   @unique @db.Uuid
  account     accounts @relation(fields: [account_id], references: [id])

  @@index([account_id])
}

2. prisma db push にてマイグレーションを実行する

prisma db push
Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "postgres", schema "public" at "localhost:5432"

🚀  Your database is now in sync with your Prisma schema. Done in 119ms
postgres=# \d
           List of relations
 Schema |   Name    | Type  |  Owner   
--------+-----------+-------+----------
 public | accounts  | table | postgres
 public | emails    | table | postgres
 public | passwords | table | postgres
 public | profiles  | table | postgres
(4 rows)

3. atlas schema inspect を実行し schema.hcl に反映させる

以下のファイルが生成されます。

schema.hcl
table "accounts" {
  schema = schema.public
  column "id" {
    null    = false
    type    = uuid
    default = sql("gen_random_uuid()")
  }
  column "created_at" {
    null    = false
    type    = timestamptz(3)
    default = sql("CURRENT_TIMESTAMP")
  }
  column "updated_at" {
    null    = false
    type    = timestamptz(3)
    default = sql("CURRENT_TIMESTAMP")
  }
  column "deleted" {
    null    = false
    type    = boolean
    default = false
  }
  primary_key {
    columns = [column.id]
  }
}
table "emails" {
  schema = schema.public
  column "id" {
    null    = false
    type    = uuid
    default = sql("gen_random_uuid()")
  }
  column "value" {
    null = false
    type = text
  }
  column "created_at" {
    null    = false
    type    = timestamptz(3)
    default = sql("CURRENT_TIMESTAMP")
  }
  column "updated_at" {
    null    = false
    type    = timestamptz(3)
    default = sql("CURRENT_TIMESTAMP")
  }
  column "deleted" {
    null    = false
    type    = boolean
    default = false
  }
  column "account_id" {
    null = false
    type = uuid
  }
  primary_key {
    columns = [column.id]
  }
  foreign_key "emails_account_id_fkey" {
    columns     = [column.account_id]
    ref_columns = [table.accounts.column.id]
    on_update   = CASCADE
    on_delete   = RESTRICT
  }
  index "emails_account_id_idx" {
    columns = [column.account_id]
  }
  index "emails_account_id_key" {
    unique  = true
    columns = [column.account_id]
  }
  index "emails_value_idx" {
    columns = [column.value]
  }
  index "emails_value_key" {
    unique  = true
    columns = [column.value]
  }
}
table "passwords" {
  schema = schema.public
  column "id" {
    null    = false
    type    = uuid
    default = sql("gen_random_uuid()")
  }
  column "value" {
    null = false
    type = bytea
  }
  column "created_at" {
    null    = false
    type    = timestamptz(3)
    default = sql("CURRENT_TIMESTAMP")
  }
  column "updated_at" {
    null    = false
    type    = timestamptz(3)
    default = sql("CURRENT_TIMESTAMP")
  }
  column "account_id" {
    null = false
    type = uuid
  }
  primary_key {
    columns = [column.id]
  }
  foreign_key "passwords_account_id_fkey" {
    columns     = [column.account_id]
    ref_columns = [table.accounts.column.id]
    on_update   = CASCADE
    on_delete   = RESTRICT
  }
  index "passwords_account_id_idx" {
    columns = [column.account_id]
  }
  index "passwords_account_id_key" {
    unique  = true
    columns = [column.account_id]
  }
  index "passwords_value_idx" {
    columns = [column.value]
  }
}
table "profiles" {
  schema = schema.public
  column "id" {
    null    = false
    type    = uuid
    default = sql("gen_random_uuid()")
  }
  column "first_name" {
    null = false
    type = text
  }
  column "family_name" {
    null = false
    type = text
  }
  column "created_at" {
    null    = false
    type    = timestamptz(3)
    default = sql("CURRENT_TIMESTAMP")
  }
  column "updated_at" {
    null    = false
    type    = timestamptz(3)
    default = sql("CURRENT_TIMESTAMP")
  }
  column "account_id" {
    null = false
    type = uuid
  }
  primary_key {
    columns = [column.id]
  }
  foreign_key "profiles_account_id_fkey" {
    columns     = [column.account_id]
    ref_columns = [table.accounts.column.id]
    on_update   = CASCADE
    on_delete   = RESTRICT
  }
  index "profiles_account_id_idx" {
    columns = [column.account_id]
  }
  index "profiles_account_id_key" {
    unique  = true
    columns = [column.account_id]
  }
}
schema "public" {
  comment = "standard public schema"
}

4. schema.hcl を調整し Prisma で定義できないものをこちらで追加する

このままだと論理削除したアカウントが持つメアドを再登録できないので emails に定義を追加します。
このとき index 名は prisma が生成したものを参考にしています。

  index "emails_value_key" {
    unique  = true
    columns = [column.value]
  }
+   index "emails_value_not_deleted_idx" {
+     unique  = true
+     columns = [column.value]
+     where   = "(NOT deleted)"
+   }
}

5. atlas schema apply にてマイグレーションを実行する

-- Planned Changes:
-- Create index "emails_value_not_is_deleted_idx" to table: "emails"
CREATE UNIQUE INDEX "emails_value_not_deleted_idx" ON "public"."emails" ("value") WHERE (NOT deleted);
✔ Apply

6. prisma db pull にて schema.prisma を調整する

Atlas 側のマイグレーションが Prisma 側で問題ないことを確認するためにも実行します。

Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "postgres", schema "public" at "localhost:5432"

✔ Introspected 4 models and wrote them into prisma/schema.prisma in 98ms
      
Run prisma generate to generate Prisma Client.

※ 2.5 ( Prisma で定義できないマイグレーションを自前SQLにて実行する)

schema.hcl ファイルを編集するのではなく自前で SQL を作成してマイグレーションを実行して そのあと Atlas 側で取り込んで整合性をとることも可能です。

CREATE UNIQUE INDEX "emails_value_not_deleted_idx" ON "public"."emails" ("value") WHERE (NOT deleted);

結局ドキュメントは欲しい

schema.prisma が見やすいとはいえやっぱりドキュメントは欲しいです。
個人的にお気に入りのこちらのツールを用いて自動生成します。

https://github.com/k1LoW/tbls

差分の管理

schema.prisma は開発支援ツールとして扱いますが整合性は担保しておきたいです。
そこで CI にて prisma db pull を実行して差分があればCIが落ちるような設定を追加しておきます。

https://github.com/otakakot/prismatlas/blob/main/.github/workflows/check.diff.yaml

おわりに

なんかいい感じにできそうじゃないでしょうか。
私の調査不足で Prisma が条件付き制約に対応しているのであればこちらの記事は無となります。
… そうですよね。2つのツールを意識していかないといけないです。
そして Atlas もどこまでできるのか全く深掘りできていないです。
実運用で使うかというとまだまだ調査が必要そうです。
結局ドキュメントは整備するのだし、実運用は黙って up down で管理ですかね。

今回作ったリポジトリはこちらに置いておきます。

https://github.com/otakakot/prismatlas

各種コマンドなどを Makefile で管理しているので気になった方は覗いてみてください。

Discussion