ゼスト Tech Blog

ゼストは「護りたい。その想いを護る。」をミッションに、在宅医療・介護業界向けのSaaSを開発しています。

Prismaと生成列

はじめに

こんにちは!またもや株式会社ゼストでエンジニアをしている正原です。 この記事が公開されるクリスマスイブイブともなると、2025年はもう約一週間しか残っていません。 稼働日も限られてきて今着手してるタスクが終わるのか?と少し焦る気持ちもありますが、 いざとなったら2026年版のニュー正原がなんとかしてくれるに違いないと信じています。 でもきっと去年までと同様に「去年末は何やってたっけ?」となるに違いないと確信もしています。

さて、今回のテーマは「Prismaと生成列」についてです。 多くのプロダクトで生産性の向上や型の安全性のためにORMを利用しているかと思いますが、 ORMは多くのDBをサポートするため、各々のDBが持つ機能がサポートされていないことが多いかと思います。 また、機能だけでなくクエリが複雑な場合やパフォーマンスが重要な場合、 やむを得ず自分たちでSQLを書くことも少なくないと思います。

ZESTが採用しているPrismaも基本的な機能はサポートされていますし、 多くの場合問題はないですが、一部諦めてSQLを書くこともあります。 しかし、新しい人が入ってきたり、自分の信用ならない記憶のことを考えると、 キャッチアップしやすく比較的考慮漏れが少なくなりそうなORMでなるべく実装したいと考えるでしょう。

個人的にもパフォーマンスまたはコストが非常に重要なところでなければ、 今後の継続的な開発速度を優先し、なるべくORMやフレームワークなどを用いた実装をしています。 そこで今回は運用コストをなるべく増やさないようにしつつ、 パフォーマンスが重要なところでPostgreSQLの生成列を利用できないか?という観点で Prismaと生成列の共存を検証していきたいと思います。

生成列とは

ではまず、そもそも今回のテーマである「生成列」とはどのようなものなのか見ていきたいと思います。 以下はPostgreSQL日本語版公式ドキュメントの引用です。

生成列は常に他の列から計算される特別な列です。 ですから、これは列におけるテーブルに対するビューのようなものです。 生成列には格納と仮想の2種類があります。 格納生成列はそれが書かれた(挿入または更新)時に計算され、あたかも通常の列のようにストレージが割り当てられます。 仮想列にはストレージは割り当てられず、列が読み出された時に計算されます。 つまり、仮想生成列はビューに似ており、格納生成列はマテリアライズドビューに似ています。(常に自動的に更新される点は除きます。)

日本語ドキュメントは17までで18はまだないようです。そのため、この引用元には格納生成列と仮想生成列のうち仮想生成列は未実装と記載されていますが、 最新のバージョン18ではサポートされていて、英語版の最新のドキュメントでは仮想生成列についても記載されています。一方、格納生成列はPostgreSQLではバージョン12(2019年リリース)からサポートされているようです。 余談ですが、MySQLの方が少し早くバージョン5.7(2015年リリース)からサポートされていたようです。

引用元の文章そのままですが、生成列とはカラムに対するビューに近いイメージと言えるのではないでしょうか。 特徴としては、生成列にはインデックスが貼れるため、高いパフォーマンスが期待できます。 なお、今回はPrismaとの共存がテーマなため、PostgreSQL 12以降で使える STORED (格納生成列)を使って検証します。

Prismaとの共存

冒頭でも述べましたが、パフォーマンスが良く便利だとしてもORMで使いにくかったりすると採用を躊躇してしまう方も少なくないと思います。 ですが、生成列はカラムのビューだと考えると、ORMでも少なくとも読み取りや行の削除は問題なくできそうです。 一方で、挿入や更新で生成列を扱ってしまうと以下のようにエラーとなってしまいます。

ERROR:  cannot insert a non-DEFAULT value into column "xxx"
DETAIL:  Column "xxx" is a generated column.

ですが、このようにDB側で自動生成されるような機能としてオートインクリメントがあります。 Prismaでもオートインクリメントはサポートされていますし、 オートインクリメントでなくてもDB側で自動生成されることを想定した dbgenerated() という関数による定義がスキーマ上で利用可能です。 生成列もこの dbgenerated() を使うことでPrismaでカラムの値を読み取ることが可能です。

model User {
  id          String @id @default(ulid())
  familyName  String @map("family_name")
  givenName   String @map("given_name")
  fullName    String @default(dbgenerated()) @map("full_name")

  @@map("users")
}

もちろんPrismaではマイグレーションとして生成列をサポートしていないため、 マイグレーションファイルの内容はスキーマからではなく --create-only を用いて別途定義する必要があります。 以下は上記の fullName を追加するマイグレーションの例です。

