Prisma で model を view に変えると何が起きるのか - 二重書き込みの解消を検証する

Prisma で model を view に変えると何が起きるのか - 二重書き込みの解消を検証する

二重書き込みによるデータの食い違いを防ぐため、写し側のテーブルをビューに置き換える方法を検証しました。読み取りコードは変えずに済む一方で、マイグレーションの自動生成や書き込み処理で気をつけるべき点が複数ありましたので、実装例とともにご紹介します。
2026.08.24

はじめに

LINE/アプリ DevOps チームの及川です。

同じ情報を 2 つのテーブルに書いている、という状態に出くわすことがあります。片方が元データで、もう片方はそれを表示用に写したもの。写しがズレないよう、保存処理では両方に書く。いわゆる二重書き込みです。

これは動作しますが、書き忘れれば 2 つが食い違います。「今ズレていないのはコードが正しいから」であって、DB の構造として防げてはいません。

この解消手段として、写し側のテーブルを、同じ名前のビューに置き換えるというやり方があります。ただ、実際にやるとなると分からないことが多くありました。

  • そもそもビューとは何か。テーブルと何が違うのか
  • 名前が同じなら、読み取りのコードは本当に変えなくていいのか
  • Prisma で modelview に変えるだけで済むのか
  • ビューにすると、何ができなくなるのか

そこで、業務とは無関係な「イベントと会場」を題材に、ゼロから検証プロジェクトを組み、手を動かしながら確かめました。この記事はその記録です。

この記事で紹介すること

  • ビューとは何か(テーブルとの違い)
  • Prisma で modelview に変えるときに起きる 3 つの変更
  • 読み取りのコードが無改修で済む理由
  • マイグレーションはどこまで自動生成されるのか
  • ビューにすると何ができなくなるのか
  • インデックスが張れないと検索は遅くなるのか

検証は Prisma 7.9.1 / prisma-kysely 3.1.1 / Kysely 0.28.8 / PostgreSQL 16(Docker)で行いました。掲載しているエラーメッセージ・実行計画・テスト結果は、すべて実行時の出力です。

ビューとは何か

先に用語を整理します。ビューは、あらかじめ書いておいた SELECT 文に名前を付けたものです。

CREATE VIEW "在庫あり商品" AS
SELECT * FROM "商品" WHERE "在庫数" > 0;

こう定義しておくと、以降は SELECT * FROM "在庫あり商品" と書けます。読む側から見ればテーブルと同じように扱えますが、実データは持っていませんPostgreSQL 公式ドキュメントにも記載されています。

CREATE VIEW defines a view of a query. The view is not physically materialized. Instead, the query is run every time the view is referenced in a query.

「実体を持たず、参照されるたびに問い合わせが実行される」ということです。

テーブルとの違いをまとめると次のとおりです。

テーブル ビュー
実データを持つか 持つ 持たない(読むたびに計算)
読めるか
書けるか 条件つき(後述)
インデックスを張れるか

「写しのテーブルをビューに置き換える」というのは、実体のある写しを捨てて、元データのテーブルを覗く窓に差し替えるということになります。データが 1 か所にしかなくなるので、食い違いようがなくなります。

検証してわかったこと

詳細に入る前に、確かめられたことを先にまとめます。

  • 読み取りのコードは 1 行も変えずに済みました。生成される型定義も、置き換えの前後で完全に同一でした
  • modelview に変えると、@@id@@index が使えなくなります。どちらも prisma validate がエラーにします
  • Prisma Migrate は CREATE VIEW を生成しません。マイグレーションは自分で書く必要があります
  • そのマイグレーションは BEGIN / COMMIT で自分で囲む必要がありました。Prisma はマイグレーションファイルを 1 つのトランザクションでは流さないためです
  • 今回作ったビューには INSERT / UPDATE / DELETE のいずれもできませんでした。テストやシードがこのテーブルに直接データを入れていると、そこが落ちます
  • インデックスはビューに張れませんが、元テーブルの索引が効くため、絞り込み検索は実テーブルと同等の速さでした

