Active Record와 PostgreSQL

이 가이드는 Active Record에서 PostgreSQL 특정 사용법을 다룹니다.

이 가이드를 읽고 나면 다음을 알 수 있습니다:

  • PostgreSQL의 데이터 유형을 사용하는 방법.
  • UUID 기본 키를 사용하는 방법.
  • 키가 아닌 열을 인덱스에 포함하는 방법.
  • 연기 가능한 외래 키를 사용하는 방법.
  • 고유 제약 조건을 사용하는 방법.
  • 제외 제약 조건을 구현하는 방법.
  • PostgreSQL로 전체 텍스트 검색을 구현하는 방법.
  • Active Record 모델을 데이터베이스 뷰로 지원하는 방법.

PostgreSQL 어댑터를 사용하려면 최소 9.3 버전이 설치되어 있어야 합니다. 이전 버전은 지원되지 않습니다.

PostgreSQL 시작하기 위해서는 Rails 구성 가이드를 참고하세요. Active Record에서 PostgreSQL을 올바르게 설정하는 방법이 설명되어 있습니다.

데이터 유형

PostgreSQL은 다양한 특정 데이터 유형을 제공합니다. 다음은 PostgreSQL 어댑터에서 지원되는 유형 목록입니다.

Bytea

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

Array

# 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
# Usage
Book.create title: "Brave New World",
            tags: ["fantasy", "fiction"],
            ratings: [4, 5]

## 단일 태그에 대한 도서
Book.where("'fantasy' = ANY (tags)")

## 다중 태그에 대한 도서
Book.where("tags @> ARRAY[?]::varchar[]", ["fantasy", "fiction"])

## 3개 이상의 평점이 있는 도서
Book.where("array_length(ratings, 1) >= 3")

Hstore

참고: hstore를 사용하려면 hstore 확장을 활성화해야 합니다.

# db/migrate/20131009135255_create_profiles.rb
class CreateProfiles < ActiveRecord::Migration[7.0]
  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"}>]>

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")

범위 유형

이 유형은 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

복합 유형

현재 복합 유형에 대한 특별한 지원은 없습니다. 일반 텍스트 열에 매핑됩니다:

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!

열거형 유형

이 유형은 일반 텍스트 열로 매핑하거나 ActiveRecord::Enum에 매핑할 수 있습니다.

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

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

기존 테이블에 열거형 열을 추가할 수도 있습니다:

# db/migrate/20230113024409_add_status_to_articles.rb
def change
  create_enum :article_status, ["draft", "published", "archived"]

  add_column :articles, :status, :enum, enum_type: :article_status, default: "draft", null: false
end

위의 마이그레이션은 모두 되돌릴 수 있지만, 필요한 경우 별도의 #up#down 메서드를 정의할 수 있습니다. 열거형 유형을 삭제하기 전에 해당 유형에 의존하는 열이나 테이블을 제거해야 합니다:

def down
  drop_table :articles

  # 또는: remove_column :articles, :status
  drop_enum :article_status
end

모델에서 열거형 속성을 선언하면 헬퍼 메서드가 추가되고 클래스의 인스턴스에 잘못된 값이 할당되는 것을 방지합니다:

# app/models/article.rb
class Article < ApplicationRecord
  enum :status, {
    draft: "draft", published: "published", archived: "archived"
  }, prefix: true
end
irb> article = Article.create
irb> article.status
=> "draft" # PostgreSQL에서 정의된 기본 상태

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

irb> article.status_archived?
=> false

irb> article.status = "deleted"
ArgumentError: 'deleted' is not a valid status

열거형 이름을 변경하려면 rename_enum을 사용하고 모델 사용을 업데이트해야 합니다:

# db/migrate/20150718144917_rename_article_status.rb
def change
  rename_enum :article_status, to: :article_state
end

새 값을 추가하려면 add_enum_value를 사용할 수 있습니다:

# db/migrate/20150720144913_add_new_state_to_articles.rb
def up
  add_enum_value :article_state, "archived" # published 다음에 추가됨
  add_enum_value :article_state, "in review", before: "published"
  add_enum_value :article_state, "approved", after: "in review"