ALTER TABLE users
ADD COLUMN full_name text
GENERATED ALWAYS AS (family_name || given_name) STORED;

これによって再度マイグレーションを実行してもテーブル定義の差分が出ないため、 今後他のマイグレーションに影響を与えることなく生成列をPrismaと共存させることができました。 以下は実際に生成列を含んだPrismaのモデルでの作成および読み取りの例です。

import { prisma } from "../lib/prisma";

const main = async () => {
  await prisma.user.create({
    data: { familyName: "yamada", givenName: "taro" },
  });

  const users = await prisma.user.findMany();

  console.log(users);
};

main().then(async () => {
  await prisma.$disconnect();
})

以下は実行結果の出力です。

[
  {
    id: '01KD28CG7NG956V9JXQN26FXA7',
    familyName: 'yamada',
    givenName: 'taro',
    fullName: 'yamadataro'
  }
]

気になるポイント

生成列がPrismaで扱えることが分かりました。生成列にはインデックスも貼れるので、有効なユースケースはいくつかあると思われます。 一方で、Prismaだから困ることやそもそも生成列でできないこともいくつかあります。

Prismaにはリードオンリーがないため生成列が作成・更新時に出てくる

紹介した定義の仕方では、Prismaのスキーマ定義の default() を使うため、作成時や更新時にPrismaのモデルにプロパティとして渡せてしまいます。 そのため現状では型による値(上記の例における fullname )の入力を制限することができません。 この辺はレポジトリ層などでプロパティを削除させたり別途Prismaのモデルの型から対象のカラムを Omit するなどの工夫が必要になります。

await prisma.user.create({
  data: { familyName: "yamada", givenName: "taro", fullName: "yamada taro" }, // fullNameがエラーにならない
});

イミュータブルな式でないといけない

生成列はイミュータブルな式(関数)しか扱えません。 NOW() などの評価するタイミングで値が変わってしまう関数などを含めてしまうと以下のようにエラーになります。

ERROR:  generation expression is not immutable

また余談ですが、仮想生成列の場合は型としてenumを使うことができません。

# CREATE TYPE "XXX" AS ENUM ('A', 'B', 'C');
CREATE TYPE
# CREATE TABLE tests ("xxx" "XXX" NOT NULL generated always as ('A') virtual);
ERROR:  virtual generated column "xxx" cannot have a user-defined type
DETAIL:  Virtual generated columns that make use of user-defined types are not yet supported.
# CREATE TABLE tests ("xxx" "XXX" NOT NULL generated always as ('A') stored);
CREATE TABLE

エラー文を読む限り「まだ」と書いてあることから、今後サポートされるかもしれませんね。

生成列の活用例

いくつか制限もある生成列ですが、ではどのようなユースケースで有効でしょうか。 調べてみた中でZESTでも使えそうだと思ったものをいくつか試してみました。

JSONデータの属性昇格

ログなど構造が常に同じでないものを保存したい機会があるかと思います。 そのような場合、RDBで管理するのではなくドキュメントDBで管理すべきなどの議論はあるかと思いますが、 生成列を用いることで検索に使いたいキーのみカラムとして扱い、 インデックスを貼ることによる高速化も可能となります。

以下のスキーマ定義では message というカラムをJSON型として扱い、 その message の中にある user_id を検索するユースケースを想定しています。

model Log {
  id        String   @id @default(ulid())
  message   Json
  userId    String?  @default(dbgenerated()) @map("user_id")
  createdAt DateTime @default(now()) @map("created_at")

  @@index([userId])
  @@map("logs")
}

以下は追加のマイグレーションファイルの内容になります。 message から user_id を抽出しています。

ALTER TABLE logs
ADD COLUMN user_id TEXT
GENERATED ALWAYS AS (message->>'user_id') STORED;

以下は上記のテーブル定義を用いてログを作成および検索するサンプルです。 通常のPrismaの関数での作成および検索ができます。

import { prisma } from "../lib/prisma";

const main = async () => {
  await prisma.log.createMany({
    data: [
      { id: "1", message: {} },
      { id: "2", message: { user_id: "xxx" } },
      { id: "3", message: { user_id: "yyy" } },
      { id: "4", message: { other_id: "zzz" } },
      { id: "5", message: { user_id: "xxx" } },
    ],
    skipDuplicates: true,
  });

  const logs = await prisma.log.findMany({ where: { userId: "xxx" } });
  console.log("logs with userId=xxx:", logs);
};

main().then(async () => {
  await prisma.$disconnect();
});

未だにSQLにおけるJSONオブジェクトの記法が覚えられず、毎回生成AIにお世話になっている僕としては、 通常のカラムと同様に扱えたり高速に検索できるのは非常にありがたいです。

インデックスの事前計算