移行の手間を見積もるときは、読み取り箇所の数ではなく書き込み箇所の数を数えるのが要点でした。

検証内容

検証環境

題材には「イベントと会場」を選びました。会場(Venue)の下にエリア(VenueArea)があり、開催情報はエリア単位で持ちますが、利用者への表示は会場単位でまとめたい、という構図です。

置き換え前は、次のように 5 つとも実テーブルです(EventAvailableVenue は後でビューに置き換えます)。

この 2 つは別々の情報ではありません。EventAvailableArea が元データで、EventAvailableVenue は、その EventAvailableArea の各行を「そのエリアがある会場」に置き換えて作った写しです。同じ情報を、粒度だけ変えて持っています。

置き換え前は、保存処理のたびにこの 2 つへ同じ内容を書いていました。写しのほうを作り直すのを忘れれば、2 つは食い違います。この写しをビューにするのが今回の題材です。

投入したデータを説明します。まず、会場とエリアの関係です。会場 B だけがエリアを 2 つ持ちます。

会場A(Venue.id = 1)
└─ 本館(VenueArea.id = 1)

会場B(Venue.id = 2)
├─ 東ホール(VenueArea.id = 2)
└─ 西ホール(VenueArea.id = 3)

開催情報はエリア単位で入れます。これが元データです。

イベント 開催エリア
サマーフェス 本館 / 東ホール / 西ホール
フード EXPO 東ホール

一方、利用者に見せたいのは会場単位です。

イベント 画面に出したい開催会場
サマーフェス 会場 A、会場 B
フード EXPO 会場 B

ここに落とし穴があります。サマーフェスの 3 つのエリアを、それぞれ「そのエリアがある会場」に置き換えてみます。

本館     → 会場A
東ホール → 会場B
西ホール → 会場B      ← 会場B が 2 回出てくる

エリアは 3 つありますが、会場としては 2 つです。置き換えただけでは会場 B が 2 回並んでしまうため、重複をまとめる必要があります。これが後で DISTINCT が効いてくる理由です。

検証方法

読み取りのクエリとテストを先に書いておき、置き換えの前後でそれらを 1 行も変えずに実行して、結果が変わらないかを見ます。読み取りは Kysely で書きました。

なお、Kysely については私が以前公開した型安全な SQL クエリビルダー「Kysely」の利点を検証するのブログをご参照頂ければ幸いです。

export const findEventsWithVenues = async (db: Kysely<DB>) =>
  db
    .selectFrom("Event")
    .select((eb) => [
      "Event.id",
      "Event.managementName",
      jsonArrayFrom(
        eb
          .selectFrom("EventAvailableVenue as eav")
          .innerJoin("Venue as v", "v.id", "eav.venueId")
          .select(["v.id", "v.name"])
          .whereRef("eav.eventId", "=", "Event.id")
          .orderBy("v.id"),
      ).as("venues"),
    ])
    .orderBy("Event.id")
    .execute();

準備編 ― modelview に変える

Prisma のスキーマを書き換えます。差分はこれだけです。

- model EventAvailableVenue {
+ view EventAvailableVenue {
    eventId String @db.VarChar(26)
    venueId Int

    event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
    venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)

-   @@id([eventId, venueId])
-   @@index([venueId])
+   @@unique([eventId, venueId])
  }

1 行の置き換えに見えて、3 つの変更が同時に起きています。順に見ていきます。

1 つめ。views はまだプレビュー機能です。 そのまま prisma validate を実行すると落ちます。

error: Error validating: View definitions are only available with the `views` preview feature.

generator に 1 行足すと通ります。このプロジェクトには generator が 2 つ(Prisma Client と prisma-kysely)ありますが、片方に書くだけで両方がビューを認識しました

generator client {
  provider        = "prisma-client"
  output          = "generated/client"
  previewFeatures = ["views"]
}

generator kysely {
  provider = "prisma-kysely"
  fileName = "types.ts"
  output   = "./generated"
}

