Active Record と PostgreSQL

本ガイドでは、PostgreSQL特有のActive Record利用法について解説します。

このガイドの内容:

  • PostgreSQLのデータ型の利用法
  • UUID主キーの利用法
  • PostgreSQLで全文検索を実装する方法
  • Active Recordモデルでデータベースビューを使う方法

PostgreSQLアダプタを利用するには、PostgreSQL 9.3以上がインストールされている必要があります。これより古いバージョンはサポートされません。

PostgreSQLを使う場合は、『Rails アプリケーションを設定する』ガイドをお読みください。Active RecordをPostgreSQL向けに正しくセットアップする方法が記載されています。

1 データ型

PostgreSQLにはさまざまな種類のデータ型(datatype)があります。以下はPostgreSQLアダプタでサポートされているデータ型のリストです。

1.1 bytea(バイナリデータ)

# db/migrate/20140207133952_create_documents.rb
create_table :documents do |t|
  t.binary 'payload'
end
# app/models/document.rb
class Document < ApplicationRecord
end
# 利用法
data = File.read(Rails.root + "tmp/output.pdf")
Document.create payload: data

1.2 配列

# db/migrate/20140207133952_create_books.rb
create_table :books do |t|
  t.string 'title'
  t.string 'tags', array: true
  t.integer 'ratings', array: true
end
add_index :books, :tags, using: 'gin'
add_index :books, :ratings, using: 'gin'
# app/models/book.rb
class Book < ApplicationRecord
end
# 利用法
Book.create title: "Brave New World",
            tags: ["fantasy", "fiction"],
            ratings: [4, 5]

## 1個のタグに対応するBooks
Book.where("'fantasy' = ANY (tags)")

## 複数のタグに対応するBooks
Book.where("tags @> ARRAY[?]::varchar[]", ["fantasy", "fiction"])

## ratingが3以上のBooks
Book.where("array_length(ratings, 1) >= 3")

1.3 hstore(キーバリューに相当)

hstoreを使うにはhstore拡張を有効にする必要があります。

# db/migrate/20131009135255_create_profiles.rb
ActiveRecord::Schema.define do
  enable_extension 'hstore' unless extension_enabled?('hstore')
  create_table :profiles do |t|
    t.hstore 'settings'
  end
end
# app/models/profile.rb
class Profile < ApplicationRecord
end
irb> Profile.create(settings: { "color" => "blue", "resolution" => "800x600" })

irb> profile = Profile.first
irb> profile.settings
=> {"color"=>"blue", "resolution"=>"800x600"}

irb> profile.settings = {"color" => "yellow", "resolution" => "1280x1024"}
irb> profile.save!

irb> Profile.where("settings->'color' = ?", "yellow")
=> #<ActiveRecord::Relation [#<Profile id: 1, settings: {"color"=>"yellow", "resolution"=>"1280x1024"}>]>

1.4 JSONとJSONB

# db/migrate/20131220144913_create_events.rb
# ... jsonデータ型の場合:
create_table :events do |t|
  t.json 'payload'
end
# ... jsonbデータ型の場合:
create_table :events do |t|
  t.jsonb 'payload'
end
# app/models/event.rb
class Event < ApplicationRecord
end
irb> Event.create(payload: { kind: "user_renamed", change: ["jack", "john"]})

irb> event = Event.first
irb> event.payload
=> {"kind"=>"user_renamed", "change"=>["jack", "john"]}

## JSONドキュメントに基づくクエリ
# ->演算子は元のJSONデータ型を返す(オブジェクトの可能性がある)が、
# ->>はテキストを返す
irb> Event.where("payload->>'kind' = ?", "user_renamed")

1.5 範囲型(range)

このデータ型はRubyのRangeオブジェクトに対応付けられます。

# db/migrate/20130923065404_create_events.rb
create_table :events do |t|
  t.daterange 'duration'
end
# app/models/event.rb
class Event < ApplicationRecord
end
irb> Event.create(duration: Date.new(2014, 2, 11)..Date.new(2014, 2, 12))

irb> event = Event.first
irb> event.duration
=> Tue, 11 Feb 2014...Thu, 13 Feb 2014

## 指定の日付の全イベント
irb> Event.where("duration @> ?::date", Date.new(2014, 2, 12))

## 範囲の境界を指定
irb> event = Event.select("lower(duration) AS starts_at").select("upper(duration) AS ends_at").first

irb> event.starts_at
=> Tue, 11 Feb 2014
irb> event.ends_at
=> Thu, 13 Feb 2014

1.6 複合型(composite type)

現在は複合型について特別なサポートはありません。これらは通常のtextカラムに対応付けられます。

CREATE TYPE full_address AS
(
  city VARCHAR(90),
  street VARCHAR(90)
);
# db/migrate/20140207133952_create_contacts.rb
execute <<-SQL
  CREATE TYPE full_address AS
  (
    city VARCHAR(90),
    street VARCHAR(90)
  );
SQL
create_table :contacts do |t|
  t.column :address, :full_address