ZESTではPostgreSQL拡張機能であるPostGISを利用しています。 このPostGISでは geographygeometry といった型を用いてある地点から半径Nkm圏内の検索や距離の近い順というソートなどが可能です。 これらの型はPrismaでは公式にサポートされていないため、ZESTではこれまでトリガーと関数で管理してきましたが、生成列でも対応できます。

以下はPrismaのスキーマファイルでPostGISの提供する型を定義したサンプルになります。 Prismaでは geographygeometry といった型は扱えないので、スキーマ上では Unsupported としています。

model Location {
  id        String  @id @default(ulid())
  name      String
  latitude  Float
  longitude Float
  point     Unsupported("geography(Point, 4326)") @default(dbgenerated())

  @@index([point], type: Gist)

  @@map("locations")
}

point というカラムを緯度経度(longitude / latitude)から生成します。 この point にインデックスを貼ってPostGISを用いた検索に使います。

ALTER TABLE locations
ADD COLUMN point geography(Point, 4326) NOT NULL
GENERATED ALWAYS AS (ST_Point(longitude, latitude, 4326)::geography) STORED;

以下は新宿から4km圏内を検索し、距離順でソートするサンプルです。 PostGISを用いた検索についてはPrismaではどうしようもないため、生クエリで記述しています。

import { Prisma } from "../generated/prisma/client";
import { prisma } from "../lib/prisma";

// 参考座標:https://qiita.com/butchi_y/items/3a6b70b38e13dc56ef13
// 計算サイト:https://keisan.casio.jp/exec/system/1257670779

/*
 * 駅名    緯度         経度
 * ------+-----------+------------------
 * 新宿駅 | 35.690921 | 139.70025799999996
 * 渋谷駅 | 35.658517 | 139.70133399999997
 * 池袋駅 | 35.728926 | 139.71038
 *
 * 新宿 <=> 渋谷:3.608509[km]
 * 新宿 <=> 池袋:4.328496[km]
 * 渋谷 <=> 池袋:7.880446[km]
 */

const main = async () => {
  await prisma.location.createMany({
    data: [
      {
        id: "1",
        name: "新宿駅",
        latitude: 35.690921,
        longitude: 139.70025799999996,
      },
      {
        id: "2",
        name: "渋谷駅",
        latitude: 35.658517,
        longitude: 139.70133399999997,
      },
      {
        id: "3",
        name: "池袋駅",
        latitude: 35.728926,
        longitude: 139.71038,
      },
    ],
    skipDuplicates: true,
  });

  const allLocations = await prisma.location.findMany();
  console.log("allLocations:", allLocations);

  const point = { latitude: 35.690921, longitude: 139.70025799999996 }; // 新宿駅
  const coordinate = newCoordinateSql(point);
  const distance = 4 * 1000; // m
  const fromShinjuku = await prisma.$queryRaw`
SELECT
  t.name,
  t.latitude,
  t.longitude,
  ST_Distance(t.point, ${coordinate}) AS distance
FROM locations t
WHERE ST_DWithin(t.point, ${coordinate}, ${distance})
ORDER BY distance ASC`;

  console.log(`Within ${distance} m from shinjuku:`, fromShinjuku);
};

type Coordinate = {
  latitude: number;
  longitude: number;
};

const newCoordinateSql = ({ latitude, longitude }: Coordinate) =>
  Prisma.sql`ST_Point(${longitude}, ${latitude}, 4326)`;

main().then(async () => {
  await prisma.$disconnect();
});
allLocations: [
  { id: '1', name: '新宿駅', latitude: 35.690921, longitude: 139.700258 },
  { id: '2', name: '渋谷駅', latitude: 35.658517, longitude: 139.701334 },
  { id: '3', name: '池袋駅', latitude: 35.728926, longitude: 139.71038 }
]
Within 4000 m from shinjuku: [
  {
    name: '新宿駅',
    latitude: 35.690921,
    longitude: 139.700258,
    distance: 0
  },
  {
    name: '渋谷駅',
    latitude: 35.658517,
    longitude: 139.701334,
    distance: 3596.63986747
  }
]

このインデックスの事前準備についてはPostGISだけでなく、 N-gramなど自然言語処理検索エンジンなどでも同様に活用できそうですね。

おわりに

いかがでしたでしょうか? 生成列は読み取りにおいては通常のカラムと相違ないため、Prisma以外のORMとも比較的相性が良いのではないかと思います。 また、オートインクリメントをサポートしているORMであれば、作成や更新においてもPrismaと同様の対応が可能かもしれません。 「パフォーマンス改善したいけど、できたらORMで扱いたいな〜」という場合、選択肢の1つとして検討してみてはどうでしょうか。

おまけ

今回の検証内容をgithubで公開しているので、良ければ参考にしてください。

github.com