2 つめ。@@id(主キー)は使えません。 残したままだとこうなります。

error: Error validating: Views cannot have primary keys.

Prisma 公式ドキュメントにも記載があります。

Views are virtual tables and do not have inherent primary keys. Hence, you cannot define @id, @@id attributes on a view block.

主キーは「重複を許さない」という DB が強制するルールです。ビューは実データを持たないので、強制する対象がありません。

3 つめ。@@index も張れません。

error: Error validating: Views cannot have indexes.

こちらもドキュメントに明記されています。

Because views are virtual tables, they cannot have indexes. Therefore, @index and @@index cannot be defined on view blocks.

代わりに書いた @@unique の役割は少し特殊なので、後の「一意性編」で扱います。

型編 ― 読み取りを変えずに済む理由

スキーマを書き換えて prisma generate を実行し、置き換え前に生成しておいた型定義と比べました。

diff verification/types.before.ts prisma/generated/types.ts
(差分なし)

完全に同一でした。ビューが実テーブルと同じ形で DB 型に入っています。

export type EventAvailableVenue = {
  eventId: string;
  venueId: number;
};
export type DB = {
  Event: Event;
  EventAvailableArea: EventAvailableArea;
  EventAvailableVenue: EventAvailableVenue; // ← ビューが実テーブルと同列で入る
  Venue: Venue;
  VenueArea: VenueArea;
};

Kysely は Kysely<DB>DB 型を頼りに「どのテーブルにどの列があるか」を判断します。その DB 型が変わらないということは、クエリのコードも変えなくてよいということです。ビューに置き換える方式を選ぶ最大の理由がここにあります。

マイグレーション編 ― CREATE VIEW は自分で書く

次にマイグレーションです。Prisma が何を生成するか見てみます。

prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script
-- DropForeignKey
ALTER TABLE "EventAvailableVenue" DROP CONSTRAINT "EventAvailableVenue_eventId_fkey";

-- DropForeignKey
ALTER TABLE "EventAvailableVenue" DROP CONSTRAINT "EventAvailableVenue_venueId_fkey";

-- DropTable
DROP TABLE "EventAvailableVenue";

DROP TABLE は出ますが、CREATE VIEW は 1 行も出ません。これは仕様で、Prisma 公式ドキュメントに明記されています。

Currently, you cannot apply views that you define in your Prisma schema to your database with Prisma Migrate or db push.

If you apply changes to your Prisma schema with Prisma Migrate or db push, Prisma ORM does not create or run any SQL related to views.

念のため、空の状態からスキーマ全体を作らせても確かめました。model は 4 つとも生成されますが、view は SQL に一度も現れません。