end

참고: 열거형 값은 삭제할 수 없으므로 addenumvalue는 되돌릴 수 없습니다. 이유는 여기에서 확인할 수 있습니다.

값 이름을 변경하려면 rename_enum_value를 사용할 수 있습니다:

# db/migrate/20150722144915_rename_article_state.rb
def change
  rename_enum_value :article_state, from: "archived", to: "deleted"
end

힌트: 모든 열거형의 모든 값을 표시하려면 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

UUID

참고: PostgreSQL 버전이 13.0 미만인 경우 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 'pgcry네, 번역을 계속하겠습니다.

### UUID 기본 키

```ruby
# 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를 기본 키로 사용하는 방법에 대해 자세히 설명합니다.

비트 문자열 유형

# 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!

네트워크 주소 유형

inetcidr 유형은 Ruby IPAddr 객체에 매핑됩니다. macaddr 유형은 일반 텍스트에 매핑됩니다.

# 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"

기하학적 유형

점을 제외한 모든 기하학적 유형은 일반 텍스트에 매핑됩니다. 점은 xy 좌표를 포함하는 배열로 캐스팅됩니다.

간격

이 유형은 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

UUID 기본 키

참고: pgcrypto(PostgreSQL >= 9.4) 또는 uuid-ossp 확장을 활성화해야 무작위 UUID를 생성할 수 있습니다.

# 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"

참고: :default 옵션을 create_table에 전달하지 않으면 gen_random_uuid()(from pgcrypto)가 가정됩니다.

UUID를 기본 키로 사용하는 테이블에 대한 Rails 모델 생성기를 사용하려면 --primary-key-type=uuid를 전달하세요.

예를 들어:

$ rails generate model Device --primary-key-type=uuid kind:string

UUID를 참조하는 외래 키가 있는 모델을 빌드할 때는 uuid를 네이티브 필드 유형으로 처리하세요. 예:

$ rails generate model Case device_id:uuid

인덱싱

PostgreSQL에는 다양한 인덱스 옵션이 포함되어 있습니다. 다음 옵션은 일반적인 인덱스 옵션에 추가로 PostgreSQL 어댑터에서 지원됩니다.

포함

새 인덱스를 만들 때 :include 옵션을 사용하여 키가 아닌 열을 포함할 수 있습니다. 이 키는 검색을 위한 인덱스 스캔에 사용되지 않지만 관련 테이블을 방문하지 않고도 인덱스 전용 스캔 중에 읽을 수 있습니다.

# db/migrate/20131220144913_add_index_users_on_email_include_id.rb

add_index :users, :email, include: :id

여러 열을 지원합니다:

# db/migrate/20131220144913_add_index_users_on_email_include_id_and_created_at.rb

add_index :users, :email, include: [:id, :created_at]

생성된 열

참고: 생성된 열은 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

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

연기 가능한 외래 키

기본적으로 PostgreSQL의 테이블 제약 조건은 각 문장 직후에 즉시 확인됩니다. 참조된 레코드가 아직 참조 테이블에 없는 경우 레코드를 만들지 않습니다. 트랜잭션이 커밋될 때 무결성 검사를 나중에 실행하도록 DEFERRABLE을 외래 키 정의에 추가할 수 있습니다. 기본적으로 모든 검사를 연기하려면 DEFERRABLE INITIALLY DEFERRED로 설정할 수 있습니다. Rails는 add_referenceadd_foreign_key 메서드의 :deferrable 키를 추가하여 이 PostgreSQL 기능을 노출합니다.

순환 종속성을 트랜잭션에서 만드는 한 예는 다음과 같습니다:

add_reference :person, :alias, foreign_key: { deferrable: :deferred }
add_reference :alias, :person, foreign_key: { deferrable: :deferred }

참조가 foreign_key: true 옵션으로 생성된 경우 첫 번째 INSERT 문을 실행할 때 다음 트랜잭션이 실패합니다. 그러나 deferrable: :deferred 옵션이 설정되면 실패하지 않습니다.

ActiveRecord::Base.connection.transaction do
  person = Person.create(id: SecureRandom.uuid, alias_id: SecureRandom.uuid, name: "John Doe")
  Alias.create(id: person.alias_id, person_id: person.id, name: "jaydee")
end

:deferrable 옵션이 :immediate로 설정되면 기본 동작을 유지하고 set_constraints 내에서 수동으로 검사를 연기할 수 있습니다. 이렇게 하면 트랜잭션이 커밋될 때 외래 키가 확인됩니다:

ActiveRecord::Base.connection.transaction do
  ActiveRecord::Base.connection.set_constraints(:deferred)
  person = Person.create(alias_id: SecureRandom.uuid, name: "John Doe")
  Alias.create(id: person.alias_id, person_id: person.id, name: "jaydee")
end

기본적으로 :deferrablefalse이며 제약 조건은 항상 즉시 확인됩니다.

고유 제약 조건

# db/migrate/20230422225213_create_items.rb
create_table :items do |t|
  t.integer :position, null: false
  t.unique_constraint [:position], deferrable: :immediate
end

기존 고유 인덱스를 연기 가능하게 변경하려면 :using_index를 사용하여 연기 가능한 고유 제약 조건을 만들 수 있습니다.

add_unique_constraint :items, deferrable: :deferred, using_index: "index_items_on_position"

외래 키와 마찬가지로 :deferrable:immediate 또는 :deferred로 설정하여 고유 제약 조건을 연기할 수 있습니다. 기본적으로 :deferrablefalse이며 제약 조건은 항상 즉시 확인됩니다.

제외 제약 조건

# db/migrate/20131220144913_create_products.rb
create_table :products do |t|
  t.integer :price, null: false
  t.daterange :availability_range, null: false

  t.exclusion_constraint "price WITH =, availability_range WITH &&", using: :gist, name: "price_check"
end

외래 키와 마찬가지로 :deferrable:immediate 또는 :deferred로 설정하여 제외 제약 조건을 연기할 수 있습니다. 기본적으로 :deferrablefalse이며 제약 조건은 항상 즉시 확인됩니다.

전체 텍스트 검색

# 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'

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

## 'cat & dog'와 일치하는 모든 문서
Document.where("textsearchable_index_col @@ to_tsquery(?)", "cat & dog")

데이터베이스 뷰

다음과 같은 테이블이 포함된 레거시 데이터베이스로 작업해야 한다고 가정해 보겠습니다:

rails_pg_guide=# \d "TBL_ART"
                                        Table "public.TBL_ART"
   Column   |            Type             |                         네, 번역을 계속하겠습니다.

데이터베이스 뷰
--------------

* [뷰 생성](https://www.postgresql.org/docs/current/static/sql-createview.html)

다음과 같은 테이블이 포함된 레거시 데이터베이스로 작업해야 한다고 가정해 보겠습니다:

railspgguide=# \d “TBLART” Table “public.TBLART” Column | Type | Modifiers ————+—————————–+———————————————————— INTID | integer | not null default nextval(‘“TBLARTINTIDseq”’::regclass) STRTITLE | character varying | STRSTAT | character varying | default ‘draft’::character varying DTPUBLAT | timestamp without time zone | BLARCH | boolean | default false Indexes: “TBLARTpkey” PRIMARY KEY, btree (“INT_ID”) “`

이 테이블은 Rails 규칙을 전혀 따르지 않습니다. PostgreSQL 뷰는 기본적으로 업데이트 가능하므로 다음과 같이 래핑할 수 있습니다:

# 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

참고: 이 애플리케이션은 비보관 Articles만 관심이 있습니다. 뷰를 사용하면 보관된 Articles를 직접 제외할 수 있습니다.

구조 덤프

config.active_record.schema_format:sql인 경우 Rails는 pg_dump를 호출하여 구조 덤프를 생성합니다.

ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags를 사용하여 pg_dump를 구성할 수 있습니다. 예를 들어, 구조 덤프에서 주석을 제외하려면 이니셜라이저에 다음을 추가하세요:

ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = ["--no-comments"]