rubyonrails.org에서 더 보기:

GitHub에서 이 파일을 읽지 마세요. 가이드는 https://guides.rubyonrails.org 에서 제공됩니다.

Active Record 쿼리 인터페이스

이 가이드는 Active Record를 사용하여 데이터베이스에서 데이터를 검색하는 다양한 방법을 다룹니다.

이 가이드를 읽은 후에는 다음을 알게 될 것입니다:

  • 다양한 메서드와 조건을 사용하여 레코드를 찾는 방법
  • 검색된 레코드의 순서, 검색할 속성, 그룹화 및 기타 속성을 지정하는 방법
  • Eager loading을 사용하여 데이터 검색에 필요한 데이터베이스 쿼리 수를 줄이는 방법
  • 동적 finder 메서드를 사용하는 방법
  • 여러 Active Record 메서드를 함께 사용하기 위한 메서드 체이닝 방법
  • 특정 레코드의 존재 여부를 확인하는 방법
  • Active Record 모델에서 다양한 계산을 수행하는 방법
  • Relations에서 EXPLAIN을 실행하는 방법

1 Active Record 쿼리 인터페이스란 무엇인가?

데이터베이스 레코드를 찾기 위해 raw SQL을 사용하는 데 익숙하다면, Rails에서 동일한 작업을 수행하는 더 나은 방법이 있다는 것을 알게 될 것입니다. Active Record는 대부분의 경우 SQL을 사용할 필요성으로부터 여러분을 보호합니다.

Active Record는 데이터베이스에 대한 쿼리를 대신 수행하며 MySQL, MariaDB, PostgreSQL, SQLite를 포함한 대부분의 데이터베이스 시스템과 호환됩니다. 어떤 데이터베이스 시스템을 사용하든 Active Record 메서드 형식은 항상 동일합니다.

이 가이드의 코드 예제들은 다음 모델들 중 하나 이상을 참조할 것입니다:

다음의 모든 모델은 따로 지정하지 않는 한 id를 primary key로 사용합니다.

class Author < ApplicationRecord
  has_many :books, -> { order(year_published: :desc) }
end
class Book < ApplicationRecord
  belongs_to :supplier
  belongs_to :author  
  has_many :reviews
  has_and_belongs_to_many :orders, join_table: "books_orders"

  scope :in_print, -> { where(out_of_print: false) } # 재고가 있는 책
  scope :out_of_print, -> { where(out_of_print: true) } # 절판된 책  
  scope :old, -> { where(year_published: ...50.years.ago.year) } # 50년 이상 된 책
  scope :out_of_print_and_expensive, -> { out_of_print.where("price > 500") } # 절판되고 비싼 책
  scope :costs_more_than, ->(amount) { where("price > ?", amount) } # 특정 가격보다 비싼 책
end
class Customer < ApplicationRecord
  has_many :orders
  has_many :reviews
end
class Order < ApplicationRecord
  belongs_to :customer
  has_and_belongs_to_many :books, join_table: "books_orders" 

  enum :status, [:shipped, :being_packed, :complete, :cancelled]

  scope :created_before, ->(time) { where(created_at: ...time) }
end
class Review < ApplicationRecord
  belongs_to :customer
  belongs_to :book

  enum :state, [:not_reviewed, :published, :hidden]
end
class Supplier < ApplicationRecord
  has_many :books
  has_many :authors, through: :books
end

Diagram of all of the bookstore models

2 데이터베이스에서 객체 검색하기

데이터베이스에서 객체를 검색하기 위해 Active Record는 여러 finder 메서드를 제공합니다. 각 finder 메서드를 사용하면 raw SQL을 작성하지 않고도 데이터베이스에서 특정 쿼리를 수행할 수 있도록 인자를 전달할 수 있습니다.

제공되는 메서드는 다음과 같습니다:

wheregroup같이 컬렉션을 반환하는 finder 메서드는 ActiveRecord::Relation 인스턴스를 반환합니다. findfirst같이 단일 엔티티를 찾는 메서드는 해당 모델의 단일 인스턴스를 반환합니다.

Model.find(options)의 주요 동작은 다음과 같이 요약할 수 있습니다:

  • 제공된 options를 동등한 SQL 쿼리로 변환합니다.
  • SQL 쿼리를 실행하고 데이터베이스에서 해당하는 결과를 검색합니다.
  • 결과로 나온 각 행에 대해 적절한 모델의 Ruby 객체를 인스턴스화합니다.
  • 있다면 after_findafter_initialize 콜백을 실행합니다.

2.1 단일 객체 조회하기

Active Record는 단일 객체를 조회하기 위한 여러가지 방법을 제공합니다.

2.1.1 find

find 메서드를 사용하면 지정된 primary key 에 해당하는 객체를 조회할 수 있으며, 추가로 지정한 옵션들과 매칭됩니다. 예를 들면:

# primary key(id)가 10인 customer를 찾습니다.
irb> customer = Customer.find(10)
=> #<Customer id: 10, first_name: "Ryan">

위 내용의 SQL 표현은 다음과 같습니다:

SELECT * FROM customers WHERE (customers.id = 10) LIMIT 1

find 메서드는 매칭되는 레코드를 찾지 못할 경우 ActiveRecord::RecordNotFound exception을 발생시킵니다.

이 메서드를 사용하여 여러 객체를 조회할 수도 있습니다. find 메서드를 호출하고 primary key 배열을 전달하면 됩니다. 반환값은 제공된 primary keys에 매칭되는 모든 레코드를 포함하는 배열입니다. 예를 들면:

# primary key가 1과 10인 customer를 찾습니다.
irb> customers = Customer.find([1, 10]) # 또는 Customer.find(1, 10)
=> [#<Customer id: 1, first_name: "Lifo">, #<Customer id: 10, first_name: "Ryan">]

위 코드의 SQL 동등문은 다음과 같습니다:

SELECT * FROM customers WHERE (customers.id IN (1,10))

모든 제공된 primary key에 대해 일치하는 레코드를 찾을 수 없는 경우 find 메서드는 ActiveRecord::RecordNotFound exception을 발생시킵니다.

테이블이 composite primary key를 사용하는 경우, 단일 항목을 찾기 위해 find에 배열을 전달해야 합니다. 예를 들어, customers가 [:store_id, :id]를 primary key로 정의된 경우:

# store_id가 3이고 id가 17인 customer를 찾음
irb> customers = Customer.find([3, 17])
=> #<Customer store_id: 3, id: 17, first_name: "Magda">

위 코드에 대한 SQL 표현은 다음과 같습니다:

SELECT * FROM customers WHERE store_id = 3 AND id = 17

복합 ID를 가진 여러 customer를 찾으려면, 배열의 배열을 전달해야 합니다:

# primary key가 [1, 8] 및 [7, 15]인 customer를 찾습니다.
irb> customers = Customer.find([[1, 8], [7, 15]]) # 또는 Customer.find([1, 8], [7, 15])
=> [#<Customer store_id: 1, id: 8, first_name: "Pat">, #<Customer store_id: 7, id: 15, first_name: "Chris">]

위 코드의 SQL 동등 표현은 다음과 같습니다:

SELECT * FROM customers WHERE (store_id = 1 AND id = 8 OR store_id = 7 AND id = 15)

2.1.2 take

take 메서드는 어떤 암시적인 ordering 없이 record를 검색합니다. 예를 들어:

irb> customer = Customer.take
=> #<Customer id: 1, first_name: "Lifo">

위의 SQL 등가식은 다음과 같습니다:

SELECT * FROM customers LIMIT 1

take 메소드는 레코드가 발견되지 않으면 nil을 반환하며 예외가 발생하지 않습니다.

take 메소드에 숫자 인자를 전달하여 해당 개수만큼의 결과를 반환받을 수 있습니다. 예를 들어

irb> customers = Customer.take(2)
=> [#<Customer id: 1, first_name: "Lifo">, #<Customer id: 220, first_name: "Sara">]

위 코드와 동등한 SQL은 다음과 같습니다:

SELECT * FROM customers LIMIT 2

take! 메서드는 take와 정확히 동일하게 동작하지만, 일치하는 레코드를 찾지 못할 경우 ActiveRecord::RecordNotFound를 발생시킵니다.

검색된 레코드는 데이터베이스 엔진에 따라 다를 수 있습니다.

2.1.3 first

first 메서드는 primary key로 정렬된(기본값) 첫 번째 레코드를 찾습니다. 예시:

irb> customer = Customer.first
=> #<Customer id: 1, first_name: "Lifo">

위 코드와 동일한 SQL은 다음과 같습니다:

SELECT * FROM customers ORDER BY customers.id ASC LIMIT 1

first 메서드는 일치하는 레코드가 없을 경우 nil을 반환하며 예외가 발생하지 않습니다.

만약 default scope에 order 메서드가 포함되어 있다면, first는 이 정렬 순서에 따라 첫 번째 레코드를 반환합니다.

first 메서드에 숫자 인자를 전달하여 해당 개수만큼의 결과를 반환할 수 있습니다. 예를 들면

irb> customers = Customer.first(3)
=> [#<Customer id: 1, first_name: "Lifo">, #<Customer id: 2, first_name: "Fifo">, #<Customer id: 3, first_name: "Filo">]

위 코드의 SQL 등가물은 다음과 같습니다:

SELECT * FROM customers ORDER BY customers.id ASC LIMIT 3

복합 primary key를 가진 model들은 정렬시 전체 복합 primary key를 사용합니다. 예를 들어, customers가 [:store_id, :id]를 primary key로 정의되어 있다면:

irb> customer = Customer.first
=> #<Customer id: 2, store_id: 1, first_name: "Lifo">

위 내용에 대한 SQL 표현은 다음과 같습니다:

SELECT * FROM customers ORDER BY customers.store_id ASC, customers.id ASC LIMIT 1

order를 사용해 정렬된 컬렉션에서 firstorder에서 지정한 속성으로 정렬된 첫 번째 레코드를 반환합니다.

irb> customer = Customer.order(:first_name).first
=> #<Customer id: 2, first_name: "Fifo">

위의 SQL 표현은 다음과 같습니다:

SELECT * FROM customers ORDER BY customers.first_name ASC LIMIT 1

first! 메서드는 first와 정확히 동일하게 동작하지만, 일치하는 레코드를 찾지 못할 경우 ActiveRecord::RecordNotFound를 발생시킨다는 점이 다릅니다.

2.1.4 last

last 메서드는 primary key로 정렬된(기본값) 마지막 레코드를 찾습니다. 예를 들면:

irb> customer = Customer.last
=> #<Customer id: 221, first_name: "Russel">

위의 내용에 대한 SQL 표현식은 다음과 같습니다:

SELECT * FROM customers ORDER BY customers.id DESC LIMIT 1

last 메서드는 일치하는 레코드가 없을 경우 nil을 반환하며 예외가 발생하지 않습니다.

복합 primary key를 가진 모델은 전체 복합 primary key를 정렬에 사용합니다. 예를 들어, customers가 [:store_id, :id]를 primary key로 정의된 경우:

irb> customer = Customer.last
=> #<Customer id: 221, store_id: 1, first_name: "Lifo">

위의 내용에 대한 SQL은 다음과 같습니다:

SELECT * FROM customers ORDER BY customers.store_id DESC, customers.id DESC LIMIT 1

default scope에 order 메서드가 포함되어 있다면, last는 이 정렬 순서에 따른 마지막 레코드를 반환합니다.

last 메서드에 숫자를 인자로 전달하여 해당 개수만큼의 결과를 반환할 수 있습니다. 예를 들어

irb> customers = Customer.last(3)
=> [#<Customer id: 219, first_name: "James">, #<Customer id: 220, first_name: "Sara">, #<Customer id: 221, first_name: "Russel">]

위의 Rails 코드와 동등한 SQL 쿼리는 다음과 같습니다:

SELECT * FROM customers ORDER BY customers.id DESC LIMIT 3

order를 사용하여 정렬된 컬렉션에서, lastorder에 명시된 속성으로 정렬된 레코드 중 마지막 레코드를 반환합니다.

irb> customer = Customer.order(:first_name).last
=> #<Customer id: 220, first_name: "Sara">

위 코드와 동등한 SQL은 다음과 같습니다:

SELECT * FROM customers ORDER BY customers.first_name DESC LIMIT 1

last! 메서드는 last처럼 동작하지만, 일치하는 레코드를 찾지 못할 경우 ActiveRecord::RecordNotFound를 발생시킨다는 점이 다릅니다.

2.1.5 find_by

find_by 메서드는 주어진 조건에 일치하는 첫 번째 레코드를 찾습니다. 예를 들면:

irb> Customer.find_by first_name: 'Lifo'
=> #<Customer id: 1, first_name: "Lifo">

irb> Customer.find_by first_name: 'Jon' 
=> nil

이는 아래와 같이 작성하는 것과 동일합니다:

Customer.where(first_name: "Lifo").take

where 조건과 일치하는 record 하나를 가져옵니다.

위의 SQL 표현식은 다음과 같습니다:

SELECT * FROM customers WHERE (customers.first_name = 'Lifo') LIMIT 1

위의 SQL에는 ORDER BY가 없다는 점에 유의하세요. find_by 조건이 여러 레코드와 일치할 수 있는 경우, 결정론적 결과를 보장하기 위해 order를 적용해야 합니다.

find_by! 메서드는 find_by와 정확히 동일하게 동작하지만, 일치하는 레코드를 찾지 못할 경우 ActiveRecord::RecordNotFound를 발생시킨다는 점이 다릅니다. 예를 들어:

irb> Customer.find_by! first_name: 'does not exist'
ActiveRecord::RecordNotFound

이는 다음과 같이 작성하는 것과 동일합니다:

Customer.where(first_name: "does not exist").take!

일치하는 레코드를 찾지 못하면 ActiveRecord::RecordNotFound 예외를 발생시킵니다.

2.1.5.1 :id를 사용한 조건

find_bywhere와 같은 메서드에서 조건을 지정할 때, id의 사용은 모델의 :id 속성과 매칭됩니다. 이는 전달된 ID가 primary key 값이어야 하는 find와는 다릅니다.

:id가 primary key가 아닌 모델(예: 복합 primary key 모델)에서 find_by(id:)를 사용할 때는 주의가 필요합니다. 예를 들어, customers가 [:store_id, :id]를 primary key로 정의된 경우:

irb> customer = Customer.last
=> #<Customer id: 10, store_id: 5, first_name: "Joe"> 
irb> Customer.find_by(id: customer.id) # Customer.find_by(id: [5, 10])
=> #<Customer id: 5, store_id: 3, first_name: "Bob">

여기서는 복합 기본키 [5, 10]을 가진 단일 레코드를 검색하려고 할 수 있지만, Active Record는 :id 컬럼이 5 또는 10인 레코드를 검색하게 되어 잘못된 레코드를 반환할 수 있습니다.

id_value 메서드는 find_bywhere 같은 finder 메서드에서 사용하기 위해 레코드의 :id 컬럼 값을 가져오는 데 사용할 수 있습니다. 아래 예시를 참조하세요:

irb> customer = Customer.last
=> #<Customer id: 10, store_id: 5, first_name: "Joe">
irb> Customer.find_by(id: customer.id_value) # Customer.find_by(id: 10)
=> #<Customer id: 10, store_id: 5, first_name: "Joe">

2.2 다수의 객체를 배치로 가져오기

많은 레코드를 순회해야 하는 경우가 있습니다. 예를 들어 많은 고객들에게 뉴스레터를 보내거나 데이터를 내보낼 때입니다.

이는 직관적으로 보일 수 있습니다:

# 테이블이 큰 경우 너무 많은 메모리를 소비할 수 있습니다.
Customer.all.each do |customer|
  NewsMailer.weekly(customer).deliver_now
end

하지만 이 방식은 테이블 크기가 증가할수록 점점 더 비실용적이 됩니다. Customer.all.each는 Active Record에게 전체 테이블을 한 번에 가져와서, 각 행마다 model 객체를 생성하고, 그 model 객체들의 전체 배열을 메모리에 유지하도록 지시하기 때문입니다. 실제로 레코드 수가 많은 경우, 전체 컬렉션이 사용 가능한 메모리 양을 초과할 수 있습니다.

Rails는 이 문제를 해결하기 위해 레코드를 메모리 친화적인 batch로 나누어 처리하는 두 가지 메서드를 제공합니다. 첫 번째 메서드인 find_each는 레코드의 batch를 가져온 다음 레코드를 model로서 개별적으로 블록에 yield합니다. 두 번째 메서드인 find_in_batches는 레코드의 batch를 가져온 다음 전체 batch를 model 배열로 블록에 yield합니다.

find_eachfind_in_batches 메서드는 한 번에 메모리에 모두 담을 수 없는 많은 수의 레코드를 batch 처리하는 데 사용하기 위한 것입니다. 단순히 천 개 정도의 레코드를 순회해야 한다면 일반적인 find 메서드가 선호되는 옵션입니다.

2.2.1 find_each

find_each 메서드는 레코드를 batch로 가져온 다음 레코드를 블록에 yield합니다. 다음 예시에서 find_each는 1000개씩 batch로 customer를 가져와서 하나씩 블록에 yield합니다:

Customer.find_each do |customer|
  NewsMailer.weekly(customer).deliver_now
end

위 코드를 통해 모든 고객에게 뉴스레터를 발송합니다.

이 처리는 모든 레코드가 처리될 때까지 필요에 따라 더 많은 batch를 가져오면서 반복됩니다.

find_each는 위에서 본 것처럼 model 클래스에서 동작하며, relation에서도 동작합니다:

Customer.where(weekly_subscriber: true).find_each do |customer|
  NewsMailer.weekly(customer).deliver_now
end

주간 구독자인 고객들에게 이메일을 보내는 코드입니다.

정렬이 없는 한, 메서드가 내부적으로 순서를 강제해야 하기 때문입니다.

수신자에 정렬이 있는 경우 동작은 config.active_record.error_on_ignored_order 플래그에 따라 달라집니다. true인 경우 ArgumentError가 발생하고, 그렇지 않으면 정렬이 무시되고 경고가 발생하며 이것이 기본값입니다. 이는 아래에서 설명하는 :error_on_ignore 옵션으로 재정의할 수 있습니다.

2.2.1.1 find_each의 옵션들

:batch_size

:batch_size 옵션을 사용하면 블록에 개별적으로 전달되기 전에 각 배치에서 검색할 레코드 수를 지정할 수 있습니다. 예를 들어 5000개 단위로 레코드를 검색하려면:

Customer.find_each(batch_size: 5000) do |customer|
  NewsMailer.weekly(customer).deliver_now
end

해당 코드는 한 번에 5000개의 record를 가져와서 모든 customer에게 뉴스레터를 보냅니다.

:start

기본적으로 레코드는 primary key의 오름차순으로 가져옵니다. :start 옵션을 사용하면 가장 낮은 ID가 필요한 ID가 아닐 때 시퀀스의 첫 번째 ID를 구성할 수 있습니다. 예를 들어, 마지막으로 처리된 ID를 체크포인트로 저장했다면 중단된 배치 프로세스를 다시 시작하고자 할 때 유용할 수 있습니다.

예를 들어, primary key가 2000부터 시작하는 고객에게만 뉴스레터를 보내려면 다음과 같이 합니다:

Customer.find_each(start: 2000) do |customer|
  NewsMailer.weekly(customer).deliver_now
end

:finish

:start 옵션과 비슷하게, :finish는 가장 높은 ID가 필요한 ID가 아닌 경우 시퀀스의 마지막 ID를 설정할 수 있게 해줍니다. 이는 예를 들어 :start:finish를 기반으로 레코드의 일부를 사용하여 배치 프로세스를 실행하고 싶을 때 유용할 수 있습니다.

예를 들어, primary key가 2000부터 10000까지인 고객들에게만 뉴스레터를 보내려면:

Customer.find_each(start: 2000, finish: 10000) do |customer|
  NewsMailer.weekly(customer).deliver_now
end

다른 예시로는 여러 worker가 동일한 처리 queue를 처리하도록 하고 싶은 경우입니다. 각 worker에게 적절한 :start:finish 옵션을 설정하여 각각 10000개의 레코드를 처리하도록 할 수 있습니다.

:error_on_ignore

relation에 order가 있을 때 error를 발생시킬지 여부를 지정하기 위해 애플리케이션 config를 재정의합니다.

:order

primary key order를 지정합니다(:asc 또는 :desc일 수 있음). 기본값은 :asc입니다.

Customer.find_each(order: :desc) do |customer|
  NewsMailer.weekly(customer).deliver_now
end

2.2.2 find_in_batches

find_in_batches는 둘 다 레코드 배치를 검색하므로 find_each와 유사합니다. 차이점은 find_in_batches가 개별적으로가 아닌 모델의 배열로 배치 를 블록에 전달한다는 것입니다. 다음 예제는 한 번에 최대 1000명의 customers 배열을 제공된 블록에 전달하며, 마지막 블록에는 남아있는 customers가 포함됩니다:

# add_customers에 한 번에 1000명의 고객 배열을 전달합니다.
Customer.find_in_batches do |customers|
  export.add_customers(customers)
end

find_in_batches는 위에서 본 것처럼 model 클래스에서 작동하며, relation에서도 작동합니다:

# add_customers에 한번에 1000명의 최근 활동한 고객들의 배열을 전달합니다.
Customer.recently_active.find_in_batches do |customers|
  export.add_customers(customers)
end

내부적으로 순서를 강제해야 하므로 정렬이 없는 한에서만 가능합니다.

2.2.2.1 find_in_batches의 옵션들

find_in_batches 메서드는 find_each와 동일한 옵션을 받습니다:

:batch_size

find_each와 마찬가지로, batch_size는 각 그룹에서 검색할 레코드의 수를 지정합니다. 예를 들어, 2500개의 레코드를 배치로 검색하려면 다음과 같이 지정할 수 있습니다:

Customer.find_in_batches(batch_size: 2500) do |customers|
  export.add_customers(customers)
end

:start

start 옵션은 레코드를 선택할 시작 ID를 지정할 수 있습니다. 앞서 언급했듯이, 기본적으로 레코드는 primary key의 오름차순으로 가져옵니다. 예를 들어, ID: 5000부터 시작해서 2500개씩 고객 레코드를 가져오려면 다음 코드를 사용할 수 있습니다:

Customer.find_in_batches(batch_size: 2500, start: 5000) do |customers|
  export.add_customers(customers) 
end

:start 옵션을 통해 레코드를 검색할 시작 id를 지정할 수 있습니다. 이는 이전 배치 처리가 특정 id에서 중단되었을 때 유용합니다.

:finish

finish 옵션은 검색할 레코드의 마지막 ID를 지정할 수 있습니다. 아래 코드는 ID가 7000인 customer까지 customer를 batch로 검색하는 사례를 보여줍니다:

Customer.find_in_batches(finish: 7000) do |customers|
  export.add_customers(customers)
end

:error_on_ignore

error_on_ignore 옵션은 relation에 특정 순서가 존재할 때 에러를 발생시킬지 여부를 지정하기 위해 애플리케이션 설정을 재정의합니다.

3 Conditions

where 메서드는 반환되는 레코드를 제한하는 조건을 지정할 수 있게 해주며, SQL 문의 WHERE 부분을 나타냅니다. 조건은 문자열, 배열 또는 해시로 지정할 수 있습니다.

3.1 Pure String Conditions

find에 조건을 추가하고 싶다면, Book.where("title = 'Introduction to Algorithms'")와 같이 지정할 수 있습니다. 이는 title 필드 값이 'Introduction to Algorithms'인 모든 책을 찾을 것입니다.

순수 문자열로 직접 조건을 만드는 것은 SQL injection 공격에 취약할 수 있습니다. 예를 들어, Book.where("title LIKE '%#{params[:title]}%'")는 안전하지 않습니다. 다음 섹션에서 array를 사용한 조건 처리의 선호되는 방법을 확인하세요.

3.2 Array 조건들

만약 타이틀이 어딘가의 인수로 변경될 수 있다면 어떻게 될까요? find는 다음과 같은 형태를 가질 것입니다:

Book.where("title = ?", params[:title])

Active Record는 첫 번째 인수를 조건 문자열로 사용하며 추가 인수는 조건문에서 물음표 (?)를 대체합니다.

여러 개의 조건을 지정하고 싶다면:

Book.where("title = ? AND out_of_print = ?", params[:title], false)

이 예시에서 첫 번째 물음표는 params[:title] 값으로 대체되고, 두 번째 물음표는 adapter에 따라 다른 SQL의 false 표현으로 대체됩니다.

아래의 코드가 더 선호됩니다:

Book.where("title = ?", params[:title])

"이 코드로:" 로 번역됩니다.

Book.where("title = #{params[:title]}")

절대로 이런 코드를 사용하지 마세요. 이는 params[:title] 값이 SQL에 직접 주입되기 때문에 SQL 인젝션 공격에 취약합니다.

인수 안전성 때문입니다. 변수를 직접 조건 문자열에 넣으면 변수가 데이터베이스에 있는 그대로 전달됩니다. 이는 악의적인 의도를 가진 사용자로부터 직접 받은 이스케이프되지 않은 변수가 될 수 있다는 것을 의미합니다. 이렇게 하면 사용자가 데이터베이스를 악용할 수 있다는 것을 알게 되면 데이터베이스에 거의 모든 것을 할 수 있기 때문에 전체 데이터베이스가 위험해집니다. 절대로 인수를 조건 문자열 안에 직접 넣지 마세요.

SQL injection의 위험성에 대한 자세한 정보는 Ruby on Rails Security Guide를 참조하세요.

3.2.1 Placeholder 조건

params의 (?) 대체 스타일과 유사하게, 조건 문자열에 키를 지정하고 이에 대응하는 키/값 해시를 함께 지정할 수 있습니다:

Book.where("created_at >= :start_date AND created_at <= :end_date",
  { start_date: params[:start_date], end_date: params[:end_date] })

많은 수의 가변 조건이 있을 때 더욱 명확한 가독성을 제공합니다.

3.2.2 LIKE를 사용하는 조건

condition 인자가 SQL injection을 방지하기 위해 자동으로 이스케이프(escape)되지만, SQL LIKE의 와일드카드(예: %_)는 이스케이프되지 않습니다. 이는 sanitize되지 않은 값이 인자로 사용될 때 예기치 않은 동작을 일으킬 수 있습니다. 예를 들어:

Book.where("title LIKE ?", params[:title] + "%")

위 코드에서는 사용자가 지정한 문자열로 시작하는 제목을 매칭하려고 합니다. 하지만 params[:title]에 포함된 %_는 와일드카드로 처리되어 예상치 않은 쿼리 결과를 초래할 수 있습니다. 이로 인해 데이터베이스가 의도한 index를 사용하지 못하게 되어 쿼리가 훨씬 느려질 수 있는 상황도 발생할 수 있습니다.

이러한 문제를 피하려면 sanitize_sql_like를 사용하여 인자의 관련 부분에서 와일드카드 문자를 이스케이프 처리하세요:

Book.where("title LIKE ?",
  Book.sanitize_sql_like(params[:title]) + "%")

3.3 Hash 조건

Active Record는 조건문의 가독성을 높일 수 있는 hash 조건을 전달할 수도 있습니다. Hash 조건을 사용하면 검색하려는 필드를 key로, 그에 대한 검색 조건을 value로 하는 hash를 전달합니다:

Hash 조건에서는 동등성, 범위, 부분집합 검사만 가능합니다.

3.3.1 동등성 조건

Book.where(out_of_print: true)

이렇게 하면 다음과 같은 SQL이 생성됩니다:

SELECT * FROM books WHERE (books.out_of_print = 1)

필드 이름은 문자열(string)로도 지정할 수 있습니다:

Book.where("out_of_print" => true)

belongs_to 관계에서는 Active Record 객체가 값으로 사용되는 경우 모델을 지정하기 위해 association key를 사용할 수 있습니다. 이 방법은 polymorphic 관계에서도 동작합니다.

author = Author.first
Book.where(author: author)
Author.joins(:books).where(books: { author: author })

Hash 조건은 key가 컬럼의 배열이고 value가 튜플의 배열인 튜플과 유사한 문법으로도 지정할 수 있습니다:

Book.where([:author_id, :id] => [[15, 1], [15, 2]])

이 구문은 테이블이 composite primary key를 사용하는 relation을 쿼리할 때 유용할 수 있습니다:

class Book < ApplicationRecord
  self.primary_key = [:author_id, :id]
end

Book.where(Book.primary_key => [[2, 1], [3, 1]])

이 코드는 composite primary key(복합 기본키)로 구성된 author_idid를 가진 Book 모델의 예시입니다. Book.where 쿼리는 author_id가 2이고 id가 1인 책과, author_id가 3이고 id가 1인 책을 찾습니다.

3.3.2 Range Conditions

Range condition은 BETWEEN SQL 표현식을 사용해 필드가 주어진 값 범위에 있는지 확인합니다. Range condition은 Ruby의 range object를 사용하여 생성됩니다:

Client.where(created_at: (Time.now.midnight - 1.day)..Time.now.midnight)

이는 다음 SQL을 생성합니다:

SELECT * FROM clients WHERE (clients.created_at BETWEEN '2008-12-21 00:00:00' AND '2008-12-22 00:00:00')

이는 Array Conditions를 사용하는 것과 동등합니다.

Client.where(created_at: ['2008-12-21 00:00:00', '2008-12-22 00:00:00'])

Range condition이 BETWEEN을 사용하는 반면, array condition은 IN을 사용합니다. 이 차이점은 시간 값을 비교할 때 중요할 수 있습니다.

객체가 속성을 받아들일 수 있을 때(responded_at과 같은), Range는 SQL 표현식에서 정의된 형태대로 속성이 NULL을 포함하는지 확인하는데 사용될 수 있습니다:

Student.where(response_time: nil..Float::INFINITY)

이는 다음 SQL을 생성합니다:

SELECT * FROM students WHERE (students.response_time <= INFINITY OR students.response_time IS NULL)
Book.where(created_at: (Time.now.midnight - 1.day)..Time.now.midnight)

Book을 생성한 시간이 어제 자정부터 오늘 자정 사이인 레코드를 찾습니다.

이것은 SQL의 BETWEEN 구문을 사용하여 어제 생성된 모든 books를 찾을 것입니다:

SELECT * FROM books WHERE (books.created_at BETWEEN '2008-12-21 00:00:00' AND '2008-12-22 00:00:00')

Array Conditions에서 예시로 든 보다 짧은 구문을 보여줍니다.

시작과 끝이 없는 range가 지원되며, 이는 미만/초과 조건을 만드는 데 사용할 수 있습니다.

Book.where(created_at: (Time.now.midnight - 1.day)..)

어제 자정 이후에 생성된 모든 책을 가져옵니다

이는 다음과 같은 SQL을 생성할 것입니다:

SELECT * FROM books WHERE books.created_at >= '2008-12-21 00:00:00'

3.3.3 서브셋 조건

만약 IN 표현식을 사용하여 레코드를 찾고 싶다면 conditions 해시에 배열을 전달할 수 있습니다:

Customer.where(orders_count: [1, 3, 5])

orders_count가 1, 3 또는 5인 고객을 찾습니다.

이 코드는 다음과 같은 SQL을 생성할 것입니다:

SELECT * FROM customers WHERE (customers.orders_count IN (1,3,5))

3.4 NOT 조건

NOT SQL 쿼리는 where.not을 사용하여 구성할 수 있습니다:

Customer.where.not(orders_count: [1, 3, 5])

orders_count가 1, 3, 5가 아닌 Customer를 찾습니다.

다시 말해서, 이 쿼리는 where를 인수 없이 호출한 다음 즉시 not을 체이닝하여 where 조건을 전달하는 방식으로 생성할 수 있습니다. 이는 이와 같은 SQL을 생성할 것입니다:

SELECT * FROM customers WHERE (customers.orders_count NOT IN (1,3,5))

만약 쿼리가 nullable 컬럼에 대해 nil이 아닌 값을 가진 hash 조건을 가지고 있다면, nullable 컬럼에 nil 값을 가진 레코드는 반환되지 않을 것입니다. 예를 들어:

Customer.create!(nullable_country: nil)
Customer.where.not(nullable_country: "UK")
# => []

# 하지만
Customer.create!(nullable_country: "UK")
Customer.where.not(nullable_country: nil) 
# => [#<Customer id: 2, nullable_country: "UK">]

3.5 OR 조건

두 relation간의 OR 조건은 첫번째 relation에 [or][]를 호출하고 두번째 relation을 인자로 전달하여 생성할 수 있습니다.

Customer.where(last_name: "Smith").or(Customer.where(orders_count: [1, 3, 5]))
SELECT * FROM customers WHERE (customers.last_name = 'Smith' OR customers.orders_count IN (1,3,5))

or

3.6 AND 조건

AND 조건은 where 조건들을 연결하여 만들 수 있습니다.

Customer.where(last_name: "Smith").where(orders_count: [1, 3, 5])

Customer에서 last_name이 "Smith"이며 orders_count가 1, 3, 5 중 하나인 레코드를 조회합니다.

SELECT * FROM customers WHERE customers.last_name = 'Smith' AND customers.orders_count IN (1,3,5)

관계들 간의 논리적 교집합을 위한 AND 조건은 첫 번째 relation에서 [and][]를 호출하고 두 번째 것을 인자로 전달하여 만들 수 있습니다.

Customer.where(id: [1, 2]).and(Customer.where(id: [2, 3]))
SELECT * FROM customers WHERE (customers.id IN (1, 2) AND customers.id IN (2, 3))

4 Ordering

데이터베이스로부터 특정한 순서로 레코드를 검색하려면 order 메소드를 사용할 수 있습니다.

예를 들어, 레코드 세트를 가져올 때 테이블의 created_at 필드를 기준으로 오름차순으로 정렬하려면:

Book.order(:created_at)
# 또는
Book.order("created_at")

ASC 또는 DESC도 지정할 수 있습니다:

Book.order(created_at: :desc)
# 또는
Book.order(created_at: :asc)
# 또는 
Book.order("created_at DESC")
# 또는
Book.order("created_at ASC")

또는 여러 필드로 정렬할 수 있습니다:

Book.order(title: :asc, created_at: :desc)
# 또는
Book.order(:title, created_at: :desc)
# 또는
Book.order("title ASC, created_at DESC") 
# 또는
Book.order("title ASC", "created_at DESC")

여러 번 order를 호출하고자 한다면, 뒤이어 나오는 order들은 첫 번째 order에 추가됩니다:

irb> Book.order("title ASC").order("created_at DESC")
SELECT * FROM books ORDER BY title ASC, created_at DESC

조인된 테이블에서도 정렬할 수 있습니다.

Book.includes(:author).order(books: { print_year: :desc }, authors: { name: :asc })
# 또는
Book.includes(:author).order("books.print_year desc", "authors.name asc")

대부분의 데이터베이스 시스템에서 select, pluck, ids와 같은 메서드를 사용하여 결과 세트에서 distinct로 필드를 선택할 때, order 절에서 사용된 필드가 select 목록에 포함되어 있지 않으면 order 메서드는 ActiveRecord::StatementInvalid 예외를 발생시킵니다. 결과 세트에서 필드를 선택하는 방법은 다음 섹션을 참조하세요.

5 Selecting Specific Fields

기본적으로 Model.findselect *를 사용하여 결과 세트의 모든 필드를 선택합니다.

결과 세트에서 필드의 하위 집합만 선택하려면, select 메서드를 통해 하위 집합을 지정할 수 있습니다.

예를 들어, isbnout_of_print 컬럼만 선택하려면:

Book.select(:isbn, :out_of_print) 
# 또는
Book.select("isbn, out_of_print")

이 find 호출에서 사용하는 SQL 쿼리는 다음과 비슷할 것입니다:

SELECT isbn, out_of_print FROM books

이는 선택한 필드로만 모델 객체를 초기화한다는 의미이므로 주의가 필요합니다. 초기화된 레코드에 없는 필드에 접근하려고 하면 다음과 같은 오류가 발생합니다:

ActiveModel::MissingAttributeError: Book 모델에서 '<attribute>' 속성이 누락되었습니다

여기서 <attribute>는 요청한 속성입니다. id 메서드는 ActiveRecord::MissingAttributeError를 발생시키지 않으므로, 연관관계를 다룰 때 주의해야 합니다. 연관관계가 제대로 작동하려면 id 메서드가 필요하기 때문입니다.

특정 필드의 고유한 값마다 하나의 레코드만 가져오고 싶다면, distinct를 사용할 수 있습니다:

Customer.select(:last_name).distinct

에서는 중복되지 않은 last_name들을 검색합니다.

이는 다음과 같은 SQL을 생성할 것입니다:

SELECT DISTINCT last_name FROM customers

uniqueness 제약조건을 제거할 수도 있습니다:

# 유니크한 last_names를 반환
query = Customer.select(:last_name).distinct

# 중복이 있더라도 모든 last_names를 반환
query.distinct(false)

6 Limit과 Offset

Model.find에 의해 발생한 SQL에 LIMIT을 적용하기 위해서는, relation에 limitoffset 메서드를 사용하여 LIMIT를 지정할 수 있습니다.

limit를 사용하여 검색할 레코드 수를 지정하고, offset을 사용하여 레코드를 반환하기 전에 건너뛸 레코드 수를 지정할 수 있습니다. 예를 들면

Customer.limit(5)

offset이 지정되지 않았으므로 테이블의 처음부터 최대 5명의 customers를 반환할 것입니다. 실행되는 SQL은 다음과 같습니다:

SELECT * FROM customers LIMIT 5

offset을 추가하기

Customer.limit(5).offset(30)

Customer를 30번째부터 5개만 골라냅니다.

31번째부터 시작하는 최대 5명의 customer를 반환할 것입니다. SQL은 다음과 같습니다:

SELECT * FROM customers LIMIT 5 OFFSET 30

7 Grouping

SQL finder에서 GROUP BY 절을 적용하려면 group 메소드를 사용할 수 있습니다.

예를 들어, order가 생성된 날짜들을 collection으로 찾으려면:

Order.select("created_at").group("created_at")

이는 데이터베이스에 order가 있는 각 날짜에 대해 하나의 Order 객체를 반환할 것입니다.

실행되는 SQL은 다음과 같습니다:

SELECT created_at
FROM orders 
GROUP BY created_at

7.1 Grouped Items의 합계

단일 쿼리에서 grouped items의 합계를 구하려면, group 뒤에 count를 호출하세요.

irb> Order.group(:status).count
=> {"being_packed"=>7, "shipped"=>12} # 포장 중인 주문이 7개, 배송된 주문이 12개

다음과 같은 SQL이 실행될 것입니다:

SELECT COUNT (*) AS count_all, status AS status
FROM orders
GROUP BY status

7.2 HAVING 조건절

SQL은 GROUP BY 필드에 조건을 지정하기 위해 HAVING 절을 사용합니다. Model.find에서 실행되는 SQL에 having 메서드를 추가하여 HAVING 절을 추가할 수 있습니다.

예시:

Order.select("created_at을 ordered_date으로, sum(total)을 total_price로").
  group("created_at").having("sum(total) > ?", 200)

실행될 SQL은 다음과 같을 것입니다:

SELECT created_at as ordered_date, sum(total) as total_price
FROM orders
GROUP BY created_at
HAVING sum(total) > 200

이는 주문된 날짜별로 그룹화되고 총액이 $200를 초과하는 각 order 객체의 날짜와 총 가격을 반환합니다.

반환된 각 order 객체의 total_price에는 다음과 같이 접근할 수 있습니다:

big_orders = Order.select("created_at, sum(total) as total_price")
                  .group("created_at")
                  .having("sum(total) > ?", 200)

big_orders[0].total_price  
# 첫 번째 Order 객체의 total price를 반환합니다

8 Conditions 재정의

8.1 unscope

unscope 메서드를 사용하여 제거할 특정 조건을 지정할 수 있습니다. 예를 들면:

Book.where("id > 100").limit(20).order("id desc").unscope(:order)

.unscope를 사용하여 이전에 적용된 특정 scope를 제거할 수 있습니다. 예를 들어 위의 경우에는 order scope를 제거합니다.

실행될 SQL은 다음과 같습니다:

SELECT * FROM books WHERE id > 100 LIMIT 20

-- unscope를 사용하지 않은 원래의 쿼리 
SELECT * FROM books WHERE id > 100 ORDER BY id desc LIMIT 20

where절의 특정 조건만 제거할 수도 있습니다. 예를 들어 다음과 같이 where절에서 id 조건을 제거할 수 있습니다:

Book.where(id: 10, out_of_print: false).unscope(where: :id)
# books.* FROM books에서 out_of_print = 0인 레코드 SELECT

unscope를 사용한 relation은 그것이 병합되는 어떤 relation에도 영향을 미칩니다:

Book.order("id desc").merge(Book.unscope(:order))
# books 테이블에서 모든 컬럼을 조회

unscope는 이전에 추가된 특정한 scope를 제거합니다. 예를 들면:

Post.where('id > 10').limit(20).order('id asc').unscope(:order)

생성된 SQL:

SELECT "posts".* FROM "posts" WHERE (id > 10) LIMIT 20

# `order` 부분이 제거되었음을 주목하세요

또한 where를 unscope할 때는, 특정 조건들을 지정할 수 있습니다:

Post.where(id: 10, trashed: false).unscope(where: :id)
# SELECT "posts".* FROM "posts" WHERE trashed = false

8.2 only

only 메서드를 사용해서 조건을 오버라이드할 수도 있습니다. 예를 들면:

Book.where("id > 10").limit(20).order("id desc").only(:order, :where)

이 메서드 체인에 대해 only:order:where 구절만을 유지하고 다른 모든 구절을 제거할 것입니다. 결과적으로 limit 구절은 제거되어 아래와 같은 쿼리가 생성됩니다:

실행될 SQL:

SELECT * FROM books WHERE id > 10 ORDER BY id DESC

-- only가 없는 원본 쿼리
SELECT * FROM books WHERE id > 10 ORDER BY id DESC LIMIT 20

8.3 reselect

reselect 메서드는 기존의 select 구문을 재정의합니다. 예시:

Book.select(:title, :isbn).reselect(:created_at)

select로 선택된 속성들을 덮어쓰고 새로운 속성들을 선택합니다.

실행되는 SQL:

SELECT books.created_at FROM books

reselect 절이 사용되지 않는 경우와 비교해보세요:

Book.select(:title, :isbn).select(:created_at)

실행되는 SQL은 다음과 같습니다:

SELECT books.title, books.isbn, books.created_at FROM books

8.4 reorder

reorder 메서드는 기본 scope 정렬을 오버라이드합니다. 예를 들어 클래스 정의에 다음이 포함된 경우:

class Author < ApplicationRecord
  has_many :books, -> { order(year_published: :desc) }
end

그리고 다음을 실행하세요:

Author.find(10).books

번역할 문장이 없는 순수 Ruby 코드 예시입니다.

실행될 SQL문은 다음과 같습니다:

SELECT * FROM authors WHERE id = 10 LIMIT 1 
SELECT * FROM books WHERE author_id = 10 ORDER BY year_published DESC

reorder 절을 사용하여 책을 정렬하는 방식을 다르게 지정할 수 있습니다:

Author.find(10).books.reorder("year_published ASC")

실행될 SQL 쿼리:

SELECT * FROM authors WHERE id = 10 LIMIT 1
SELECT * FROM books WHERE author_id = 10 ORDER BY year_published ASC

8.5 reverse_order

reverse_order 메서드는 정렬 조건이 지정된 경우 순서를 반대로 바꿉니다.

Book.where("author_id > 10").order(:year_published).reverse_order

where 절로 필터링한 결과를 year_published로 정렬한 뒤 reverse_order로 순서를 뒤집습니다.

실행될 SQL 쿼리:

SELECT * FROM books WHERE author_id > 10 ORDER BY year_published DESC

쿼리에 ordering 절이 지정되어 있지 않은 경우, reverse_order는 primary key를 기준으로 역순으로 정렬합니다.

Book.where("author_id > 10").reverse_order

reverse_order는 정렬 순서를 뒤집습니다. 단, 모델에 순서를 지정하지 않은 경우 기본 키로 역순 정렬을 수행합니다.

실행될 SQL문:

SELECT * FROM books WHERE author_id > 10 ORDER BY books.id DESC

reverse_order 메소드는 아무런 인자도 받지 않습니다.

8.6 rewhere

rewhere 메서드는 기존의 명명된 where 조건을 덮어씁니다. 예를 들어:

Book.where(out_of_print: true).rewhere(out_of_print: false)

절을 완전히 덮어씁니다. rewhere는 where와는 달리 동일한 조건에 대한 기존의 조건 절을 제거한 후 새로운 조건을 추가합니다.

실행될 SQL:

SELECT * FROM books WHERE out_of_print = 0

rewhere 절을 사용하지 않으면 where 절들이 AND로 결합됩니다:

Book.where(out_of_print: true).where(out_of_print: false)

실행되는 SQL문은 다음과 같습니다:

SELECT * FROM books WHERE out_of_print = 1 AND out_of_print = 0

rewhere는 기존 조건을 새로운 조건으로 대체할 수 있습니다. 예를 들어 rewhere는 사용자 검색이나 필터링을 수행할 때 유용할 수 있습니다:

Article.where(trashed: true).rewhere(trashed: false)

처음 구성된 where(trashed: true)는 무시되고 대신 where(trashed: false)가 사용됩니다.

8.7 regroup

regroup 메서드는 기존의 명명된 group 조건을 재정의합니다. 예를 들어:

Book.group(:author).regroup(:id)

:author로 그룹화된 쿼리를 :id로 재그룹화합니다.

실행될 SQL 쿼리:

SELECT * FROM books GROUP BY id

만약 regroup 절을 사용하지 않으면, 모든 group 절들이 함께 결합됩니다.

Book.group(:author).group(:id)
  • 여러 컬럼의 그룹화는 group(:column1, :column2) 혹은 여러 group 호출로 지정할 수 있습니다.

실행될 SQL은 다음과 같습니다:

SELECT * FROM books GROUP BY author, id

9 Null Relation

none 메서드는 레코드가 없는 연결 가능한 relation을 반환합니다. 반환된 relation에 연결된 이후의 모든 조건들은 계속해서 빈 relation을 생성합니다. 이는 메서드나 scope에 대한 응답이 0개의 결과를 반환할 수 있는 시나리오에서 연결 가능한 응답이 필요할 때 유용합니다.

Book.none # 빈 Relation을 반환하고 쿼리를 실행하지 않습니다.
# 아래의 highlighted_reviews 메서드는 항상 Relation을 반환하도록 기대됩니다.
Book.first.highlighted_reviews.average(:rating)
# => 책의 평균 평점을 반환합니다

class Book
  # 리뷰가 5개 이상이면 리뷰들을 반환하고,
  # 그렇지 않으면 아직 리뷰가 없는 책으로 간주합니다
  def highlighted_reviews
    if reviews.count > 5 
      reviews
    else
      Review.none # 아직 최소 임계값을 충족하지 않음  
    end
  end
end

10 Readonly 객체

Active Record는 relation에 readonly 메소드를 제공하여 반환된 객체들의 수정을 명시적으로 금지할 수 있습니다. readonly 레코드를 변경하려는 모든 시도는 성공하지 못하고 ActiveRecord::ReadOnlyRecord 예외가 발생합니다.

customer = Customer.readonly.first
customer.visits += 1
customer.save # ActiveRecord::ReadOnlyRecord 에러를 발생시킴

customer가 명시적으로 readonly 객체로 설정되었기 때문에, 위의 코드는 visits 값을 업데이트하여 customer.save를 호출할 때 ActiveRecord::ReadOnlyRecord 예외를 발생시킬 것입니다.

11 레코드 업데이트를 위한 잠금

잠금은 데이터베이스의 레코드를 업데이트할 때 경쟁 상태를 방지하고 원자적 업데이트를 보장하는 데 도움이 됩니다.

Active Record는 두 가지 잠금 메커니즘을 제공합니다:

  • Optimistic Locking
  • Pessimistic Locking

11.1 낙관적 잠금(Optimistic Locking)

낙관적 잠금은 여러 사용자가 동시에 같은 레코드를 편집할 수 있게 하며, 데이터 충돌이 최소한으로 발생한다고 가정합니다. 이는 레코드가 열린 이후 다른 프로세스가 해당 레코드를 변경했는지 확인하는 방식으로 동작합니다. 만약 변경이 발생했다면 ActiveRecord::StaleObjectError 예외가 발생하고 업데이트는 무시됩니다.

낙관적 잠금 컬럼

낙관적 잠금을 사용하려면 테이블에 integer 타입의 lock_version 컬럼이 있어야 합니다. 레코드가 업데이트될 때마다 Active Record는 lock_version 컬럼의 값을 증가시킵니다. 만약 업데이트 요청의 lock_version 필드 값이 데이터베이스의 현재 lock_version 컬럼 값보다 낮다면, 해당 업데이트 요청은 ActiveRecord::StaleObjectError와 함께 실패하게 됩니다.

예시:

c1 = Customer.find(1)
c2 = Customer.find(1)

c1.first_name = "Sandra" 
c1.save

c2.first_name = "Michael"
c2.save # ActiveRecord::StaleObjectError를 발생시킴

그런 다음 exception을 rescue하고 rollback하거나, merge하거나, 또는 충돌을 해결하는 데 필요한 비즈니스 로직을 적용하는 등의 방법으로 충돌을 처리할 책임이 있습니다.

이 동작은 ActiveRecord::Base.lock_optimistically = false를 설정하여 끌 수 있습니다.

lock_version 컬럼의 이름을 오버라이드하려면, ActiveRecord::Baselocking_column이라는 클래스 속성을 제공합니다:

class Customer < ApplicationRecord
  self.locking_column = :lock_customer_column
end

위 코드는 Customer 모델의 optimistic locking에서 사용될 column을 변경하는 예시입니다. 일반적으로 lock_version이 사용되지만 새로운 column을 사용하도록 지정할 수 있습니다.

11.2 Pessimistic Locking

Pessimistic locking은 기본 데이터베이스가 제공하는 locking 메커니즘을 사용합니다. relation을 구축할 때 lock을 사용하면 선택된 행에 대한 배타적 lock을 얻습니다. lock을 사용하는 relation은 일반적으로 deadlock 상태를 방지하기 위해 transaction 안에 래핑됩니다.

예시:

Book.transaction do
  book = Book.lock.first
  book.title = "Algorithms, second edition"
  book.save!
end

MySQL 백엔드에서 위의 세션은 다음과 같은 SQL을 생성합니다:

SQL (0.2ms)   BEGIN
Book Load (0.3ms)   SELECT * FROM books LIMIT 1 FOR UPDATE
Book Update (0.4ms)   UPDATE books SET updated_at = '2009-02-07 18:05:56', title = 'Algorithms, second edition' WHERE id = 1
SQL (0.8ms)   COMMIT

lock 메서드에 raw SQL을 전달하여 다양한 유형의 lock을 허용할 수 있습니다. 예를 들어, MySQL에는 LOCK IN SHARE MODE라는 표현식이 있어 record를 잠그면서도 다른 query가 읽을 수 있도록 허용합니다. 이 표현식을 사용하려면 lock 옵션으로 전달하면 됩니다:

Book.transaction do
  book = Book.lock("LOCK IN SHARE MODE").find(1)
  book.increment!(:views) 
end

이 코드는 데이터베이스의 SHARE MODE 락을 사용하여 읽기 트랜잭션을 수행합니다. ID가 1인 book 레코드를 검색하고 views 카운터를 증가시킵니다.

lock 메소드에 전달하는 raw SQL을 데이터베이스가 지원해야 합니다.

만약 이미 모델의 인스턴스가 있다면, 다음 코드를 사용하여 트랜잭션을 시작하고 lock을 한 번에 획득할 수 있습니다:

book = Book.first
book.with_lock do
  # 이 블록은 트랜잭션 내에서 호출되며,
  # book은 이미 lock 상태입니다.
  book.increment!(:views)
end

12 Joining Tables

Active Record는 결과 SQL에서 JOIN 절을 지정하기 위한 두 가지 finder method를 제공합니다: joinsleft_outer_joins입니다. joinsINNER JOIN 또는 사용자 정의 쿼리에 사용되어야 하는 반면, left_outer_joinsLEFT OUTER JOIN을 사용하는 쿼리에 사용됩니다.

12.1 joins

joins 메서드를 사용하는 방법은 여러 가지가 있습니다.

12.1.1 SQL 문자열 조각 사용하기

joinsJOIN 절을 지정하는 raw SQL을 직접 제공할 수 있습니다:

Author.joins("INNER JOIN books ON books.author_id = authors.id AND books.out_of_print = FALSE")

Author.joins를 사용하여 SQL INNER JOIN을 직접 지정할 수 있습니다.

이는 다음과 같은 SQL을 생성할 것입니다:

SELECT authors.* FROM authors INNER JOIN books ON books.author_id = authors.id AND books.out_of_print = FALSE

12.1.2 Array/Hash로 Named Associations 사용하기

Active Record를 사용하면 모델에 정의된 associations의 이름을 joins 메서드를 사용할 때 해당 associations에 대한 JOIN 절을 지정하는 단축키로 사용할 수 있습니다.

다음의 모든 예시는 INNER JOIN을 사용하여 예상되는 join 쿼리를 생성합니다:

12.1.2.1 단일 Association 조인하기
Book.joins(:reviews)

Book 모델과 관련된 reviews를 joins 합니다.

이는 다음과 같이 출력됩니다:

SELECT books.* FROM books
  INNER JOIN reviews ON reviews.book_id = books.id

또는 영어로 표현하면: "리뷰가 있는 모든 책들에 대한 Book 객체를 반환". 한 책에 여러 개의 리뷰가 있는 경우 중복된 책들이 표시된다는 점에 유의하세요. 중복되지 않은 책들을 원한다면 Book.joins(:reviews).distinct를 사용할 수 있습니다.

12.1.3 여러 Association 조인하기

Book.joins(:author, :reviews)

Book과 author, reviews를 join합니다.

이는 다음과 같이 출력됩니다:

SELECT books.* FROM books
  INNER JOIN authors ON authors.id = books.author_id
  INNER JOIN reviews ON reviews.book_id = books.id

또는 영어로는: "작성자가 있고 리뷰가 하나 이상 있는 모든 책을 반환하라"는 의미입니다. 다시 말하지만, 여러 개의 리뷰가 있는 책은 여러 번 표시될 것입니다.

12.1.3.1 중첩된 Association 조인하기 (단일 레벨)
Book.joins(reviews: :customer)

reviews를 거쳐서 customer까지 연결되는 중첩 join을 수행합니다.

이렇게 생성됩니다:

SELECT books.* FROM books
  INNER JOIN reviews ON reviews.book_id = books.id
  INNER JOIN customers ON customers.id = reviews.customer_id

또는 영어로: "고객의 리뷰가 있는 모든 책을 반환한다."

12.1.3.2 중첩된 Association 조인하기 (다중 레벨)
Author.joins(books: [{ reviews: { customer: :orders } }, :supplier])

Active Record 쿼리 인터페이스

Active Record는 데이터베이스의 데이터에 액세스할 수 있게 해주는 풍부한 API를 제공합니다. 이 가이드에서는 다음 주제를 다룹니다:

  • 단건 객체를 찾는 방법
  • 객체들의 집합을 찾는 방법
  • 검색을 제한하는 방법
  • 데이터베이스 집계를 수행하는 방법
  • Table Association을 사용하는 방법
  • INNER JOIN과 LEFT OUTER JOIN을 사용하는 방법
  • Eager loading을 통해 쿼리 횟수를 최소화하는 방법
  • 데이터베이스에서 여러 객체들을 조작하기 위해 Scopes를 사용하는 방법

이 가이드에서 설명하는 예제들은 다음 모델들을 사용할 것입니다:

class Author < ApplicationRecord
  has_many :books
  has_many :authors_books
end

class Book < ApplicationRecord
  belongs_to :supplier
  belongs_to :author
  has_many :reviews
  has_and_belongs_to_many :orders, join_table: 'books_orders'

  scope :in_print, -> { where(out_of_print: false) }
  scope :out_of_print, -> { where(out_of_print: true) }
  scope :old, -> { where(year_published: ...50.years.ago.year) }
  scope :out_of_print_and_expensive, -> { out_of_print.where('price > 500') }
  scope :costs_more_than, ->(amount) { where('price > ?', amount) }
end

다음과 같이 생성됩니다:

SELECT authors.* FROM authors
  INNER JOIN books ON books.author_id = authors.id
  INNER JOIN reviews ON reviews.book_id = books.id
  INNER JOIN customers ON customers.id = reviews.customer_id
  INNER JOIN orders ON orders.customer_id = customers.id
INNER JOIN suppliers ON suppliers.id = books.supplier_id

또는 영어로는 "책 리뷰가 있고 또한 고객이 주문한 저자들과, 그 책들의 공급업체를 반환합니다."

12.1.4 Join된 테이블에 대한 조건 지정하기

join된 테이블에 대한 조건은 일반적인 ArrayString 조건을 사용하여 지정할 수 있습니다. Hash 조건은 join된 테이블의 조건을 지정하기 위한 특별한 문법을 제공합니다:

time_range = (Time.now.midnight - 1.day)..Time.now.midnight
Customer.joins(:orders).where("orders.created_at" => time_range).distinct

어제 자정부터 오늘 자정 사이에 주문한 고객들을 모두 찾습니다.

이것은 created_at을 비교하기 위해 SQL의 BETWEEN 표현식을 사용하여 어제 생성된 orders를 가진 모든 customers를 찾습니다.

대안으로 더 깔끔한 문법은 hash 조건을 중첩하는 것입니다:

time_range = (Time.now.midnight - 1.day)..Time.now.midnight
Customer.joins(:orders).where(orders: { created_at: time_range }).distinct

어제 자정부터 오늘 자정까지의 기간 동안 주문한 고객들을 찾는 예제입니다. distinct는 중복된 고객을 제거하는데 사용됩니다.

더 복잡한 조건이나 기존의 named scope를 재사용하기 위해서는 merge를 사용할 수 있습니다. 먼저 Order 모델에 새로운 named scope를 추가해보겠습니다:

class Order < ApplicationRecord
  belongs_to :customer

  scope :created_in_time_range, ->(time_range) {
    where(created_at: time_range)
  }
end

위 코드는 주문 시간의 범위를 지정하는 scope를 정의합니다.

이제 merge를 사용하여 created_in_time_range scope를 병합할 수 있습니다:

time_range = (Time.now.midnight - 1.day)..Time.now.midnight
Customer.joins(:orders).merge(Order.created_in_time_range(time_range)).distinct

time_range가 설정된 기간 동안 주문한 고객들을 중복없이 조회합니다.

이는 SQL BETWEEN 표현식을 사용하여, 어제 생성된 주문을 가진 모든 customer를 찾습니다.

12.2 left_outer_joins

관련 레코드가 있든 없든 레코드 세트를 선택하고 싶다면 left_outer_joins 메서드를 사용할 수 있습니다.

Customer.left_outer_joins(:reviews).distinct.select("customers.*, COUNT(reviews.*) AS reviews_count").group("customers.id")

다음과 같이 생성됩니다:

SELECT DISTINCT customers.*, COUNT(reviews.*) AS reviews_count FROM customers
LEFT OUTER JOIN reviews ON reviews.customer_id = customers.id GROUP BY customers.id

"모든 customer를 반환하되, review가 있든 없든 상관없이 각각의 review 수를 함께 반환합니다"

12.3 where.associatedwhere.missing

associatedmissing 쿼리 메소드는 association의 존재 여부를 기반으로 레코드 세트를 선택할 수 있게 해줍니다.

where.associated 사용법:

Customer.where.associated(:reviews)

Customer 모델에 연결된 reviews를 가진 레코드만 조회합니다.

출력:

SELECT customers.* FROM customers
INNER JOIN reviews ON reviews.customer_id = customers.id
WHERE reviews.id IS NOT NULL

이는 "적어도 하나의 리뷰를 남긴 모든 고객들을 반환하라"는 의미입니다.

where.missing을 사용하려면:

Customer.where.missing(:reviews)

reviews가 없는 Customer를 조회합니다.

Produces:

(번역할 내용이 없어서 번역을 제공할 수 없습니다. 번역이 필요한 본문을 제공해 주시면 번역해드리겠습니다.)

SELECT customers.* FROM customers
LEFT OUTER JOIN reviews ON reviews.customer_id = customers.id
WHERE reviews.id IS NULL

이는 "리뷰를 하나도 작성하지 않은 모든 고객들을 반환한다"는 의미입니다.

13 Eager Loading Associations

Eager loading은 Model.find로 반환된 객체들의 연관 레코드들을 가능한 적은 쿼리로 로딩하는 메커니즘입니다.

13.1 N + 1 쿼리 문제

다음과 같이 10개의 books를 찾아서 각각의 authors의 last_name을 출력하는 코드를 살펴보겠습니다:

books = Book.limit(10)

books.each do |book|
  puts book.author.last_name
end

이 코드는 처음 보면 괜찮아 보입니다. 하지만 실행되는 쿼리의 총 개수에 문제가 있습니다. 위 코드는 1(10개의 책을 찾기 위해) + 10(각 책마다 author를 로드하기 위해 하나씩) = 11개의 쿼리를 총 실행합니다.

13.1.1 N + 1 쿼리 문제의 해결책

Active Record는 로드될 모든 association을 미리 지정할 수 있도록 해줍니다.

사용 가능한 메서드들은 다음과 같습니다:

13.2 includes

includes를 사용하면, Active Record는 지정된 모든 association들이 가능한 최소한의 쿼리를 사용하여 로드되도록 보장합니다.

위의 경우를 includes 메서드를 사용하여 다시 살펴보면, Book.limit(10)을 수정하여 authors를 eager load할 수 있습니다:

books = Book.includes(:author).limit(10)

books.each do |book|
  puts book.author.last_name
end

위 코드는 원래의 경우처럼 11개의 쿼리가 아닌 단 2개의 쿼리만 실행할 것입니다:

SELECT books.* FROM books LIMIT 10
SELECT authors.* FROM authors
  WHERE authors.id IN (1,2,3,4,5,6,7,8,9,10)

13.2.1 다수의 Association을 Eager Loading하기

Active Record는 includes 메소드를 사용할 때 배열, 해시, 또는 배열/해시의 중첩된 해시를 통해 하나의 Model.find 호출로 원하는 만큼의 association을 eager load할 수 있습니다.

13.2.1.1 다수 Association의 배열
Customer.includes(:orders, :reviews)

각각의 customers와 연관된 orders와 reviews를 모두 로드합니다.

13.2.1.2 Nested Associations Hash
Customer.includes(orders: { books: [:supplier, :author] }).find(1)

이 코드는 주어진 customer의 관련된 orders records, books records, supplier records 그리고 author records들을 모두 동시에 로드합니다.

이것은 id가 1인 고객을 찾고, 관련된 모든 orders, orders와 관련된 모든 books, 그리고 각 books의 author와 supplier를 eager load합니다.

13.2.2 Eager Load된 Association에 조건 지정하기

Active Record는 joins처럼 eager load된 association에도 조건을 지정할 수 있게 해주지만, 권장되는 방법은 joins를 사용하는 것입니다.

하지만 꼭 해야한다면, 일반적인 방법으로 where를 사용할 수 있습니다.

Author.includes(:books).where(books: { out_of_print: true })

books가 절판(out_of_print)된 Author를 찾는 where 조건을 가진 eager loading을 수행합니다.

이는 joins 메서드가 INNER JOIN 함수를 사용하여 쿼리를 생성하는 것과 달리, LEFT OUTER JOIN이 포함된 쿼리를 생성합니다.

  SELECT authors.id AS t0_r0, ... books.updated_at AS t1_r5 FROM authors LEFT OUTER JOIN books ON books.author_id = authors.id WHERE (books.out_of_print = 1)

where 조건이 없다면 일반적인 두 개의 쿼리가 생성될 것입니다.

이와 같이 where를 사용하는 것은 Hash를 전달할 때만 작동합니다. SQL fragments의 경우 joined 테이블을 강제하기 위해 references를 사용해야 합니다:

Author.includes(:books).where("books.out_of_print = true").references(:books)

includes와 함께 조건을 사용하기 위해 references를 명시적으로 호출해야 하는 경우입니다. 이렇게 하면 Active Record는 LEFT OUTER JOIN을 사용하고, 책들이 절판된 모든 저자들을 찾을 것입니다.

includes 쿼리에서 어떤 저자도 책을 가지고 있지 않은 경우에도 모든 저자가 로드됩니다. joins(INNER JOIN)를 사용하면 조인 조건이 반드시 일치해야 하며, 그렇지 않으면 어떤 레코드도 반환되지 않습니다.

조인의 일부로 association이 eager load되는 경우, custom select 절의 필드들은 로드된 모델에 존재하지 않습니다. 이는 그 필드들이 부모 레코드에 나타나야 하는지 자식 레코드에 나타나야 하는지가 모호하기 때문입니다.

13.3 preload

preload를 사용하면 Active Record가 지정된 각 association을 association당 하나의 쿼리를 사용하여 로드합니다.

N + 1 쿼리 문제로 돌아가서, authors를 preload하도록 Book.limit(10)을 다시 작성할 수 있습니다:

books = Book.preload(:author).limit(10)

books.each do |book|
  puts book.author.last_name
end

위 코드는 원래의 11개 쿼리와는 달리 단 2개의 쿼리만 실행할 것입니다:

SELECT books.* FROM books LIMIT 10
SELECT authors.* FROM authors
  WHERE authors.id IN (1,2,3,4,5,6,7,8,9,10)

preload 메소드는 배열, 해시, 또는 배열/해시의 중첩된 해시를 includes 메소드와 동일한 방식으로 사용하여 하나의 Model.find 호출로 여러 association을 로드할 수 있습니다. 하지만 includes 메소드와 달리 preload된 association에 대한 조건을 지정할 수는 없습니다.

13.4 eager_load

eager_load를 사용하면 Active Record는 LEFT OUTER JOIN을 사용하여 지정된 모든 association을 로드합니다.

N + 1이 발생했던 케이스를 eager_load 메서드를 사용하여 다시 살펴보면, authors에 대한 Book.limit(10)을 다음과 같이 다시 작성할 수 있습니다:

books = Book.eager_load(:author).limit(10)

books.each do |book|
  puts book.author.last_name
end

위 코드는 원본의 11개의 쿼리와 달리 단 1개의 쿼리만 실행합니다:

SELECT "books"."id" AS t0_r0, "books"."title" AS t0_r1, ... FROM "books"
  LEFT OUTER JOIN "authors" ON "authors"."id" = "books"."author_id" 
  LIMIT 10

eager_load 메서드는 includes 메서드와 동일한 방식으로 배열, 해시, 또는 배열/해시의 중첩 해시를 사용하여 단일 Model.find 호출로 원하는 만큼의 association을 로드할 수 있습니다. 또한 includes 메서드처럼 eager load되는 association에 대한 조건을 지정할 수 있습니다.

13.5 strict_loading

Eager loading은 N + 1 쿼리를 방지할 수 있지만 일부 association들은 여전히 lazy loading될 수 있습니다. 어떤 association도 lazy loading되지 않도록 하기 위해서는 strict_loading을 활성화할 수 있습니다.

relation에서 strict loading 모드를 활성화하면, 레코드가 어떤 association이라도 lazy loading 하려고 시도할 때 ActiveRecord::StrictLoadingViolationError가 발생합니다:

user = User.strict_loading.first
user.address.city # ActiveRecord::StrictLoadingViolationError를 발생시킵니다
user.comments.to_a # ActiveRecord::StrictLoadingViolationError를 발생시킵니다

모든 relation에 대해 활성화하려면 config.active_record.strict_loading_by_default 플래그를 true로 변경하세요.

대신 위반 사항을 logger로 전송하려면 config.active_record.action_on_strict_loading_violation:log로 변경하세요.

13.6 strict_loading!

strict_loading!를 호출하여 레코드 자체에서 strict loading을 활성화할 수 있습니다:

user = User.first
user.strict_loading!
user.address.city # ActiveRecord::StrictLoadingViolationError를 발생시킴
user.comments.to_a # ActiveRecord::StrictLoadingViolationError를 발생시킴

strict_loading!:mode 인자도 받습니다. 이를 :n_plus_one_only로 설정하면 N + 1 쿼리를 발생시키는 association이 lazy loading될 때만 에러가 발생합니다.

user.strict_loading!(mode: :n_plus_one_only)
user.address.city # => "Tatooine" 
user.comments.to_a # => [#<Comment:0x00...]
user.comments.first.likes.to_a # ActiveRecord::StrictLoadingViolationError를 발생시킵니다

13.7 연관관계에서의 strict_loading 옵션

strict_loading 옵션을 제공하여 단일 연관관계에 대해 strict loading을 활성화할 수 있습니다:

class Author < ApplicationRecord
  has_many :books, strict_loading: true
end

Scopes

Scope를 사용하면 자주 사용하는 쿼리를 정의하여 association 객체나 모델의 메서드 호출로 참조할 수 있습니다. 이러한 scope를 사용하면 where, joins, includes와 같은 이전에 다룬 모든 메서드를 사용할 수 있습니다. 모든 scope 본문은 추가 메서드(다른 scope와 같은)를 호출할 수 있도록 ActiveRecord::Relation 또는 nil을 반환해야 합니다.

간단한 scope를 정의하려면 클래스 내부에서 scope 메서드를 사용하고, 이 scope가 호출될 때 실행하고자 하는 쿼리를 전달합니다:

class Book < ApplicationRecord
  scope :out_of_print, -> { where(out_of_print: true) }
end

out_of_print scope를 호출하려면 클래스에서 호출할 수 있습니다:

irb> Book.out_of_print
=> #<ActiveRecord::Relation> # 절판된 모든 책들

Book 객체로 구성된 association에서도 가능합니다:

irb> author = Author.first
irb> author.books.out_of_print
=> #<ActiveRecord::Relation> # author의 절판된 모든 book들

스코프는 스코프 내에서도 체이닝이 가능합니다:

class Book < ApplicationRecord
  scope :out_of_print, -> { where(out_of_print: true) }
  scope :out_of_print_and_expensive, -> { out_of_print.where("price > 500") }
end

13.8 인자 전달하기

scope에 인자를 전달할 수 있습니다:

class Book < ApplicationRecord
  scope :costs_more_than, ->(amount) { where("price > ?", amount) }
end

scope를 클래스 메서드처럼 호출하세요:

irb> Book.costs_more_than(100.10)

하지만 이것은 class method로 제공받을 수 있는 기능을 단순히 중복하는 것입니다.

class Book < ApplicationRecord
  def self.costs_more_than(amount)
    where("price > ?", amount) 
  end
end

이러한 메소드들은 association 객체에서 여전히 접근할 수 있습니다:

irb> author.books.costs_more_than(100.10)

13.9 조건문 사용하기

scope에서는 조건문을 활용할 수 있습니다:

class Order < ApplicationRecord
  scope :created_before, ->(time) { where(created_at: ...time) if time.present? }
end

time이 present?일 때만 쿼리를 조건부로 실행하는 scope를 정의합니다.

다른 예시들과 같이, 이것은 class method처럼 동작할 것입니다.

class Order < ApplicationRecord
  def self.created_before(time)
    where(created_at: ...time) if time.present? # time이 존재하는 경우 해당 시간 이전에 생성된 주문들을 반환합니다
  end
end

하지만 한 가지 중요한 주의사항이 있습니다: scope는 조건문이 false로 평가되더라도 항상 ActiveRecord::Relation 객체를 반환하는 반면, class method는 nil을 반환합니다. 이는 조건문이 포함된 class method들을 체이닝할 때, 조건문 중 하나라도 false를 반환하면 NoMethodError가 발생할 수 있습니다.

13.10 Default Scope 적용하기

모델의 모든 쿼리에 scope를 적용하고 싶다면, 모델 내부에서 default_scope 메소드를 사용할 수 있습니다.

class Book < ApplicationRecord
  default_scope { where(out_of_print: false) }
end

이 model에 대해 query가 실행되면 SQL query는 아래와 같은 형태가 됩니다:

SELECT * FROM books WHERE (out_of_print = false)

만약 default scope로 더 복잡한 작업을 해야 한다면, 이를 클래스 메소드로 정의할 수 있습니다:

class Book < ApplicationRecord
  def self.default_scope
    # ActiveRecord::Relation을 반환해야 합니다.
  end
end

default_scope는 scope 인자가 Hash로 주어질 때 레코드를 생성/빌드하는 동안에도 적용됩니다. 레코드를 업데이트하는 동안에는 적용되지 않습니다. 예시:

class Book < ApplicationRecord
  default_scope { where(out_of_print: false) }
end
irb> Book.new
=> #<Book id: nil, out_of_print: false>
irb> Book.unscoped.new
=> #<Book id: nil, out_of_print: nil>

주의하세요. Array 형식으로 지정된 default_scope 쿼리 인수는 기본 속성 할당을 위한 Hash로 변환될 수 없습니다. 예를 들면:

class Book < ApplicationRecord
  default_scope { where("out_of_print = ?", false) }
end
irb> Book.new
=> #<Book id: nil, out_of_print: nil>

13.11 Scope의 병합

where 절과 마찬가지로 scope는 AND 조건을 사용하여 병합됩니다.

class Book < ApplicationRecord
  scope :in_print, -> { where(out_of_print: false) }  # 절판되지 않은 도서
  scope :out_of_print, -> { where(out_of_print: true) } # 절판된 도서

  scope :recent, -> { where(year_published: 50.years.ago.year..) } # 최근 50년 이내 출판된 도서
  scope :old, -> { where(year_published: ...50.years.ago.year) } # 50년 이전에 출판된 도서
end
irb> Book.out_of_print.old
SELECT books.* FROM books WHERE books.out_of_print = 'true' AND books.year_published < 1969

scopewhere 조건을 섞어서 사용할 수 있으며, 최종 SQL에서는 모든 조건들이 AND로 연결됩니다.

irb> Book.in_print.where(price: ...100)
SELECT books.* FROM books WHERE books.out_of_print = 'false' AND books.price < 100

만약 마지막 where 절이 우선순위를 갖도록 하고 싶다면 merge를 사용할 수 있습니다.

irb> Book.in_print.merge(Book.out_of_print)
SELECT books.* FROM books WHERE books.out_of_print = true

한 가지 중요한 주의사항은 default_scopescopewhere 조건문에서 앞에 추가된다는 것입니다.

class Book < ApplicationRecord
  # 50년 전부터 현재까지 출판된 도서만 기본으로 조회합니다
  default_scope { where(year_published: 50.years.ago.year..) }

  # 절판되지 않은 도서만 조회합니다
  scope :in_print, -> { where(out_of_print: false) }
  # 절판된 도서만 조회합니다 
  scope :out_of_print, -> { where(out_of_print: true) }
end
irb> Book.all
SELECT books.* FROM books WHERE (year_published >= 1969)

irb> Book.in_print
SELECT books.* FROM books WHERE (year_published >= 1969) AND books.out_of_print = false  

irb> Book.where('price > 50')
SELECT books.* FROM books WHERE (year_published >= 1969) AND (price > 50)

위에서 볼 수 있듯이 default_scopescopewhere 조건 모두에 병합됩니다.

13.12 모든 Scoping 제거하기

어떤 이유로든 scoping을 제거하고 싶다면 [unscoped][] 메서드를 사용할 수 있습니다. 이는 특히 모델에 default_scope이 지정되어 있고 특정 쿼리에서는 이를 적용하고 싶지 않을 때 유용합니다.

Book.unscoped를 실행하고 즉시 레코드를 로드합니다.

이 메서드는 모든 scope를 제거하고 테이블에 대해 일반적인 query를 수행합니다.

irb> Book.unscoped.all 
SELECT books.* FROM books

irb> Book.where(out_of_print: true).unscoped.all
SELECT books.* FROM books

unscoped는 블록을 받을 수도 있습니다:

irb> Book.unscoped { Book.out_of_print }
SELECT books.* FROM books WHERE books.out_of_print = true

14 Dynamic Finders

테이블에서 정의한 모든 필드(속성이라고도 함)에 대해 Active Record는 finder 메서드를 제공합니다. 예를 들어 Customer 모델에 first_name이라는 필드가 있다면, Active Record는 자동으로 find_by_first_name 인스턴스 메서드를 제공합니다. Customer 모델에 locked 필드도 있다면, find_by_locked 메서드도 함께 제공됩니다.

레코드를 찾지 못했을 때 ActiveRecord::RecordNotFound 에러를 발생시키려면 dynamic finder 끝에 느낌표(!)를 지정할 수 있습니다. 예: Customer.find_by_first_name!("Ryan")

first_nameorders_count 모두로 찾고 싶다면, 필드 사이에 "and"를 입력하여 이러한 finder들을 연결할 수 있습니다. 예: Customer.find_by_first_name_and_orders_count("Ryan", 5).

15 Enums

enum을 사용하면 속성에 대한 값들의 Array를 정의하고 이름으로 참조할 수 있습니다. 데이터베이스에 실제로 저장되는 값은 값들 중 하나에 매핑된 정수입니다.

enum을 선언하면 다음과 같은 것들이 생성됩니다:

  • enum 값을 가지고 있거나 가지고 있지 않은 모든 객체를 찾는 데 사용할 수 있는 scope
  • 객체가 특정 enum 값을 가지고 있는지 확인하는 데 사용할 수 있는 인스턴스 메서드
  • 객체의 enum 값을 변경하는 데 사용할 수 있는 인스턴스 메서드

enum의 가능한 모든 값에 대해 위의 것들이 생성됩니다.

예를 들어, 다음과 같은 enum 선언이 있다고 가정해봅시다:

class Order < ApplicationRecord
  enum :status, [:shipped, :being_packaged, :complete, :cancelled]
end

이러한 scopes는 자동으로 생성되며 특정 status 값을 가진 또는 가지지 않은 모든 객체를 찾는 데 사용할 수 있습니다:

irb> Order.shipped
=> #<ActiveRecord::Relation> # status == :shipped인 모든 orders
irb> Order.not_shipped  
=> #<ActiveRecord::Relation> # status != :shipped인 모든 orders

이러한 인스턴스 메서드들은 자동으로 생성되며 모델이 status enum에 대해 해당 값을 가지고 있는지 확인합니다:

irb> order = Order.shipped.first
irb> order.shipped?
=> true
irb> order.complete?
=> false

이러한 인스턴스 메서드들은 자동으로 생성되며, 먼저 status 값을 지정된 값으로 업데이트한 다음 상태가 해당 값으로 성공적으로 설정되었는지 여부를 확인합니다:

irb> order = Order.first
irb> order.shipped!
UPDATE "orders" SET "status" = ?, "updated_at" = ? WHERE "orders"."id" = ?  [["status", 0], ["updated_at", "2019-01-24 07:13:08.524320"], ["id", 1]]
=> true

enum에 대한 전체 문서는 여기에서 찾을 수 있습니다.

16 Method Chaining 이해하기

Active Record 패턴은 Method Chaining을 구현하며, 이를 통해 여러 Active Record 메서드를 간단하고 명확한 방식으로 함께 사용할 수 있습니다.

이전에 호출된 메서드가 all, where, joins와 같은 ActiveRecord::Relation을 반환할 때 구문에서 메서드를 체이닝할 수 있습니다. 단일 객체를 반환하는 메서드(단일 객체 검색 섹션 참조)는 구문의 마지막에 있어야 합니다.

아래에 몇 가지 예시가 있습니다. 이 가이드는 모든 가능성을 다루지는 않고 예시로 몇 가지만 다룰 것입니다. Active Record 메서드가 호출될 때 쿼리는 즉시 생성되어 데이터베이스로 전송되지 않습니다. 쿼리는 실제로 데이터가 필요할 때만 전송됩니다. 그래서 아래의 각 예시는 단일 쿼리를 생성합니다.

16.1 여러 테이블에서 필터링된 데이터 가져오기

Customer
  .select("customers.id, customers.last_name, reviews.body")
  .joins(:reviews)
  .where("reviews.created_at > ?", 1.week.ago)

1주일 전 이후에 작성된 review가 있는 customer의 id와 last_name, 그리고 review의 body를 조회합니다.

결과는 다음과 같이 나타날 것입니다:

SELECT customers.id, customers.last_name, reviews.body
FROM customers
INNER JOIN reviews
  ON reviews.customer_id = customers.id
WHERE (reviews.created_at > '2019-01-08')

16.2 여러 테이블로부터 특정 데이터 검색하기

Book
  .select("books.id, books.title, authors.first_name")
  .joins(:author)  
  .find_by(title: "Abstraction and Specification in Program Development")

이 query는 books 테이블에서 id와 title을 선택하고 authors 테이블에서 first_name을 선택하며, author association을 통해 join을 하고, 제목이 "Abstraction and Specification in Program Development"인 책을 찾습니다.

위 내용은 다음과 같이 생성됩니다:

SELECT books.id, books.title, authors.first_name
FROM books
INNER JOIN authors
  ON authors.id = books.author_id
WHERE books.title = $1 [["title", "Abstraction and Specification in Program Development"]]
LIMIT 1

만약 쿼리가 여러 레코드와 일치하는 경우 find_by는 첫번째 것만 가져오고 나머지는 무시합니다(위의 LIMIT 1 구문 참조).

17 새 객체 찾기 또는 생성하기

레코드를 찾거나 존재하지 않을 경우 생성해야 하는 경우가 흔합니다. find_or_create_byfind_or_create_by! 메서드를 사용하여 이 작업을 수행할 수 있습니다.

17.1 find_or_create_by

find_or_create_by 메서드는 지정된 속성을 가진 레코드가 존재하는지 확인합니다. 존재하지 않으면 create가 호출됩니다. 예시를 살펴보겠습니다.

"Andy"라는 이름의 고객을 찾고, 없다면 새로 생성하고 싶다고 가정해봅시다. 다음과 같이 실행할 수 있습니다:

irb> Customer.find_or_create_by(first_name: 'Andy')
=> #<Customer id: 5, first_name: "Andy", last_name: nil, title: nil, visits: 0, orders_count: nil, lock_version: 0, created_at: "2019-01-17 07:06:45", updated_at: "2019-01-17 07:06:45">

이 메서드로 생성되는 SQL은 다음과 같습니다:

customers에서 (customers.first_name = 'Andy') 모든 컬럼을 LIMIT 1 선택
BEGIN
customers 테이블에 (created_at, first_name, locked, orders_count, updated_at) 컬럼에 ('2011-08-30 05:22:57', 'Andy', 1, NULL, '2011-08-30 05:22:57') 값을 삽입
COMMIT

find_or_create_by는 이미 존재하는 레코드나 새로운 레코드를 반환합니다. 이 경우 Andy라는 이름을 가진 customer가 없었기 때문에 새로운 레코드가 생성되어 반환됩니다.

새로운 레코드가 데이터베이스에 저장되지 않을 수 있습니다. 이는 create와 마찬가지로 validation을 통과했는지 여부에 따라 달라집니다.

새로운 레코드를 생성할 때 'locked' 속성을 false로 설정하고 싶지만, 이를 쿼리에 포함시키고 싶지 않다고 가정해봅시다. 즉, "Andy"라는 이름의 customer를 찾거나, 해당 customer가 존재하지 않을 경우 잠기지 않은 상태의 "Andy"라는 이름의 customer를 생성하고 싶습니다.

이를 달성하는 방법에는 두 가지가 있습니다. 첫 번째는 create_with를 사용하는 것입니다:

Customer.create_with(locked: false).find_or_create_by(first_name: "Andy")

Customer를 찾을 때나 생성할 때 lockedfalse로 설정하려면 create_with로 이 기본값을 지정할 수 있습니다.

두 번째 방법은 block을 사용하는 것입니다:

Customer.find_or_create_by(first_name: "Andy") do |c|
  c.locked = false
end

find_or_create_by와 블록을 함께 사용할 수 있습니다. 주어진 속성을 가진 레코드를 찾거나 새 레코드를 만들 때 블록이 실행됩니다.

이 block은 customer가 생성될 때만 실행됩니다. 두 번째로 이 코드를 실행할 때는 block이 무시됩니다.

17.2 find_or_create_by!

find_or_create_by!를 사용하여 새 레코드가 유효하지 않은 경우 예외를 발생시킬 수도 있습니다. 이 가이드에서는 validation에 대해 다루지 않지만, 잠시 다음을 추가한다고 가정해보겠습니다.

validates :orders_count, presence: true

orders_count가 존재하고 nil이 아닌지 검증합니다.

Customer 모델에 포함됩니다. orders_count를 전달하지 않고 새로운 Customer를 생성하려고 시도하면, 레코드는 유효하지 않게 되고 예외가 발생할 것입니다:

irb> Customer.find_or_create_by!(first_name: 'Andy') 
ActiveRecord::RecordInvalid: 유효성 검사 실패: Orders count는 비워둘 수 없습니다

17.3 find_or_initialize_by

[find_or_initialize_by][] 메서드는 find_or_create_by와 비슷하게 작동하지만 create 대신 new를 호출합니다. 이는 새로운 model 인스턴스가 메모리에 생성되지만 데이터베이스에는 저장되지 않는다는 것을 의미합니다. find_or_create_by 예제를 이어서, 'Nina'라는 이름의 customer를 찾아보겠습니다:

irb> nina = Customer.find_or_initialize_by(first_name: 'Nina')
=> #<Customer id: nil, first_name: "Nina", orders_count: 0, locked: true, created_at: "2011-08-30 06:09:27", updated_at: "2011-08-30 06:09:27">

irb> nina.persisted? # 데이터베이스에 저장되었는지 확인 
=> false

irb> nina.new_record? # 새로운 레코드인지 확인
=> true

객체가 아직 데이터베이스에 저장되지 않았기 때문에, 다음과 같은 SQL이 생성됩니다:

SELECT * FROM customers WHERE (customers.first_name = 'Nina') LIMIT 1

데이터베이스에 저장하고 싶을 때는, 단순히 save를 호출하세요:

irb> nina.save
=> true

18 SQL로 찾기

테이블에서 레코드를 찾기 위해 직접 SQL을 사용하고 싶다면 find_by_sql를 사용할 수 있습니다. find_by_sql 메서드는 기본 쿼리가 단일 레코드만 반환하더라도 객체 배열을 반환합니다. 예를 들어 다음과 같은 쿼리를 실행할 수 있습니다:

irb> Customer.find_by_sql("SELECT * FROM customers INNER JOIN orders ON customers.id = orders.customer_id ORDER BY customers.created_at desc")
=> [#<Customer id: 1, first_name: "Lucas" ...>, #<Customer id: 2, first_name: "Jan" ...>, ...] 

find_by_sql은 데이터베이스에 커스텀 쿼리를 실행하고 인스턴스화된 객체를 검색하는 간단한 방법을 제공합니다.

18.1 select_all

find_by_sql에는 [lease_connection.select_all][]이라는 유사한 메서드가 있습니다. select_allfind_by_sql처럼 사용자 정의 SQL을 사용하여 데이터베이스에서 객체를 검색하지만, 인스턴스화하지는 않습니다. 이 메서드는 ActiveRecord::Result 클래스의 인스턴스를 반환하며, 이 객체에서 to_a를 호출하면 각 해시가 레코드를 나타내는 해시 배열을 반환합니다.

irb> Customer.lease_connection.select_all("SELECT first_name, created_at FROM customers WHERE id = '1'").to_a
=> [{"first_name"=>"Rafael", "created_at"=>"2012-11-10 23:23:45.281189"}, {"first_name"=>"Eileen", "created_at"=>"2013-12-09 11:22:35.221282"}]

lease_connection.select_all

18.2 pluck

pluck는 현재 relation에서 지정된 칼럼(들)의 값을 가져오는 데 사용할 수 있습니다. 인자로 칼럼 이름 목록을 받아서 지정된 칼럼들의 값을 해당 데이터 타입으로 배열로 반환합니다.

irb> Book.where(out_of_print: true).pluck(:id)
SELECT id FROM books WHERE out_of_print = true
=> [1, 2, 3]

irb> Order.distinct.pluck(:status) 
SELECT DISTINCT status FROM orders
=> ["shipped", "being_packed", "cancelled"]

irb> Customer.pluck(:id, :first_name)
SELECT customers.id, customers.first_name FROM customers
=> [[1, "David"], [2, "Fran"], [3, "Jose"]]

pluck는 다음과 같은 코드를 대체할 수 있게 해줍니다:

Customer.select(:id).map { |c| c.id }
# 또는
Customer.select(:id).map(&:id)
# 또는
Customer.select(:id, :first_name).map { |c| [c.id, c.first_name] }
  • with: 키워드를 사용하여 데이터베이스를 채우는 데 사용할 객체를 명시할 수 있습니다. 이 객체는create` 메소드에 전달되는 어떤 것이든 될 수 있습니다. 데이터베이스에 넣을 속성을 가진 해시부터 객체 그 자체까지 모두 가능합니다.

마찬가지로 create 메소드에 블록을 넘길 수도 있습니다:

# calls Book.create with a block
Book.destroy_all

book = Book.create do |book|
  book.name = "Blink"
  book.title = "Blink"
  book.author = "Malcolm Gladwell"
end

다음 코드는 모든 대상에서 동일한 블록을 호출합니다:

5.times do
  Book.create do |book|
    book.name = "Blink"
    book.title = "Blink"
    book.author = "Malcolm Gladwell"
  end
end
Customer.pluck(:id)
# 또는
Customer.pluck(:id, :first_name)

select와 달리 pluck은 데이터베이스 결과를 ActiveRecord 객체를 생성하지 않고 직접 Ruby Array로 변환합니다. 이는 대규모이거나 자주 실행되는 쿼리의 경우 성능이 더 좋을 수 있습니다. 하지만 모델 메서드의 오버라이드는 사용할 수 없습니다. 예를 들어:

class Customer < ApplicationRecord
  def name
    "나는 #{first_name}입니다"
  end
end
irb> Customer.select(:first_name).map &:name
=> ["나는 David입니다", "나는 Jeremy입니다", "나는 Jose입니다"] 

irb> Customer.pluck(:first_name)
=> ["David", "Jeremy", "Jose"]

단일 테이블의 필드만 쿼리할 필요는 없으며, 여러 테이블을 함께 쿼리할 수도 있습니다.

irb> Order.joins(:customer, :books).pluck("orders.created_at, customers.email, books.title")

게다가 select나 다른 Relation scope들과는 달리 pluck은 즉시 쿼리를 실행하며, 따라서 추가적인 scope들과 체이닝할 수 없습니다. 하지만 이전에 이미 구성된 scope들과는 함께 작동할 수 있습니다:

irb> Customer.pluck(:first_name).limit(1)
NoMethodError: undefined method `limit' for #<Array:0x007ff34d3ad6d8>

irb> Customer.limit(1).pluck(:first_name)
=> ["David"]

pluck을 사용할 때, relation 객체가 include 값을 포함하고 있다면 쿼리에 필요하지 않더라도 eager loading이 트리거된다는 점을 알아야 합니다. 예를 들면:

irb> assoc = Customer.includes(:reviews)
irb> assoc.pluck(:id)
SELECT "customers"."id" FROM "customers" LEFT OUTER JOIN "reviews" ON "reviews"."id" = "customers"."review_id"

이를 피하는 한 가지 방법은 includes를 unscope하는 것입니다:

irb> assoc.unscope(:includes).pluck(:id)

18.3 pick

pick은 현재 relation에서 지정된 컬럼의 값을 선택하는데 사용될 수 있습니다. 컬럼명의 리스트를 인자로 받아서 지정된 컬럼 값들의 첫 번째 행을 해당 데이터 타입과 함께 반환합니다. pickrelation.limit(1).pluck(*column_names).first의 단축 버전이며, 주로 이미 하나의 행으로 제한된 relation을 가지고 있을 때 유용합니다.

pick은 다음과 같은 코드를 대체할 수 있게 해줍니다:

Customer.where(id: 1).pluck(:id).first

전치사 "with"은 매우 흔한 용어입니다. 하지만 Ruby에서는 특별한 의미를 가지고 있습니다:

with_options prefix: 'admin' do |admin|
  admin.resources :posts
  admin.resources :comments
end

위의 패턴은 동일한 설정을 공유하는 리소스 그룹을 정의하는 데 자주 사용됩니다. DRY(같은 것을 반복하지 않는다)에 매우 유용합니다.

이는 다음 코드와 정확히 동일합니다:

resources :posts, prefix: 'admin'
resources :comments, prefix: 'admin'

위의 코드는 다음 매칭되는 URL과 Helper를 생성합니다:

      admin_posts GET       /admin/posts(.:format)          posts#index
                  POST      /admin/posts(.:format)          posts#create
   new_admin_post GET       /admin/posts/new(.:format)      posts#new
  edit_admin_post GET       /admin/posts/:id/edit(.:format) posts#edit
       admin_post GET       /admin/posts/:id(.:format)      posts#show
                  PATCH/PUT /admin/posts/:id(.:format)      posts#update
                  DELETE    /admin/posts/:id(.:format)      posts#destroy
Customer.where(id: 1).pick(:id)

18.4 ids

[ids][]는 테이블의 primary key를 사용하여 relation의 모든 ID를 pluck 하는데 사용할 수 있습니다.

irb> Customer.ids
SELECT id FROM customers
class Customer < ApplicationRecord
  self.primary_key = "customer_id"
end
irb> Customer.ids
SELECT customer_id FROM customers

19 Existence of Objects

객체가 존재하는지 단순히 확인하고 싶다면 exists? 라는 메서드를 사용할 수 있습니다. 이 메서드는 find와 동일한 쿼리를 사용하여 데이터베이스를 조회하지만, 객체나 객체 컬렉션을 반환하는 대신 true 또는 false를 반환합니다.

Customer.exists?(1)

Customer 레코드의 ID가 1인 레코드가 존재하는지 확인합니다.

exists? 메서드는 여러 값을 받을 수 있지만, 주의할 점은 이러한 레코드들 중 하나라도 존재하면 true를 반환한다는 것입니다.

Customer.exists?(id: [1, 2, 3]) 
# 또는 
Customer.exists?(first_name: ["Jane", "Sergei"])

모델이나 relation에서 어떤 인수도 없이 exists?를 사용하는 것도 가능합니다.

Customer.where(first_name: "Ryan").exists?

where 조건절 안에 명시된 조건을 만족하는 record가 하나 이상 존재하는지 확인합니다.

위 코드는 first_name이 'Ryan'인 customer가 적어도 하나 있으면 true를 반환하고, 그렇지 않으면 false를 반환합니다.

exists?를 호출하는 것은 해당 모델의 테이블에 레코드가 한 개 이상 존재하는 지를 확인하는 가장 단순한 방법입니다.

위의 코드는 customers 테이블이 비어있으면 false를, 그렇지 않으면 true를 반환합니다.

모델이나 relation에 대해 존재 여부를 확인하기 위해 any?many?도 사용할 수 있습니다. many?는 아이템이 존재하는지 확인하기 위해 SQL count를 사용합니다.

# model을 통해
Order.any?
# SELECT 1 FROM orders LIMIT 1
Order.many?
# SELECT COUNT(*) FROM (SELECT 1 FROM orders LIMIT 2)

# named scope을 통해 
Order.shipped.any?
# SELECT 1 FROM orders WHERE orders.status = 0 LIMIT 1
Order.shipped.many?
# SELECT COUNT(*) FROM (SELECT 1 FROM orders WHERE orders.status = 0 LIMIT 2)

# relation을 통해
Book.where(out_of_print: true).any?
Book.where(out_of_print: true).many?

# association을 통해
Customer.first.orders.any?
Customer.first.orders.many?

20 Calculations

이 섹션의 서두에서는 count 를 예시 메서드로 사용하지만, 설명하는 옵션들은 모든 하위 섹션에 적용됩니다.

모든 계산 메서드는 모델에서 직접 동작합니다:

irb> Customer.count
SELECT COUNT(*) FROM customers

또는 relation에서:

irb> Customer.where(first_name: 'Ryan').count
SELECT COUNT(*) FROM customers WHERE (first_name = 'Ryan')

relation에서 복잡한 계산을 수행하기 위해 다양한 finder 메서드들을 사용할 수 있습니다:

irb> Customer.includes("orders").where(first_name: 'Ryan', orders: { status: 'shipped' }).count

이는 Ryan이라는 first_name을 가진 Customer 중에서 shipped 상태의 orders를 가진 고객의 수를 계산합니다.

실행될 내용:

SELECT COUNT(DISTINCT customers.id) FROM customers
  LEFT OUTER JOIN orders ON orders.customer_id = customers.id
  WHERE (customers.first_name = 'Ryan' AND orders.status = 0)

Order가 enum status: [ :shipped, :being_packed, :cancelled ]를 가지고 있다고 가정합니다.

20.1 count

모델 테이블에 있는 레코드의 수를 확인하고 싶다면 Customer.count를 호출하면 그 숫자가 반환됩니다. 더 구체적으로 데이터베이스에 title이 있는 모든 customer를 찾고 싶다면 Customer.count(:title)을 사용할 수 있습니다.

옵션에 대해서는 상위 섹션인 Calculations를 참조하세요.

20.2 average

테이블의 특정 숫자의 평균을 보고 싶다면 해당 테이블과 관련된 클래스에서 average 메서드를 호출할 수 있습니다. 이 메서드 호출은 다음과 같습니다:

Order.average("subtotal")

평균값을 구합니다. subtotal 열의 평균값을 반환합니다.

이는 해당 field의 평균값을 나타내는 숫자(3.14159265와 같은 부동소수점 숫자일 수 있음)를 반환합니다.

옵션에 대해서는 상위 섹션인 Calculations를 참조하세요.

20.3 minimum

테이블의 특정 필드에서 최소값을 찾으려면 해당 테이블과 관련된 클래스에서 minimum 메서드를 호출할 수 있습니다. 이 메서드 호출은 다음과 같은 형태입니다:

Order.minimum("subtotal")

주문(Order) 테이블의 subtotal 컬럼의 최솟값을 반환합니다.

옵션에 대해서는 상위 섹션인 Calculations를 참조하세요.

20.4 maximum

테이블의 필드에서 최대값을 찾으려면 테이블과 관련된 class에서 maximum 메서드를 호출할 수 있습니다. 이 메서드 호출은 다음과 같이 사용됩니다:

Order.maximum("subtotal")

subtotal 컬럼의 최대값을 계산합니다.

옵션에 대해서는 상위 섹션인 Calculations을 참조하세요.

20.5 sum

테이블의 모든 레코드에 대한 필드의 합계를 구하려면 해당 테이블과 관련된 클래스에서 sum 메서드를 호출할 수 있습니다. 이 메서드 호출은 다음과 같이 보일 것입니다:

Order.sum("subtotal")

옵션에 대해서는 상위 섹션인 Calculations를 참조하세요.

21 EXPLAIN 실행하기

relation에 대해 explain을 실행할 수 있습니다. EXPLAIN 출력은 데이터베이스마다 다릅니다.

예를 들어, 다음을 실행하면:

Customer.where(id: 1).joins(:orders).explain

위 쿼리는 Customer 모델과 관련된 orders 테이블을 JOIN 하는 쿼리의 실행 계획을 표시합니다.

MySQL과 MariaDB에서는 다음과 같이 출력될 수 있습니다:

EXPLAIN SELECT `customers`.* FROM `customers` INNER JOIN `orders` ON `orders`.`customer_id` = `customers`.`id` WHERE `customers`.`id` = 1
+----+-------------+------------+-------+---------------+
| id | select_type | table      | type  | possible_keys |
+----+-------------+------------+-------+---------------+
|  1 | SIMPLE      | customers  | const | PRIMARY       |
|  1 | SIMPLE      | orders     | ALL   | NULL          |
+----+-------------+------------+-------+---------------+
+---------+---------+-------+------+-------------+
| key     | key_len | ref   | rows | Extra       |
+---------+---------+-------+------+-------------+
| PRIMARY | 4       | const |    1 |             |
| NULL    | NULL    | NULL  |    1 | Using where |
+---------+---------+-------+------+-------------+

2개의 행이 설정됨 (0.00 )

Active Record는 해당 데이터베이스 셸과 유사한 형태로 예쁘게 출력을 수행합니다. 따라서 PostgreSQL adapter로 동일한 쿼리를 실행하면 다음과 같이 출력됩니다:

EXPLAIN SELECT "customers".* FROM "customers" INNER JOIN "orders" ON "orders"."customer_id" = "customers"."id" WHERE "customers"."id" = $1 [["id", 1]]
                                  실행 계획
------------------------------------------------------------------------------
 Nested Loop (비용=4.33..20.85 =4 너비=164)
    ->  Index Scan using customers_pkey on customers (비용=0.15..8.17 =1 너비=164)
          Index Cond: (id = '1'::bigint)
    ->  Bitmap Heap Scan on orders (비용=4.18..12.64 =4 너비=8)
          Recheck Cond: (customer_id = '1'::bigint)
          ->  Bitmap Index Scan on index_orders_on_customer_id (비용=0.00..4.18 =4 너비=0)
                Index Cond: (customer_id = '1'::bigint)
(7 rows)

Eager loading은 내부적으로 두 개 이상의 쿼리를 트리거할 수 있으며, 일부 쿼리는 이전 쿼리의 결과가 필요할 수 있습니다. 이러한 이유로 explain은 실제로 쿼리를 실행하고, 그 후에 쿼리 계획을 요청합니다. 예를 들어, 다음을 실행하면:

Customer.where(id: 1).includes(:orders).explain

includes와 연결된 SQL 쿼리들을 보고 싶으면 explain을 사용할 수 있습니다. explain은 실행되는 모든 쿼리에 관한 세부 정보를 보여줍니다.

MySQL과 MariaDB의 경우 다음과 같은 결과가 나올 수 있습니다:

EXPLAIN SELECT `customers`.* FROM `customers`  WHERE `customers`.`id` = 1
+----+-------------+-----------+-------+---------------+
| id | select_type | table     | type  | possible_keys |
+----+-------------+-----------+-------+---------------+
|  1 | SIMPLE      | customers | const | PRIMARY       |
+----+-------------+-----------+-------+---------------+
+---------+---------+-------+------+-------+
| key     | key_len | ref   | rows | Extra |
+---------+---------+-------+------+-------+
| PRIMARY | 4       | const |    1 |       |
+---------+---------+-------+------+-------+

1개의 행이 집합에 있습니다 (0.00 )

EXPLAIN SELECT `orders`.* FROM `orders`  WHERE `orders`.`customer_id` IN (1)
+----+-------------+--------+------+---------------+
| id | select_type | table  | type | possible_keys |
+----+-------------+--------+------+---------------+
|  1 | SIMPLE      | orders | ALL  | NULL          |
+----+-------------+--------+------+---------------+
+------+---------+------+------+-------------+
| key  | key_len | ref  | rows | Extra       |
+------+---------+------+------+-------------+
| NULL | NULL    | NULL |    1 | Using where |
+------+---------+------+------+-------------+


1개의 행이 집합에 있습니다 (0.00 )

그리고 PostgreSQL의 경우 다음과 같이 출력될 수 있습니다:

  Customer Load (0.3ms)  SELECT "customers".* FROM "customers" WHERE "customers"."id" = $1  [["id", 1]]
  Order Load (0.3ms)  SELECT "orders".* FROM "orders" WHERE "orders"."customer_id" = $1  [["customer_id", 1]]
=> EXPLAIN SELECT "customers".* FROM "customers" WHERE "customers"."id" = $1 [["id", 1]]
                                    쿼리 계획
----------------------------------------------------------------------------------
 인덱스 스캔: customers_pkey 사용하여 customers 테이블에 대해  (비용=0.15..8.17 rows=1 width=164)
   인덱스 조건: (id = '1'::bigint)
(2 rows)

21.1 Explain 옵션

데이터베이스와 adapter가 지원하는 경우(현재 PostgreSQL, MySQL, MariaDB), 더 자세한 분석을 위해 옵션을 전달할 수 있습니다.

PostgreSQL을 사용할 때 다음과 같이 할 수 있습니다:

Customer.where(id: 1).joins(:orders).explain(:analyze, :verbose)

위 쿼리는 주어진 id를 가진 모든 customer 레코드와 그들의 orders를 검색하고, 사용된 쿼리 실행 계획의 자세한 분석 정보를 표시합니다. :analyze 옵션은 실제 실행 통계를 포함하고, :verbose 옵션은 모든 세부 정보를 출력합니다.

yields:

EXPLAIN (ANALYZE, VERBOSE) SELECT "shop_accounts".* FROM "shop_accounts" INNER JOIN "customers" ON "customers"."id" = "shop_accounts"."customer_id" WHERE "shop_accounts"."id" = $1 [["id", 1]]
                                                                   QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------
 Nested Loop (비용=0.30..16.37 =1 너비=24) (실제 시간=0.003..0.004 =0 반복=1)
   출력: shop_accounts.id, shop_accounts.customer_id, shop_accounts.customer_carrier_id 
   내부 유일: true
   ->  인덱스 스캔 using shop_accounts_pkey on public.shop_accounts (비용=0.15..8.17 =1 너비=24) (실제 시간=0.003..0.003 =0 반복=1)
         출력: shop_accounts.id, shop_accounts.customer_id, shop_accounts.customer_carrier_id
         인덱스 조건: (shop_accounts.id = '1'::bigint)
   ->  인덱스 전용 스캔 using customers_pkey on public.customers (비용=0.15..8.17 =1 너비=8) (실행되지 않음)
         출력: customers.id
         인덱스 조건: (customers.id = shop_accounts.customer_id)
          가져오기: 0
 계획 시간: 0.063 ms
 실행 시간: 0.011 ms
(12 rows)

MySQL 또는 MariaDB를 사용하는 경우 다음과 같습니다:

Customer.where(id: 1).joins(:orders).explain(:analyze)

위 코드는 PostgreSQL의 실제 쿼리 실행 계획과 실제 런타임 통계를 포함한 쿼리 분석을 반환합니다.

yields: 결과를 반환합니다:

ANALYZE SELECT `shop_accounts`.* FROM `shop_accounts` INNER JOIN `customers` ON `customers`.`id` = `shop_accounts`.`customer_id` WHERE `shop_accounts`.`id` = 1  
+----+-------------+-------+------+---------------+------+---------+------+------+--------+----------+------------+--------------------------------+
| id | select_type | table | type | possible_keys | key  | key_len | ref  | rows | r_rows | filtered | r_filtered | Extra                          |
+----+-------------+-------+------+---------------+------+---------+------+------+--------+----------+------------+--------------------------------+
|  1 | SIMPLE      | NULL  | NULL | NULL          | NULL | NULL    | NULL | NULL | NULL   | NULL     | NULL       | const 테이블에서 일치하는  없음 |
+----+-------------+-------+------+---------------+------+---------+------+------+--------+----------+------------+--------------------------------+ 
1 row in set (0.00 sec)

EXPLAIN과 ANALYZE 옵션은 MySQL과 MariaDB 버전에 따라 다릅니다. (MySQL 5.7, MySQL 8.0, MariaDB)

21.2 EXPLAIN 해석하기

EXPLAIN 출력을 해석하는 것은 본 가이드의 범위를 벗어납니다. 다음 링크들이 도움이 될 수 있습니다:



맨 위로