prisma migrate diff --from-empty --to-schema prisma/schema.prisma --script
CREATE TABLE "Venue" (
CREATE TABLE "VenueArea" (
CREATE TABLE "Event" (
CREATE TABLE "EventAvailableArea" (
-- EventAvailableVenue は 1 回も出てこない

db push でも同じでした。空のデータベースに実行すると、できたのはテーブル 4 つだけです。

        名前        |   種別
--------------------+----------
 Event              | テーブル
 EventAvailableArea | テーブル
 Venue              | テーブル
 VenueArea          | テーブル

つまり Prisma から見ると、view ブロックは「この名前でこういう形のものが DB にある」という申告でしかなく、実体を作るのは利用者の役割ということになります。そこで、生成された SQL の末尾に CREATE VIEW を手で足しました。あわせて全体を BEGIN / COMMIT で囲んでいますが、その理由はこの直後に説明します。

BEGIN;

-- DropForeignKey
ALTER TABLE "EventAvailableVenue" DROP CONSTRAINT "EventAvailableVenue_eventId_fkey";

-- DropForeignKey
ALTER TABLE "EventAvailableVenue" DROP CONSTRAINT "EventAvailableVenue_venueId_fkey";

-- DropTable
DROP TABLE "EventAvailableVenue";

-- CreateView(Prisma 未生成・手書き)
-- 1エリア = 1会場だが、複数エリアを持つ会場では同じ会場が重複するため DISTINCT が必須。
CREATE VIEW "EventAvailableVenue" AS
SELECT DISTINCT
    eaa."eventId",
    va."venueId"
FROM "EventAvailableArea" eaa
INNER JOIN "VenueArea" va ON va."id" = eaa."areaId";

COMMIT;

ここで大事なのが、DROP TABLECREATE VIEW を切り離さないことです。別々のマイグレーションに分けると、その間「EventAvailableVenue がどこにも存在しない時間」ができ、そこにアプリが読みに来ると落ちます。

ただし、同じファイルに並べるだけでは足りませんでした。Prisma はマイグレーションファイルを 1 つのトランザクションでは流しません。「成功する文 → 失敗する文」の順に並べたファイルを流して確かめたところ、失敗したあとも 1 文目の結果が残りました。

CREATE TABLE "TxProbe" (id int);          -- 成功する
SELECT this_function_does_not_exist();    -- 失敗する
マイグレーションは失敗(Database error code: 42883)
→ TxProbe は残ったまま

途中で失敗したら全部なかったことにしたいなら、マイグレーション SQL に BEGIN / COMMIT を自分で書きます。同じ内容を囲って試すと、今度は 1 文目ごと巻き戻りました。

BEGIN;
CREATE TABLE "TxProbe" (id int);
SELECT this_function_does_not_exist();
COMMIT;
マイグレーションは失敗
→ TxProbe は存在しない(巻き戻った)

PostgreSQL は DDL もトランザクションで巻き戻せます。手元でも確認できました。

CREATE TABLE tmp_ddl (id int);
BEGIN; DROP TABLE tmp_ddl; ROLLBACK;
-- → tmp_ddl は残っている

したがって今回のマイグレーションも BEGIN / COMMIT で囲んでいます。

これで読み取り側から見てどうなるかも確かめました。入れ替え中に別のセッションから SELECT を投げて比べています。

入れ替え方 読み取り側に起きたこと
BEGIN / COMMIT で囲む エラーにならず、待たされたうえで成功(2,050 ms 後に 1 行返却)
囲まずに DROPCREATE VIEW を離す 44 ms でエラーERROR: relation "t" does not exist

囲めば「テーブルが無い」状態が外から見えることはなく、待つだけで済みます。囲まなければ、その隙間に来た読み取りは失敗します。

読み取り編 ― DISTINCT が重複を畳む

適用して、クエリもテストも 1 行も変えずに実行しました。

    eventId     | venueId
----------------+---------
 evt_food_expo  |       2
 evt_summer_fes |       1
 evt_summer_fes |       2
(3 rows)

 Test Files  1 passed (1)
      Tests  3 passed (3)

適用前に控えておいたデータとも完全に一致しました。読み取りが無改修で通ることが確認できています。

そして DISTINCT の効果も見えます。元テーブル側では、サマーフェスは 3 行あります。

    eventId     | areaId | venueId |    name
----------------+--------+---------+------------
 evt_summer_fes |      1 |       1 | 本館
 evt_summer_fes |      2 |       2 | 東ホール
 evt_summer_fes |      3 |       2 | 西ホール

東ホールと西ホールは、どちらも venueId = 2(会場 B)です。これがビュー越しにはサマーフェス 2 行に畳まれています。

DISTINCT があるかどうかで、アプリが受け取る中身はこう変わります。

DISTINCT あり : サマーフェス: 開催会場 = [ 会場A, 会場B ]
DISTINCT なし : サマーフェス: 開催会場 = [ 会場A, 会場B, 会場B ]
                                                    ↑ 同じ会場が 2 回

DISTINCT が無ければ、画面には「開催会場:会場 A、会場 B、会場 B」と、同じ会場名が並んで出ることになります。エラーにはならず、表示だけが間違う壊れ方です。

重複を潰す処理はビュー定義の中に 1 度書くだけで、読み取り側のクエリには一切現れません。読み取り箇所がいくつあっても、DISTINCT の書き忘れという不具合が起こらなくなります。

書き込み編 ― 読めるが、書けない

ここからが注意点です。ビューに書き込もうとすると、3 つとも拒否されました。

INSERT INTO "EventAvailableVenue" VALUES ('evt_food_expo', 1);
UPDATE "EventAvailableVenue" SET "venueId" = 1;
DELETE FROM "EventAvailableVenue";
ERROR:  cannot insert into view "EventAvailableVenue"
DETAIL:  Views containing DISTINCT are not automatically updatable.

ERROR:  cannot update view "EventAvailableVenue"
ERROR:  cannot delete from view "EventAvailableVenue"

ただし、「ビューだから書けない」わけではありません。PostgreSQL には自動更新可能ビューという仕組みがあり、条件を満たすビューには普通に書き込めます。公式ドキュメントから条件を抜粋します。

The view must have exactly one entry in its FROM list, which must be a table or another updatable view.

The view definition must not contain WITH, DISTINCT, GROUP BY, HAVING, LIMIT, or OFFSET clauses at the top level.

どこで条件を外れているのか、3 種類のビューを作って切り分けました。

ビュー定義 書き込み PostgreSQL のメッセージ
1 テーブルをそのまま選ぶだけ できる (成功)
2 テーブルを JOINDISTINCT なし) できない Views that do not select from a single table or view are not automatically updatable.
JOINDISTINCT(今回のビュー) できない Views containing DISTINCT are not automatically updatable.

今回のビューは JOINDISTINCT両方で条件を外れています。「エリア単位を会場単位に畳む」という目的上どちらも必須なので、読み取り専用になることは受け入れる前提でした。

そして、書き込みをしているのはアプリのコードだけではありません。テストやシードも、たいていテーブルに直接データを入れて期待値を作っています。そこも全部書き換えることになります。実際この検証でも、置き換え前に書いたシードをそのまま流して落ちました。

error: cannot delete from view "EventAvailableVenue"
    at prisma/seed.ts:5:1

なお、書き込み箇所を型で見つけられるかは、使っているライブラリで差がありました。Prisma Client 経由の書き込みはコンパイルエラーになります。

error TS2339: Property 'createMany' does not exist on type 'EventAvailableVenueDelegate<...>'

一方で、Kysely は素通りします

await db
  .insertInto("EventAvailableVenue")
  .values({ eventId: "x", venueId: 1 })
  .execute();
// 型チェックは通り、実行時に初めて失敗する

「型編」で見たとおり DB 型はビューを実テーブルと同列に載せるため、insertInto の対象として区別が付かないのが理由です。型が同じだから読み取りが無改修で済むという利点と、型が同じだから書き込みも通ってしまうという欠点は、同じことの裏表になっています。Kysely を使っているなら、書き込み箇所は型に頼らず自分で探す必要があります。

一意性編 ― @@unique は誰が守るのか

「準備編」で @@id@@unique に置き換えました。名前が似ているので同じものに見えますが、役割が違います。

まず、@@unique を書いても DB 側には何も作られません。実テーブルと並べて数えると分かります。

              対象              | 制約数 | インデックス数
--------------------------------+--------+----------------
 EventAvailableVenue(ビュー)  |      0 |              0
 EventAvailableArea(テーブル) |      3 |              2

スキーマ上はどちらにも一意の宣言がありますが、実体があるのはテーブル側だけです。しかも @@unique は必須ですらなく、消しても prisma validate は通り、Kysely 用の型も変わりませんでした(変わるのは Prisma Client 側で、findUnique のように 1 件を特定する API が使えなくなります)。

つまり @@unique は「DB に強制させる制約」ではなく、「この組み合わせは一意です」と Prisma に伝える申告です。では、その申告が嘘にならないよう守っているのは誰か。ビュー定義の DISTINCT だけです。

言葉だけだと掴みにくいので、実際に外してみました。スキーマの @@unique はそのままに、ビューだけ DISTINCT 無しに差し替えます。

CREATE OR REPLACE VIEW "EventAvailableVenue" AS
SELECT eaa."eventId", va."venueId"          -- DISTINCT を外した
FROM "EventAvailableArea" eaa
INNER JOIN "VenueArea" va ON va."id" = eaa."areaId";

ビューの中身は 3 行から 4 行に増え、会場 B が 2 回返るようになります。

    eventId     | venueId
----------------+---------
 evt_food_expo  |       2
 evt_summer_fes |       1
 evt_summer_fes |       2
 evt_summer_fes |       2     ← 重複

この状態で prisma validate を実行すると、通ります

The schema at prisma/schema.prisma is valid 🚀

スキーマは「一意です」と申告したままなのに、DB は重複行を返している。それでも Prisma からは何も言われません。気づけたのはアプリのテストだけでした。

AssertionError: expected [ { id: 2, name: '会場B' }, …(1) ]
                to have a length of 1 but got 2
 Tests  2 failed | 1 passed (3)

対策として難しいことは要らず、マイグレーション SQL に「なぜ DISTINCT が必要か」をコメントで残すだけです。将来この SQL を読む人が、消してよいものだと誤解しなくなります。

性能編 ― インデックスが張れないと遅くなるのか

ビューにはインデックスを張れません。では検索が遅くなるのか、というのがこの節です。

先に用語を 3 つ

インデックス(索引)は、本の巻末索引と同じ役割です。無ければ最初のページから順にめくって探しますが、索引があれば目的のページに直行できます。

EXPLAIN は、DB に「その検索を、どうやって実行するつもりか」を先に説明させるコマンドです。公式ドキュメントにはこうあります。

This command displays the execution plan that the PostgreSQL planner generates for the supplied statement. The execution plan shows how the table(s) referenced by the statement will be scanned — by plain sequential scan, index scan, etc. — and if multiple tables are referenced, what join algorithms will be used to bring together the required rows from each input table.

ここに出てくる planner(プランナ) が、探し方を決める担当です。同じ検索でも、状況に応じて速いほうを選びます。出力に出てくる探し方は、主に次の 2 つです。

出力 意味 たとえると
Seq Scan 全件を順に読む 最初のページからめくる
Index Scan 索引を使う 巻末索引から直行する

プランナが判断に使うのが統計情報(その列にどんな値が何件くらいあるか、という目安)で、これを更新するのが ANALYZE です。

実際に測ってみる

見るのは「元テーブル VenueArea の索引が使われるかどうか」です。まず、シードした 4 行の状態です。

ANALYZE;
EXPLAIN SELECT * FROM "EventAvailableVenue" WHERE "venueId" = 2;
->  Seq Scan on "VenueArea" va          ← VenueArea の索引は使われていない

Seq Scan、つまり全件走査でした。ここだけ見ると「索引が効いていない」と読めてしまいます。

(計画全体にはもう一方の元テーブル EventAvailableArea の索引が現れますが、いま見たいのは絞り込み条件に対応する VenueArea の索引なので、以降はそこだけを追います)

そこで、クエリはそのままに、データを 17 万行まで増やして同じことをしました。設定は何も変えていません。

->  Index Scan using "VenueArea_venueId_idx" on "VenueArea" va   ← 索引に切り替わった
      Index Cond: ("venueId" = 2)

索引を使う計画に変わりました。4 行のときに全件走査だったのは索引が使えなかったからではなく、その規模なら全部読んだほうが速いとプランナが判断したためです。4 ページの冊子に巻末索引を引くようなもので、かえって遠回りになります。

次に、実行時間も測ります。EXPLAINANALYZE を付けると、計画を立てるだけでなく実際に実行して、かかった時間まで出してくれます。

The ANALYZE option causes the statement to be actually executed, not only planned. Then actual run time statistics are added to the display, including the total elapsed time expended within each plan node (in milliseconds) and the total number of rows it actually returned. This is useful for seeing whether the planner's estimates are close to reality.

まず、索引がちゃんと仕事をしているかを見ます。同じ 17 万行のビューに対して、索引を使える状態と、設定で使えないようにした状態を比べました。

ビューへの同じ検索 Execution Time
そのまま(索引が使える) 約 0.1 ms
索引を使えないようにした場合 約 17〜21 ms

100 倍以上の差がつきました。ビュー越しでも索引が効いている、ということです。

ただしこれはビュー同士の比較です。知りたいのは「ビューにしたことで遅くなったか」のはずなので、置き換え前と同じ形(実テーブル + venueId の索引)を同じ 17 万行分つくり、同じ検索で比べました。

同じ検索・同じデータ量 Execution Time(3 回)
実テーブル(置き換え前に相当) 0.068 / 0.055 / 0.053 ms
ビュー(置き換え後) 0.171 / 0.089 / 0.097 ms

ビューのほうがやや遅い傾向はあるものの、同じ 0.1 ms 前後に収まりました。ビューは読まれるたびに JOINDISTINCT を実行しますが、絞り込みが元テーブルの索引に届くため、少数に絞られてからの処理で済むためです。

なお、これは「絞り込んで少数を取る」検索での結果です。ビュー全体を読み切るような検索なら、毎回の JOINDISTINCT の分だけ実テーブルより不利になります。用途に近い形で測るのが確実です。

なぜ効くのか

計画をよく見ると、ビューに投げた WHERE "venueId" = 2 が、元テーブル VenueArea の条件として実行されていますIndex Cond: ("venueId" = 2) の行)。ビューは実データを持たないので、条件がそのまま元テーブルまで届き、そこにある索引が使われる形になります。

つまり索引は「消えた」のではなく、元テーブル側にあるものがそのまま働いているわけです。したがってビュー化を検討するときは、絞り込みに使う列が元テーブル側で索引されているかを確認すればよいことになります。逆にいうと、元テーブルに索引が無ければ、ここで初めて問題になります。

なお実行計画は、データ量だけでなく統計情報の状態でも変わります。「必ずこの計画になる」と覚えるのではなく、「この索引が使われているかどうか」で読むのが確実です。

まとめ

最初に挙げた疑問に、検証をもとに答えます。

当時の疑問 整理した答え
ビューとテーブルの違いは ビューは実データを持たず、読まれるたびに元テーブルから計算されます
読み取りのコードは変えなくていいのか 変えずに済みました。生成される型が完全に同一だったためです
modelview にするだけか @@id@@index が使えなくなります。どちらも prisma validate がエラーにします
マイグレーションは自動生成されるか DROP TABLE までです。CREATE VIEW は自分で書きます
1 ファイルにまとめれば途中失敗時に巻き戻るか 巻き戻りません。BEGIN / COMMIT を自分で書く必要があります
何ができなくなるか 今回のビューは書き込みが一切できません。テストとシードの書き換えが必要でした
索引が張れないと遅くなるか 元テーブルの索引が使われるため、実テーブルと同等(0.1 ms 前後)でした

読み取り箇所が多く、そこを触りたくない場合ほど、ビューへの置き換えが有利になります。「エリア単位を会場単位にまとめる」という変換を 1 か所に閉じ込められるのも利点でした。

一方で、読み取り側で浮いた手間は、そっくり書き込み側に移ります。移行の規模を測るなら、読み取り箇所ではなく書き込み箇所を数えるのが確実です。あわせて、views が Preview 機能である点も判断材料になります。

この記事が誰かのお役に立てば幸いです。

参考情報

クラスメソッドオペレーションズ株式会社について

クラスメソッドグループのオペレーション企業です。
運用・保守開発・サポート・情シス・バックオフィスの専門チームが、IT・AI をフル活用した「しくみ」を通じて、お客様の業務代行から課題解決や高付加価値サービスまでを提供するエキスパート集団です。
当社は様々な職種でメンバーを募集しています。
「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、クラスメソッドオペレーションズ株式会社 コーポレートサイト をぜひご覧ください。※2026 年 1 月 アノテーション㈱から社名変更しました

この記事をシェアする

関連記事