end
# app/models/contact.rb
class Contact < ApplicationRecord
end
irb> Contact.create address: "(Paris,Champs-Élysées)"
irb> contact = Contact.first
irb> contact.address
=> "(Paris,Champs-Élysées)"
irb> contact.address = "(Paris,Rue Basse)"
irb> contact.save!

1.7 列挙型(enumerated type)

この型は、通常のtextカラムまたはActiveRecord::Enumに対応付けられます。

# db/migrate/20131220144913_create_articles.rb
def up
  create_enum :article_status, ["draft", "published"]

  create_table :articles do |t|
    t.enum :status, enum_type: :article_status, default: "draft", null: false
  end
end

# enumの削除機能はRailsに組み込まれていないが、手動での削除は可能
# 依存するテーブルを最初にすべて削除しておくこと
def down
  drop_table :articles

  execute <<-SQL
    DROP TYPE article_status;
  SQL
end
# app/models/article.rb
class Article < ApplicationRecord
  enum status: {
    draft: "draft", published: "published"
  }, _prefix: true
end
irb> Article.create status: "draft"
irb> article = Article.first
irb> article.status
=> "draft"

irb> article.status = "published"
irb> article.save!

既存の値の前または後に新しい値を追加する場合は、ALTER TYPEを使うこと。

# db/migrate/20150720144913_add_new_state_to_articles.rb
# メモ: ALTER TYPE ... ADD VALUEはトランザクションブロック内では実行できないので、
# ここではdisable_ddl_transaction!を利用
disable_ddl_transaction!

def up
  execute <<-SQL
    ALTER TYPE article_status ADD VALUE IF NOT EXISTS 'archived' AFTER 'published';
  SQL
end

ENUMの値はDROPできません。理由についてはこの記事を参照してください。

Hint: 現在のenumにある値をすべて表示するには、クエリをbin/rails dbまたはpsqlで実行できます。

SELECT n.nspname AS enum_schema,
       t.typname AS enum_name,
       e.enumlabel AS enum_value
  FROM pg_type t
      JOIN pg_enum e ON t.oid = e.enumtypid
      JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace

1.8 UUID

uuidを使うには、pgcrypto拡張(PostgreSQL 9.4以降のみ)またはuuid-ossp拡張を有効にする必要があります。

# db/migrate/20131220144913_create_revisions.rb
create_table :revisions do |t|
  t.uuid :identifier
end
# app/models/revision.rb
class Revision < ApplicationRecord
end
irb> Revision.create identifier: "A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11"

irb> revision = Revision.first
irb> revision.identifier
=> "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"

uuid型を用いて、マイグレーションファイル内で以下のように参照を定義できます。

# db/migrate/20150418012400_create_blog.rb
enable_extension 'pgcrypto' unless extension_enabled?('pgcrypto')
create_table :posts, id: :uuid

create_table :comments, id: :uuid do |t|
  # t.belongs_to :post, type: :uuid
  t.references :post, type: :uuid
end
# app/models/post.rb
class Post < ApplicationRecord
  has_many :comments
end
# app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :post
end

UUIDを主キーとして利用する方法について詳しくは後述します。

1.9 ビット列データ型(bit string type)

# db/migrate/20131220144913_create_users.rb
create_table :users, force: true do |t|
  t.column :settings, "bit(8)"
end
# app/models/user.rb
class User < ApplicationRecord
end
irb> User.create settings: "01010011"
irb> user = User.first
irb> user.settings
=> "01010011"
irb> user.settings = "0xAF"
irb> user.settings
=> "10101111"
irb> user.save!

1.10 ネットワークアドレス型

inet型およびcidr型は、RubyのIPAddrオブジェクトに対応付けられます。macaddr型は通常のtextデータ型に対応付けられます。

# db/migrate/20140508144913_create_devices.rb
create_table(:devices, force: true) do |t|
  t.inet 'ip'
  t.cidr 'network'
  t.macaddr 'address'
end
# app/models/device.rb
class Device < ApplicationRecord
end
irb> macbook = Device.create(ip: "192.168.1.12", network: "192.168.2.0/24", address: "32:01:16:6d:05:ef")

irb> macbook.ip
=> #<IPAddr: IPv4:192.168.1.12/255.255.255.255>

irb> macbook.network
=> #<IPAddr: IPv4:192.168.2.0/255.255.255.0>

irb> macbook.address
=> "32:01:16:6d:05:ef"

1.11 幾何データ型(geometric type)

pointを除くすべての幾何データ型は、通常のtextデータ型に対応付けられます。pointは、x座標とy座標を含む配列にキャストされます。

1.12 期間(interval)

このデータ型はActiveSupport::Durationオブジェクトに対応付けられます。

# db/migrate/20200120000000_create_events.rb
create_table :events do |t|
  t.interval 'duration'
end
# app/models/event.rb
class Event < ApplicationRecord
end
irb> Event.create(duration: 2.days)

irb> event = Event.first
irb> event.duration
=> 2 days

2 UUID主キー

ランダムなUUIDを生成するには、pgcrypto拡張(PostgreSQL9.4以降)またはuuid-ossp拡張を有効にする必要があります。

# db/migrate/20131220144913_create_devices.rb
enable_extension 'pgcrypto' unless extension_enabled?('pgcrypto')
create_table :devices, id: :uuid do |t|
  t.string :kind
end
# app/models/device.rb
class Device < ApplicationRecord
end
irb> device = Device.create
irb> device.id
=> "814865cd-5a1d-4771-9306-4268f188fe9e"

pgcryptogen_random_uuid()関数は、create_table:defaultオプションが渡されていないことを前提としています。

3 生成列(generated column)

生成列はPostgreSQL 12.0以降でサポートされます。

# db/migrate/20131220144913_create_users.rb
create_table :users do |t|
  t.string :name
  t.virtual :name_upcased, type: :string, as: 'upper(name)', stored: true
end

# app/models/user.rb
class User < ApplicationRecord
end

# 利用法
user = User.create(name: 'John')
User.last.name_upcased # => "JOHN"

4 全文検索

# db/migrate/20131220144913_create_documents.rb
create_table :documents do |t|
  t.string :title
  t.string :body
end

add_index :documents, "to_tsvector('english', title || ' ' || body)", using: :gin, name: 'documents_idx'
# app/models/document.rb
class Document < ApplicationRecord
end
# Usage
Document.create(title: "Cats and Dogs", body: "are nice!")

## 'cat & dog'にマッチするすべてのドキュメント
Document.where("to_tsvector('english', title || ' ' || body) @@ to_tsquery(?)",
                 "cat & dog")

オプションで、このベクタを自動生成カラムとして保存することも可能です(PostgreSQL 12.0以降)。

# db/migrate/20131220144913_create_documents.rb
create_table :documents do |t|
  t.string :title
  t.string :body

  t.virtual :textsearchable_index_col,
            type: :tsvector, as: "to_tsvector('english', title || ' ' || body)", stored: true
end

add_index :documents, :textsearchable_index_col, using: :gin, name: 'documents_idx'

# 利用法
Document.create(title: "Cats and Dogs", body: "are nice!")

## 'cat & dog'にマッチするすべてのドキュメント
Document.where("textsearchable_index_col @@ to_tsquery(?)", "cat & dog")

5 データベースビュー(database view)

以下のテーブルを含むレガシーデータベースを扱う必要があるとします。

rails_pg_guide=# \d "TBL_ART"
                                        Table "public.TBL_ART"
   Column   |            Type             |                         Modifiers
------------+-----------------------------+------------------------------------------------------------
 INT_ID     | integer                     | not null default nextval('"TBL_ART_INT_ID_seq"'::regclass)
 STR_TITLE  | character varying           |
 STR_STAT   | character varying           | default 'draft'::character varying
 DT_PUBL_AT | timestamp without time zone |
 BL_ARCH    | boolean                     | default false
Indexes:
    "TBL_ART_pkey" PRIMARY KEY, btree ("INT_ID")

このテーブルはRailsの規約にまったく従っていません。 PostgreSQLのシンプルなビュー(view)はデフォルトで更新可能なので、以下のようにラップできます。

# db/migrate/20131220144913_create_articles_view.rb
execute <<-SQL
CREATE VIEW articles AS
  SELECT "INT_ID" AS id,
         "STR_TITLE" AS title,
         "STR_STAT" AS status,
         "DT_PUBL_AT" AS published_at,
         "BL_ARCH" AS archived
  FROM "TBL_ART"
  WHERE "BL_ARCH" = 'f'
  SQL
# app/models/article.rb
class Article < ApplicationRecord
  self.primary_key = "id"
  def archive!
    update_attribute :archived, true
  end
end
irb> first = Article.create! title: "Winter is coming", status: "published", published_at: 1.year.ago
irb> second = Article.create! title: "Brace yourself", status: "draft", published_at: 1.month.ago

irb> Article.count
=> 2
irb> first.archive!
irb> Article.count
=> 1

このアプリケーションは、archiveされていないArticlesのみを扱う前提です。ビューには条件を設定可能なので、archiveされたArticlesを直接除外できます。

フィードバックについて

Railsガイドは GitHub の yasslab/railsguides.jp で管理・公開されております。本ガイドを読んで気になる文章や間違ったコードを見かけたら、気軽に Pull Request を出して頂けると嬉しいです。Pull Request の送り方については GitHub の README をご参照ください。

原著における間違いを見つけたら『Rails のドキュメントに貢献する』を参考にしながらぜひ Rails コミュニティに貢献してみてください 🛠💨✨

本ガイドの品質向上に向けて、皆さまのご協力が得られれば嬉しいです。よろしくお願いします。

YassLab 株式会社
https://yasslab.jp/

支援・協賛

Railsガイドは下記のサポーターから継続的な支援を受けています。Railsガイドへの支援・協賛にご興味あれば info@yasslab.jp までお問い合わせください。

  1. Star
  2. このエントリーをはてなブックマークに追加