티스토리 뷰

프로그래밍 공부/TIL : Rails Tutorial

Chapter 7 : Active Record Query Interface

마음 따뜻한 개발자, 나른한 하루 2020. 7. 7. 18:45
이 글은 Rails 5.0 Guide 기준으로 작성됩니다.
https://guides.rubyonrails.org/v5.0/active_record_querying.html

 

 

  • Active Record Query Interface Intro

기존의 SQL을 사용하여 데이터베이스 레코드를 찾는 데 익숙하다면 일반적으로 Rails에서 동일한 작업을 수행하는 더 좋은 방법이 있다는 것을 알게 될 것입니다. Active Record는 대부분의 경우 SQL을 사용하지 않아도됩니다.

 

이 글의 전체 코드 예제는 다음 모델 중 하나 이상을 참조합니다.

 참고  다음의 모든 모델의 기본키는 따로 지정하지 않는 한 id를 기본키로 사용합니다.

class Client < ApplicationRecord
  has_one :address
  has_many :orders
  has_and_belongs_to_many :roles
end
class Address < ApplicationRecord
  belongs_to :client
end
class Order < ApplicationRecord
  belongs_to :client, counter_cache: true
end
class Role < ApplicationRecord
  has_and_belongs_to_many :clients
end

Active Record는 데이터베이스에서 Query를 수행하며, MySQL, MariaDB, PostgreSQL 및 SQLite를 포함한 대부분의 데이터베이스 시스템과 호환됩니다. 사용중인 데이터베이스 시스템에 관계없이 Active Record 방법 형식은 항상 동일합니다.

 

 

  • Retrieving Objects from the Database

데이터베이스에서 개체를 검색하기 위해 Active Record는 다양한 finder method을 제공합니다. 각 finder method를 사용하면 SQL을 작성하지 않고 데이터베이스에서 특정 Query를 수행하기 위해 인수(argument)를 전달할 수 있습니다.

find
create_with
distinct
eager_load
extending
from
group
having
includes
joins
left_outer_joins
limit
lock
none
offset
order
preload
readonly
references
reorder
reverse_order
select
distinct
where

where 및 group과 같은 collection을 반환하는 Finder method는 ActiveRecord::Relation의 instance를 반환합니다. find 및 first와 같은 단일 entity(하나의 데이터)를 찾는 method는 Model의 단일 instance를 반환합니다.

 

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

  • 제공된 옵션을 동등한 SQL 쿼리로 변환
  • SQL 쿼리를 시작하고 데이터베이스에서 해당 결과를 검색
  • 모든 결과 행에 대해 적절한 모델의 동등한 Ruby 객체를 instance화
  • after_find를 실행 한 후 after_initialize 콜백 (있는 경우)을 실행하십시오.

 

단일 라인 객체탐색

Active Record는 단일 개체를 검색하는 여러 가지 method를 사용할 수 있습니다.

 

1. find

기본키 기반의 id값을 입력받아 데이터를 탐색합니다.

## ORM
Bulletin.find(32)

## SQL
SELECT * FROM bulletins WHERE (bulletins.id = 32) LIMIT 1

 

 참고  다음과 같이 여러개의 데이터를 찾아내볼 수도 있습니다.

## ORM
Bulletin.find([1, 3])

## SQL
SELECT * FROM bulletins WHERE (bulletins.id IN (1,3))

 raise error Message  find!

=> ActiveRecord::RecordNotFound

 

2. take

take 메소드는 순서없이 랜덤으로 데이터를 검색합니다.

## ORM
Bulletin.take

## SQL
SELECT * FROM bulletins LIMIT 1

데이터가 없고 예외가 발생하지 않으면 take method는 nil을 반환합니다. take 메소드에 숫자 인수(argument)를 전달하여 갯수에 맞는 결과를 반환할 수 있습니다.

## ORM
Bulletin.take(2)

## SQL
SELECT * FROM bulletins LIMIT 2

 참고 1  데이터가 아무것도 없을 경우, nil(NULL) 반환

 참고 2  데이터 정렬 기준은 Database 내 로직에 따라 다름. (지금은 ASC 정렬로 보여지나, 꼭 이게 아닐 수 있단 얘기임.)

 raise error 메소드 및 Message  take!

=> ActiveRecord::RecordNotFound

 

3. first

first method는 기본키(default)로 정렬 된 첫번째 데이터를 찾습니다.

## ORM
Bulletin.first

## SQL
SELECT * FROM bulletins ORDER BY clients.id ASC LIMIT 1

기본 scope에 order method가 포함 된 경우, first는 order method에 따라 첫 번째 레코드를 반환합니다.

first method에 숫자를 전달하여 갯수에 맞는 결과를 반환할 수 있습니다.

## ORM
clients = Client.first(3)
# => [
#   #<Client id: 1, first_name: "Lifo">,
#   #<Client id: 2, first_name: "Fifo">,
#   #<Client id: 3, first_name: "Filo">
# ]

## SQL
SELECT * FROM clients LIMIT 2

order를 사용하여 정렬이 된 collection와 함께 first 메소드 사용 시 반환되는 record 중, 첫 번째 record를 반환합니다.

## ORM
client = Client.order(:first_name).first
# => #<Client id: 2, first_name: "Fifo">

## SQL
SELECT * FROM clients ORDER BY clients.first_name ASC LIMIT 1

 참고  이터가 아무것도 없을 경우, nil(NULL) 반환

 raise error 메소드 및 Message  first!

=> ActiveRecord::RecordNotFound

 

4. last

last metho는 기본키(default)로 정렬 된 마지막 record를 찾습니다.

## ORM
Bulletin.last

## SQL
SELECT * FROM bulletins ORDER BY bulletins.id DESC LIMIT 1

기본 scope에 order method가 포함 된 경우, last는 order method에 따라 마지막 record를 반환합니다.

last method에 숫자를 전달하여 갯수에 맞는 결과를 반환할 수 있습니다.

 

order를 사용하여 정렬이 된 collection와 함께 last 메소드 사용 시 반환되는 record 중, 마지막 record를 반환합니다.

Model.order(:title).last

# Bulletin Load (0.6ms)  SELECT  "bulletins".* FROM "bulletins" ORDER BY "bulletins"."title" DESC LIMIT ?  [["LIMIT", 1]]

 참고  데이터가 아무것도 없을 경우, nil(NULL) 반환

 raise error 메소드 및 Message  last!

=> ActiveRecord::RecordNotFound

 

5. find_by

find_by 메소드는 탐색조건과 일치하는 결과 중 첫 번째 record만을 찾습니다.

## ORM
Client.find_by first_name: 'Lifo'
# => #<Client id: 1, first_name: "Lifo">

## SQL
SELECT * FROM clients WHERE (clients.first_name = 'Lifo') LIMIT 1

아래와 같은 표현(take) 역시 find_by와 동일하다고 볼 수 있습니다.

Client.where(first_name: 'Lifo').take

 참고  데이터가 아무것도 없을 경우, nil(NULL) 반환

 raise error 메소드 및 Message  last!

=> ActiveRecord::RecordNotFound

 

Retrieving Multiple Objects in Batches

많은 사용자에게 newsletter를 보내거나 데이터를 내보낼 때와 같이 많은 레코드 전송을 반복해야 하는 경우가 종종 있습니다.

아래와 같은 예시처럼요 :

# This may consume too much memory if the table is big.
User.all.each do |user|
  NewsMailer.weekly(user).deliver_now
end

여기서 all 메소드는 모든 레코드를 보여주는 메소드입니다.

 

그리고 단순히 위 방식으로 데이터를 보여줄 경우, 전체 테이블 을 fetch 후, row 당 객체를 빌드 후, 전체 배열을 메모리에 유지 및 Return하도록 지시하기 때문에 이 방법은 테이블 크기가 증가함에 따라 점점 비실용적 입니다. 실제로 많은 수의 레코드가있는 경우 잔여 메모리 양을 초과 할 수 있습니다.

 

Rails는 처리를 위해 레코드를 메모리 친화적 인 배치로 나누어이 문제를 해결하는 두 가지 방법을 제공합니다.

  • 첫 번째 방법인 find_each는 일련의 레코드를 검색 한 다음 각 레코드를 Model에 개별적으로 block에 생성합니다.

  • 두 번째 방법 인 find_in_batches는 batch 레코드를 검색 한 다음, 전체 batch를 Model 배열 형태로 block에 생성합니다.

 참고  find_each 및 find_in_batches method는 한 번 작업에 있어 메모리 용량에 맞지 않는 많은 수의 레코드를 일괄 처리하는 데 사용됩니다. 수천 개의 record를 반복해야 하는 경우, find method가 선호됩니다.

 

1. find_each

Model이 가진 데이터를 탐색 후, 각 레코드의 객체 데이터를 블록(do-end 사이) 내에 개별적으로(반복적으로) 생성 합니다.

 

일괄적으로 탐색 후, 각 record를 block에 생성합니다.

다음 예제에서 find_each는 1000 단위로 사용자를 검색하여 하나씩 블록에 생성합니다.

## user 모델이 가진 모든 데이터를 Active Job으로 보낸 후, 작업(메일 보내기)

User.find_each do |user|
  NewsMailer.weekly(user).deliver_now
end

모든 레코드가 처리 될 때까지 이 프로세스가 반복되어 필요에 따라 더 많은 batch를 가져옵니다.

find_each는 위와 같이 Model Class와 관계(Association)에서도 작동합니다.

## User 모델에서 weekly_subscriber: true 인 조건을 가진 데이터 대해서만 탐색

User.where(weekly_subscriber: true).find_each do |user|
  NewsMailer.weekly(user).deliver_now
end

또한 다음과 같은 옵션을 통해 block을 제어할 수 있습니다 :

Bulletin.find_each(start: 3, finish: 6, batch_size: 10) do |data|
  (do something...)
end

receiver에 order(정렬)이 있는 경우 동작은 config.active_record.error_on_ignored_order 에 따라 다릅니다.

  • true이면 ArgumentError가 발생
  • false일 경우, 순서가 무시되고 경고가 발행됩니다 (기본값).

아래 설명 된 :error_on_ignore 옵션으로 이를 재정의 할 수 있습니다.

 

Options for find_each

  1) :batch_size

  기본 1000으로 설정된 Record 수를 조절할 수 있습니다.

 

  2) :start

  데이터 탐색 시작지점을 정합니다. WHERE ("tables"."id" >= 3)

 

  3) :finish

  데이터 탐색 종료지점을 정합니다. 보통 :start 옵션과 함께 쓰입니다. WHERE ("tables"."id" <= 6)

 

  4) :error_on_ignore

  order이 relation에있을 때 오류가 발생해야 하는지(raise) 여부를 지정하기 위해 Application 구성을 재정의합니다.

 

 참고  메소드 등을 통한 제어는 못합니다.

 

2. find_in_batches

find_in_batches method는 모두 record barch를 검색하므로 find_each와 유사합니다만, 차이점이 있다면 find_in_batches는 개별적으로가 아니라 Model의 배열로 block에 barch를 생성한다는 것입니다.

 

다음 예제는 제공된 블록에 한 번에 최대 1000개의 invoices 배열을 생성하고 마지막 block에는 남은 invoices이 포함됩니다.

# Give add_invoices an array of 1000 invoices at a time

Invoice.find_in_batches do |invoices|
  export.add_invoices(invoices)
end

 

find_in_batches는 위와 같이 Model Class와 간 관계(relations)에서도 작동합니다.

Invoice.pending.find_in_batches do |invoices|
  pending_invoices_export.add_invoices(invoices)
end

 

Options for find_in_batches

find_in_batches 메소드는 find_each와 동일한 옵션을 가지고 있습니다. (:batch_size, :start, :finish, :error_on_ignore)

 

 

  • conditions

where 메소드를 사용하면 SQL 문의 WHERE 부분과 동일하게 조건을 지정할 수 있습니다.

조건은 다음과 같은 type에서 줄 수 있습니다 : String, Array, Hash

 

Pure String Conditions

Client.where("orders_count = '2'")

찾기에 조건을 추가하려면 위와 같이 조건(Where)을 지정하면됩니다.

위 코드 같은 경우 orders_count 필드 값이 2 인 모든 클라이언트를 찾습니다.


 주의  SQL Injection

 부록  SQL Injection Prevention Techniques for Ruby on Rails Web Applications

ORM을 활용한 데이터 탐색에 있어 다음과 같은 방식으로 데이터 탐색을 할 수 있습니다.

b_title = "안녕하세요"
Bulletin.where("bulletins.title LIKE '%#{b_title}%'")

# => title 컬럼에 '안녕하세요' 문자가 포함된 모든 데이터 탐색

하지만 위 방법은 Rails에서는 SQL의 보안에 취약하다 보니 추천하지 않는 방법입니다.

b_title = "' OR 1='1"
Bulletin.where("bulletins.title LIKE '%#{b_title}%'")

위와같이 true를 반환하는 문법을 통해 모든 데이터 결과를 볼 수 있는 취약점이 생깁니다.

그래서 Rails에서는 아래와 같은 방법을 권장합니다.

b_title = 안녕하세요
Bulletin.where("bulletins.title LIKE ?", '%#{b_title}%')

과거에는 직접적으로 변수 혹은 문자를 같은 위치선상에 놓았더라면, 이번 방법은 조건절과 문자값을 따로 분리를 하는겁니다.

위 방법을 통해 SQL Injection을 방지할 수 있습니다.


Array Conditions

이제 조건문 갯수가 달라질 수 있다면, 어디에서 이에 대해 표현을 해낼까요?

예를들어, find는 아래와 같은 형식을 띈다고 치겠습니다.

Client.where("orders_count = ?", params[:orders])

Active Record는 첫 번째 인수(argument)를 조건 문자열로 사용하며 추가된 인수는 물음표 (?)로 표현됩니다.

여러개의 조건을 지정하려는 경우엔 아래와 같이 코드를 작성해주면 됩니다. :

Client.where("orders_count = ? AND locked = ?", params[:orders], false)

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

Rails에서는 아래의 'Good' case와 같이 작성하는 것이 선호됩니다.

## Good
Client.where("orders_count = ?", params[:orders])

## Bad
Client.where("orders_count = #{params[:orders]}")

이는 argument safety 때문입니다. 변수를 조건 문자열에 직접 넣으면 변수가 그대로 데이터베이스에 전달됩니다. 이는 악의적인 의도를 가진 사용자로부터 직접 이스케이프 처리되지 않은 변수가 됨을 의미합니다. 이렇게하면 사용자가 데이터베이스에 대한 모든 작업을 간접적으로 수행할 수 있기 때문에 전체 데이터베이스가 위험에 노출됩니다.(SQL Injection) 조건 문자열 안에 직접 인수를 넣지 마세요.

 참고  SQL Injection의 위험에 대한 자세한 내용은 Ruby on Rails Security Guide를 참조하십시오.

 

Placeholder Conditions

(?)와 같은 대체 스타일의 params와 유사하게 조건키에 해당 키/값 Hash와 함께 키를 지정할 수도 있습니다.

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

이는 변수 조건이 많은 경우 가독성이 향상됩니다.

 

Hash Conditions

Active Record를 사용 시, 조건문의 가독성을 높일 수있는 Hash 조건을 전달할 수 있습니다. 해시 조건을 사용하면 정규화하려는 Key와 값이 포함 된 Hash를 전달합니다.

 참고  Hash 조건에서는 동등성(equality), 범위(range) 및 subset 검사만 가능합니다.

 

Hash는 기본적으로 아래와 같이 표현할 수 있습니다.

## ORM
Client.where(locked: true)

## SQL
SELECT * FROM clients WHERE (clients.locked = 1)

또한 위의 표현을 아래와 같이 string 형태로 동일하게 해낼 수 있습니다.

Client.where('locked' => true)

belong_to 관계의 경우, Active Record 객체가 값으로 사용되는 경우 연관키(association key)를 사용하여 모델을 지정할 수 있습니다. 이 방법은 polymorphic에서도 작동합니다.

Article.where(author: author)
Author.joins(:articles).where(articles: { author: author })

 

Range Conditions

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

SQL문 BETWEEN 문법을 활용해서 어제(yesterday) 작성된 모든 Client를 찾을 수 있습니다.

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

이는 Array 조건의 예제에 대한 더 짧은 구문을 할 수 있습니다.

 

Subset Conditions

IN 표현식을 사용하여 데이터를 찾고자 할 시, 조건(Where) 해시에 배열을 전달하면 됩니다.

## ORM
Client.where(orders_count: [1,3,5])

## SQL
SELECT * FROM clients WHERE (clients.orders_count IN (1,3,5))

 

NOT Conditions

조건 탐색에 있어 where.not 표현을 통해 부정 조건을 할 수 있습니다.

Client.where.not(locked: true)

즉,이 쿼리는 인수(argument)없이 where을 호출 한 다음, where 조건을 전달하지 않고 부정문을 추가하여 아래와 같은 SQL이 작동됩니다.

SELECT * FROM clients WHERE (clients.locked != 1)

 

OR Conditions

두 관계 간의 OR 조건은 첫 번째 관계를 호출하거나, 호출하고 두 번째 관계를 인수(argument)로 전달하여 작성할 수 있습니다.

## ORM
Client.where(locked: true).or(Client.where(orders_count: [1,3,5]))

## SQL
SELECT * FROM clients WHERE (clients.locked = 1 OR clients.orders_count IN (1,3,5))

 

 

  • Ordering

특정 순서로 데이터베이스에서 레코드를 검색하기 위해 order 메소드를 사용할 수 있습니다.

예를 들어, 레코드를 가져 와서 테이블의 created_at 필드에 따라 오름차순으로 정렬하려는 경우 다음과 같이 표현할 수 있습니다. :

Client.order(:created_at)
# OR
Client.order("created_at")

 

ASC(오름차순) 혹은 DESC(내림차순) 정렬을 지정할 수도 있습니다.

Client.order(created_at: :desc)
# OR
Client.order(created_at: :asc)
# OR
Client.order("created_at DESC")
# OR
Client.order("created_at ASC")

 

여러 컬럼에 대해서도 정렬을 설정할 수 있습니다.

Client.order(orders_count: :asc, created_at: :desc)
# OR
Client.order(:orders_count, created_at: :desc)
# OR
Client.order("orders_count ASC, created_at DESC")
# OR
Client.order("orders_count ASC", "created_at DESC")

 

order 메소드를 여러번 사용 시, subsequent order이 이어서 append 됩니다.

Client.order("orders_count ASC").order("created_at DESC")
# SELECT * FROM clients ORDER BY orders_count ASC, created_at DESC

 참고  대부분의 데이터베이스 시스템에서 select, pluck 및 id와 같은 메소드를 사용하여 결과 집합과 구분되는 필드를 선택할 때; order 메소드에 사용된 필드가 선택 목록에 포함되어 있지 않으면 ActiveRecord::StatementInvalid 예외를 발생시킵니다. 결과 집합에서 필드를 선택(select)하려면 다음 섹션(Selecting Specific Fields)을 참조하십시오.

 

 

  • Selecting Specific Fields

기본적으로 Model.find는 select *를 사용하여 모든 필드를 선택합니다.

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

 

예를 들어 viewable_by 및 locked 컬럼만 선택하려면 다음을 수행하십시오.:

Client.select(:viewable_by, :locked)
# OR
Client.select("viewable_by, locked")

위의 문법에서 보여지는 SQL 쿼리는 아래와 같습니다.

SELECT viewable_by, locked FROM clients

SELECT 문법은 선택한 필드만으로 Model 객체를 초기화한다는 의미이므로 주의하세요. 초기화 된 레코드에 없는 필드에 접근하려고 하면 아래의 메시지가 보입니다.

ActiveModel::MissingAttributeError: missing attribute: <attribute>

여기서 <attribute>는 요청한 속성입니다. id 메소드는 ActiveRecord::MissingAttributeError 를 발생시키지 않으므로 연관(Associations) 기능을 수행 할 때는 id 메소드가 제대로 작동해야하기 때문에주의해야합니다.

 

특정 필드에서 중복이 제거된 값을 가져오려 한다면, distinct를 사용할 수 있습니다.

## ORM
Client.select(:name).distinct

## SQL
SELECT DISTINCT name FROM clients

고유성 제한 조건을 제거 할 수도 있습니다.

query = Client.select(:name).distinct
# => Returns unique names
 
query.distinct(false)
# => Returns all names, even if there are duplicates

 

 

 

  • Limit and Offset

Model.find에 의해 발생 된 SQL에 LIMIT를 적용하기 위해 관계(relation)에서 limit 및 offset 메소드를 사용하여 LIMIT를 지정할 수 있습니다.

  • limit을 사용하여 검색할 레코드 수를 지정할 수 있습니다.
  • offset을 사용하여 레코드 반환을 시작하기 전에 건너 뛸 레코드 수를 지정할 수 있습니다.

LIMIT을 활용한 아래코드는 처음부터 5개의 레코드를 반환합니다.

그리고 작업 결과 실행되는 SQL은 아래와 같습니다.

## ORM
Client.limit(5)

## SQL
SELECT * FROM clients LIMIT 5

 

OFFSET은 대신 앞의 30명은 건너뛴 채로 31명 부터 최대 5명의 Client가 반환됩니다.

## ORM
Client.limit(5).offset(30)

## SQL
SELECT * FROM clients LIMIT 5 OFFSET 30

 

 

  • Group

파인더가 실행 한 SQL에 GROUP BY절을 적용하기 위해 GROUP 메소드를 사용할 수 있습니다.

예를 들어, 주문이 작성된 date collection을 찾으려면 아래와 같이 코드를 표현하면 됩니다.

## ORM
Order.select("date(created_at) as ordered_date, sum(price) as total_price").group("date(created_at)")

## SQL
SELECT date(created_at) as ordered_date, sum(price) as total_price
FROM orders
GROUP BY date(created_at)

GROUP BY는 SQL에서 나온 결과에 대해 attribute name을 그룹화하는 기능입니다.

 

Total of grouped items

단일 쿼리에서 그룹화 된 항목의 총 갯수(total of grouped items)를 얻으려면 group 뒤의 count 메소드를 사용하세요.

## ORM
Order.group(:status).count
# => { 'awaiting_approval' => 7, 'paid' => 12 }

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

 

 

  • Having

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

 

HAVING은 GROUP BY 분류 내에서 조건탐색을 할 때 사용됩니다.

## SQL
Order.select("date(created_at) as ordered_date, sum(price) as total_price").
  group("date(created_at)").having("sum(price) > ?", 100)
  
## ORM
SELECT date(created_at) as ordered_date, sum(price) as total_price
FROM orders
GROUP BY date(created_at)
HAVING sum(price) > 100

 

 

  • Overriding Conditions

unscope

사용하지 않을 메소드를 지정할 수 있습니다.

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

위 구문의 표현은 SQL에서는 아래와 같이 표현됩니다. (실제로 SQL 상에선 ORDER BY가 쓰이지 않은게 확인됩니다.)

SELECT * FROM articles WHERE id > 10 LIMIT 20
 
# Original query without `unscope`
SELECT * FROM articles WHERE id > 10 ORDER BY id asc LIMIT 20

 

특정 where 조건에 대해서도 unscope 할 수도 있습니다.

Article.where(id: 10, trashed: false).unscope(where: :id)
# SELECT "articles".* FROM "articles" WHERE trashed = 0

 

unscope를 사용한 관계(relation)는 합쳐지는 관계에 있어서도 영향을 미칩니다.

Article.order('id asc').merge(Article.unscope(:order))
# SELECT "articles".* FROM "articles"

 

only

only 메소드를 사용하여 조건을 override 할 수도 있습니다.

Article.where('id > 10').limit(20).order('id desc').only(:order, :where)

위 코드에 대한 SQL 표현은 아래와 같습니다. (실제로 SQL 상에선 ORDER BY, WHERE만 쓰인게 확인됩니다.)

SELECT * FROM articles WHERE id > 10 ORDER BY id DESC
 
# Original query without `only`
SELECT * FROM articles WHERE id > 10 ORDER BY id DESC LIMIT 20

 

reselect

기존 select 문을 재정의 합니다. (실제로 SQL 상에선 created_at만 select 되는게 확인됩니다.)

## ORM
Post.select(:title, :body).reselect(:created_at)

## SQL
SELECT `posts`.`created_at` FROM `posts`

만약 위 코드에서 reselect를 쓰지 않았다면, 아래와 같은 결과를 볼 수 있습니다.

## ORM
Post.select(:title, :body).select(:created_at)

## SQL
SELECT `posts`.`title`, `posts`.`body`, `posts`.`created_at` FROM `posts`

 

reorder

기본 범위 order를 재정의 합니다.

class Article < ApplicationRecord
  has_many :comments, -> { order('posted_at DESC') }
end
 
Article.find(10).comments.reorder('name')

위 코드에 대한 SQL 표현은 아래와 같습니다. (실제로 SQL 상에선 posted_at이 아닌 name이 정렬 기준이 된게 확인됩니다.)

## SQL
SELECT * FROM articles WHERE id = 10 LIMIT 1
SELECT * FROM comments WHERE article_id = 10 ORDER BY name

reorder이 사용되지 않는 경우 실행 된 SQL은 다음과 같습니다.

## SQL
SELECT * FROM articles WHERE id = 10 LIMIT 1
SELECT * FROM comments WHERE article_id = 10 ORDER BY posted_at DESC

 

reverse_order

ORDER을 반대로 바꿉니다. (실제로 SQL 상에선 기존의 ASC 정렬이 DESC로 reverse order 된게 확인됩니다.)

## ORM
Client.where("orders_count > 10").order(:name).reverse_order

## SQL
SELECT * FROM clients WHERE orders_count > 10 ORDER BY name DESC

Query에 ordering 컬럼이 지정되지 않으면 reverse_order는 기본키를 역순으로 정렬합니다.

## ORM
Client.where("orders_count > 10").reverse_order

## SQL
SELECT * FROM clients WHERE orders_count > 10 ORDER BY clients.id DESC

 

rewhere

기존의 조건절(where)에 대해 다시 새로이 정의합니다. (실제로 SQL 상에선 기존의 true(1)이 false(0)로 조건문이 변경된게 확인됩니다.)

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

## SQL
SELECT * FROM articles WHERE `trashed` = 0

rewhere 절을 사용하지 않는 경우 실행결과는 아래와 같습니다.

## ORM
Article.where(trashed: true).where(trashed: false)

## SQL
SELECT * FROM articles WHERE `trashed` = 1 AND `trashed` = 0

 

 

  • Null Relation

none 메소드는 레코드가 없는 연결 가능한 관계를 반환합니다. 반환된 관계에 연결된 모든 후속 조건은 계속 빈 관계를 생성합니다. 이 방법은 0 반환이 필요한 scope, 연결 응답이 필요한 시나리오에 유용합니다.

Article.none # returns an empty Relation and fires no queries.
# The visible_articles method below is expected to return a Relation.
@articles = current_user.visible_articles.where(name: params[:name])
 
def visible_articles
  case role
  when 'Country Manager'
    Article.where(country: country)
  when 'Reviewer'
    Article.published
  when 'Bad User'
    Article.none # => returning [] or nil breaks the caller code in this case
  end
end

 

 

  • Readonly Objects

Active Record는 반환 된 객체의 수정을 명시 적으로 허용하지 않는 관계에 대한 읽기 전용 메소드를 제공합니다. readonly record를 변경하려고 하면 ActiveRecord::ReadOnlyRecord 예외가 발생합니다.

client = Client.readonly.first
client.visits += 1
client.save

 

client가 명시 적으로 읽기 전용 객체로 설정되어 있으므로 위의 코드는 Update 후, client.save를 호출 시 ActiveRecord::ReadOnlyRecord 예외를 발생시킵니다.

 

 

  • Locking Records for Update

데이터베이스 작업에 있어 2개 이상의 작업이 진행될 경우 때로는 프로세스 내에서 작업을 하는 쓰레드 자원이 꼬임으로 인해 'race condition' 문제가 발생하고, 우리가 기대한 처리결과가 발생하지 않을 수 있습니다.

 

이를 위한 해결방법은 Locking 이라는 개념을 활용하는겁니다.

* Locking : 두 개 이상의 작업이 동시 진행될 때, 이전 작업이 끝날 때 까지 대기

Active Record에서는 2개의 locking 메커니즘을 제공합니다.

이에 대해서 살펴보겠습니다.

 

1. Optimistic Locking

Optimistic locking은 여러 사용자가 편집을 위해 동일한 레코드에 접근할 수 있으며 데이터와의 최소 충돌을 가정합니다. 다른 프로세스가 레코드 조회 후 수정했는지 여부를 확인하여 이를 수행합니다. ActiveRecord::StaleObjectError 예외가 발생하면 업데이트가 무시됩니다.

 

Optimistic locking column

테이블 컬럼 중 lock 버전 엧크를 위해 존재하는 lock_version

optimistic locking을 사용하려면 테이블에 integer 유형의 lock_version이라는 컬럼이 있어야 합니다. 레코드가 업데이트 될 때마다 활성 레코드는 lock_version을 증가시킵니다. 현재 데이터베이스의 lock_version 컬럼에 쓰여진 값 보다 더 낮은 값으로 업데이트 요청을 하는 경우, ActiveRecord::StaleObjectError와 함께 업데이트 요청이 실패합니다.

c1 = Client.find(1)
c2 = Client.find(1)
 
c1.first_name = "Michael"
c1.save
 
c2.name = "should fail"
c2.save # Raises an ActiveRecord::StaleObjectError

 

충돌에 있어 exception에 대해 rescue문법 처리 후 rollback, merging, business logic을 적용하여 충돌을 대비해야합니다.

ActiveRecord::Base.lock_optimistically = false 를 통해 Optimistic locking 사용을 비활성화 시킬 수 있습니다.

 

lock_version 컬럼 이름을 override 할 수 있습니다. (ActiveRecord::Base는 locking_column이라는 class 속성을 제공합니다.) : 

class Client < ApplicationRecord
  self.locking_column = :lock_client_column
end

 

2. Pessimistic Locking

 부록  Pessimistic Locking In Rails

Pessimistic locking은 기본 데이터베이스에서 제공하는 locking 메커니즘을 사용합니다. 관계(relation)를 작성할 때 lock을 사용하면 선택한 행(row)에서 exclusive lock을 얻습니다. 교착 상태(deadlock)를 방지하기 위해 lock을 사용하는 관계는 일반적으로 트랜잭션 내부에 랩핑(wrapped)됩니다.

 

예를들어 아래와 같은 케이스 입니다. :

Item.transaction do
  i = Item.lock.first
  i.name = 'Jones'
  i.save!
end

위 세션은 MySQL 백엔드에 대해 아래 SQL 결과가 나옵니다.

SQL (0.2ms)   BEGIN
Item Load (0.3ms)   SELECT * FROM `items` LIMIT 1 FOR UPDATE
Item Update (0.4ms)   UPDATE `items` SET `updated_at` = '2009-02-07 18:05:56', `name` = 'Jones' WHERE `id` = 1
SQL (0.8ms)   COMMIT

다른 유형의 잠금을 허용하기 위해 원시 SQL을 lock 메소드로 전달할 수도 있습니다. 예를 들어, MySQL에는 LOCK IN SHARE MODE라는 표현식이 있는데 레코드를 잠글 수는 있지만 다른 쿼리는 읽을 수 있습니다. 이 표현식을 지정하려면 lock option으로 전달하면 됩니다 .

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

이미 Model Instance가 있는 경우, 아래 코드를 사용하여 트랜잭션을 시작하고 한 번에 lock을 획득 할 수 있습니다.

item = Item.first
item.with_lock do
  # This block is called within a transaction,
  # item is already locked.
  item.increment!(:views)
end

 

 

  • Joining Tables

Active Record는 결과 SQL에 JOIN 절을 지정하기 위한 두 가지 finder 메소드를 제공합니다: joins, left_outer_joins

 

1. joins

조인 방법을 사용하는 방법에는 여러 가지가 존재합니다.

 

1) Using a String SQL Fragment

특정 JOIN 절을 지정하여 SQL을 사용할 수 있습니다.

## ORM
Author.joins("INNER JOIN posts ON posts.author_id = authors.id AND posts.published = 't'")

## SQL
SELECT authors.* FROM authors INNER JOIN posts ON posts.author_id = authors.id AND posts.published = 't'

 

2) Using Array/Hash of Named Associations

Active Record를 사용하면 모델에 정의된 Association 이름을 조인 방법을 사용할 때 해당 연결에 대한 JOIN 절을 지정하는 바로 가기(shortcut)로 사용할 수 있습니다.

 

예를들어, 아래와 같은 Category, Article, Comment, Guest,Tag 모델에 대해 연관관계가 있습니다.

class Category < ApplicationRecord
  has_many :articles
end
 
class Article < ApplicationRecord
  belongs_to :category
  has_many :comments
  has_many :tags
end
 
class Comment < ApplicationRecord
  belongs_to :article
  has_one :guest
end
 
class Guest < ApplicationRecord
  belongs_to :comment
end
 
class Tag < ApplicationRecord
  belongs_to :article
end

이제 INNER JOIN을 사용하여 예상되는 모든 Join Query를 생성합니다.

 

2-1) Joining a Single Association

## ORM
Category.joins(:articles)

## SQL
SELECT categories.* FROM categories
  INNER JOIN articles ON articles.category_id = categories.id

위를 영어 문장으로 표현하면 다음과 같습니다. : "return a Category object for all categories with articles (=> articles가 있는 모든 category 개체를 반환)"

 

둘 이상의 article에 동일한 categories가 있으면 중복 categories가 표시됩니다. 중복을 제거한 categories를 원하면 distinct 메소드를 활용하면 됩니다. :

Category.joins(:articles).distinct

 

3) Joining Multiple Associations

## ORM
Article.joins(:category, :comments)

## SQL
SELECT articles.* FROM articles
  INNER JOIN categories ON categories.id = articles.category_id
  INNER JOIN comments ON comments.article_id = articles.id

위를 영어 문장으로 표현하면 다음과 같습니다. : "return all articles that have a category and at least one comment (=>카테고리(category)와 하나 이상의 주석(comment)이있는 모든 기사(articles)를 반환)".

comment가 여러개인 article은 여러 번 표시됩니다.

 

3-1) Joining Nested Associations (Single Level)

## ORM
Article.joins(comments: :guest)

## SQL
SELECT articles.* FROM articles
  INNER JOIN comments ON comments.article_id = articles.id
  INNER JOIN guests ON guests.comment_id = comments.id

위를 영어 문장으로 표현하면 다음과 같습니다. : "return all articles that have a comment made by a guest. (=> guest가 작성한 comment이 있는 articles를 모두 반환)"

 

 

3-2) Joining Nested Associations (Multiple Level)

## ORM
Category.joins(articles: [{ comments: :guest }, :tags])

## SQL
SELECT categories.* FROM categories
  INNER JOIN articles ON articles.category_id = categories.id
  INNER JOIN comments ON comments.article_id = articles.id
  INNER JOIN guests ON guests.comment_id = comments.id
  INNER JOIN tags ON tags.article_id = articles.id

위를 영어 문장으로 표현하면 다음과 같습니다. : "return all categories that have articles, where those articles have a comment made by a guest, and where those articles also have a tag. (=> articles이 있는 모든 categories, 해당 articles에 guest이 작성한 comment 및 해당 articles에 tag가 있는 모든 categories를 반환)"

 

4) Specifying Conditions on the Joined Tables

일반 arraystring 조건을 사용하여 조인 된 테이블에 조건을 지정할 수 있습니다. Hash 조건은 결합 된 테이블(joined tables)의 조건을 지정하기 위한 특수 구문을 제공합니다.

time_range = (Time.now.midnight - 1.day)..Time.now.midnight
Client.joins(:orders).where('orders.created_at' => time_range)

깔끔한 구문은 Hash 조건을 중첩(nest)하는 것입니다.

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

어제 작성된 주문이 있는 모든 클라이언트를 다시 BETWEEN SQL 표현식을 사용하여 찾을 수 있습니다.


2. left_outer_joins

연관된 record 유무 여부에 관계없이 record 집합을 선택하려는 경우 left_outer_joins 메소드를 활용할 수 있습니다.

## ORM
Author.left_outer_joins(:posts).distinct.select('authors.*, COUNT(posts.*) AS posts_count').group('authors.id')

## SQL
SELECT DISTINCT authors.*, COUNT(posts.*) AS posts_count FROM "authors"
LEFT OUTER JOIN posts ON posts.author_id = authors.id GROUP BY authors.id

 

 

  • Eager Loading Associations

Eager loading은 가능한 적은 Query를 사용하여 관련 레코드를 조회하는 메커니즘입니다.

 

1. SQL N+1 Problem

위 결과는 comments가 가진 정보 중, bulletin_id를 참고해서 부모 Model인 bulletins의 :title 을 보여주는 간단한 코드입니다.

그런데 매번 결과를 보여줄 때 마다 SQL을 탐색해내는 비효율적인 탐색결과를 보여주고 있습니다. (지금은 데이터가 적어서 그렇지, 나중에 1000개 이상의 데이터에서 위와같은 현상이 나타나면 큰 골칫덩이 입니다.)

 

Active Record를 사용하면 조회될 모든 연결(associations)을 사전에 미리 정할 수 있습니다. 그 중 하나로 Model.find 호출의 include 메소드를 활용하면 됩니다. includes 메소드를 사용하면 Active Record는 지정된 모든 연결이 가능한 최소한의 쿼리 수를 통해 조회되도록 할 수 있습니다.

 

위의 경우를 다시 살펴보면 Client.limit(10)을 addresses에 대해 eager load 되도록 아래와 같이 개편해서 작성할 수 있습니다. :

Comment.includes(:bulletin).each do |t|
  puts "[#{t.id}] #{t.bulletin.title}"
end

 

이제 초반의 코드와는 달리 2개의 쿼리만으로 결과를 가져옵니다.

 

1. Eager Loading Multiple Associations

Active Record를 사용하면 include 메소드를 사용하여 Array, Hash, nested hash of array/hash를 사용하여 단일 Model.find 호출과 관계없이 많은 수의 Association 을 조회할 수 있습니다.

 

Array of Multiple Associations

Article.includes(:category, :comments)

위 ORM은 모든 articles와 관련된 category, 각 article에 대한 comment이 조회됩니다.

 

Nested Associations Hash

Category.includes(articles: [{ comments: :guest }, :tags]).find(1)

This will find the category with id 1 and eager load all of the associated articles, the associated articles' tags and comments, and every comment's guest association.

ID가 1 인 category가 있다면

  • category와 관련된 articles
  • article와 관련된 tags
  • article와 관련된 comments
  • 모든 comments의 guest association

위 내용들이 eager load됩니다.

 

eager_loads는 includes 메소드를 통해서도 표현할 수 있습니다.

 참고 1  includes 메소드는 preload(사전 테이블 참조), eager_load(테이블 조인) 두 개의 성격을 모두 가지고 있습니다.

 참고 2  includes 메소드에서 preload는 where 메소드를 쓰지 못합니다.

 부록  A Visual Guide to Using :includes in Rails

# Bulletin(id: integer, title: string, content: text, pwd: string, condition: boolean, company: boolean, created_at: datetime, updated_at: datetime, comments_count: integer)
# Comment(id: integer, bulletin_id: integer, body: string, is_admin: boolean, created_at: datetime, updated_at: datetime)

Bulletin.includes(:comments).where(comments: {is_admin: true})
Bulletin.includes(:comments).where("bulletins.condition = ?", true).references(:comments)

includes 메소드를 where 메소드와 함께 쓸 시, eager_load 방식으로 사용됨.

2. Specifying Conditions on Eager Loaded Associations

Active Record를 사용하면 join처럼 eager load 된 연관에서 조건을 지정할 수 있지만, 권장되는 방법은 join을 사용하는 것입니다.

However if you must do this, you may use where as you would normally.

그러나 이 작업을 수행해야 하는 경우 평소와 같이 어디에서나 사용할 수 있습니다.

 

joins 메소드는 대신 INNER JOIN을 사용하는 반면, eager_load는 LEFT OUTER JOIN을 사용합니다.

## ORM
Article.includes(:comments).where(comments: { visible: true })

## SQL
SELECT "articles"."id" AS t0_r0, ... "comments"."updated_at" AS t1_r5 FROM "articles" LEFT OUTER JOIN "comments" ON "comments"."article_id" = "articles"."id" WHERE (comments.visible = 1)

where 조건이 없으면 정상적인 두 쿼리 집합이 생성됩니다.

 

 참고   where  과 같은 문법은 Hash를 전달할 때만 작동합니다. SQL-fragments의 경우  references  를 사용하여 Join된 테이블을 강제로 실행해야 합니다.

Article.includes(:comments).where("comments.visible = true").references(:comments)

여기서 includes 메소드가 쓰이는 경우, articles에 대한 comments가 없으면 모든 articles는 계속 조회됩니다.

조인(INNER JOIN)을 사용하면 Join 조건이 일치해야합니다. 그렇지 않으면 record가 반환되지 않습니다.

 참고  Association이 Join의 일부로 eager_load되면 사용자 지정 select 절의 필드가 로드 된 모델에 표시되지 않습니다. 이는 부모 record에 표시되어야 하는지 or 자식에 표시되어야하는지 모호하기 때문입니다.

 

 

  • Scope

Chapter 1 때 당시 CoC 규약을 통해 코드의 반복 사용을 줄이기 위한 예시로 scope 문법을 설명했었습니다.

이처럼 Scope는 코드의 반복을 줄이면서, Model 파일에서 블록 혹은 메소드 타입을 생성 후 SQL 쿼리를 다루도록 도와주는 메소드 입니다.

 

scope을 사용하면 연결 개체 또는 Model에서 method 호출로 참조 할 수 있는 일반적으로 사용되는 Query를 지정할 수 있습니다. where, joins, includes과 같이 이전에 다룬 모든 method를 사용할 수 있습니다.

 

모든 scope body는 ActiveRecord::Relation 또는 nil을 반환하여 추가 메서드(예 : 다른 scope)를 호출 할 수 있도록 해야합니다.

class Article < ApplicationRecord
  scope :published, -> { where(published: true) }
end

scope는 또한 scope 내에서 체인(chainable)이 가능합니다.

class Article < ApplicationRecord
  scope :published,               -> { where(published: true) }
  scope :published_and_commented, -> { published.where("comments_count > 0") }
end

published scope는 class 중 하나에서 메소드를 호출 할 수 있습니다.

Article.published # => [published articles]

또는 Article 객체로 구성된 Association에서도 호출할 수 있습니다.

category = Category.first
category.articles.published # => [published articles belonging to this category]

 

1. Passing in arguments

변수를 전달해야 할 경우, 다음과 같이 해내면 됩니다.

class 메소드 인 것처럼 scope를 사용할 수 있습니다.

class Article < ApplicationRecord
  scope :created_before, ->(time) { where("created_at < ?", time) }
end
Article.created_before(Time.zone.now)

 

scope 방법으로 메소드를 만드는 법도 있지만, 기본적인 방법으로 method를 만들어 내는 방법도 있습니다. class 메소드를 사용하는 것이 scope에 대한 인수(arguments)를 받음에 있어 선호되는 방법입니다.

class Article < ApplicationRecord
  def self.created_before(time)
    where("created_at < ?", time)
  end
end

메소드는 Association 객체에서도 접근 가능합니다.

category.articles.created_before(time)

 

2. Using conditionals

스코프는 조건(where)을 사용할 수 있습니다.

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

 

다른 예제와 마찬가지로 Class method와 비슷하게 동작합니다.

class Article < ApplicationRecord
  def self.created_before(time)
    where("created_at < ?", time) if time.present?
  end
end

 

그러나 한 가지 중요한 주의사항이 있습니다. 조건은 false로 평가 되더라도 class method는 nil을 반환하지만, scope는 항상 ActiveRecord::Relation 개체를 반환합니다. 조건 중 하나라도 false를 반환하면 조건부와 class method를 연결할 때 NoMethodError가 발생할 수 있습니다.

 

3. Applying a default scope

scope를 모든 Query에서 Model에 기본으로 적용하려면 Model 자체에서 default_scope 메서드를 사용할 수 있습니다.

class Client < ApplicationRecord
  default_scope { where("removed_at IS NULL") }
end

이 Model에서 Query가 실행되면 이제 SQL Query는 아래와 같습니다.

SELECT * FROM clients WHERE removed_at IS NULL

default_scope로 더 복잡한 작업을 수행해야하는 경우 class method으로 통해 정의 할 수도 있습니다.

class Client < ApplicationRecord
  def self.default_scope
    # Should return an ActiveRecord::Relation.
  end
end

 

 참고  scope 인수(argument)가 Hash로 제공 될 때 레코드를 작성/빌드하는 동안 default_scope에도 적용됩니다. record를 Update하는 동안에는 적용되지 않습니다. (아래 예시코드 참고)

class Client < ApplicationRecord
  default_scope { where(active: true) }
end
 
Client.new          # => #<Client id: nil, active: true>
Client.unscoped.new # => #<Client id: nil, active: nil>

 

Array 형식으로 주어질 경우, default_scope의 Query 인수(arguments)를 Hash로 변환 할 수 없습니다. (아래 예시코드 참고)

class Client < ApplicationRecord
  default_scope { where("active = ?", true) }
end
 
Client.new # => #<Client id: nil, active: nil>

 

4. Merging of scopes

AND 조건을 사용하여 scope를 합치는 것과 같습니다.

class User < ApplicationRecord
  scope :active, -> { where state: 'active' }
  scope :inactive, -> { where state: 'inactive' }
end
 
User.active.inactive
# SELECT "users".* FROM "users" WHERE "users"."state" = 'active' AND "users"."state" = 'inactive'

 

scope, where 조건문, 최종 SQL이 AND와 JOIN 된 모든 조건을 갖도록 scope를 혼합(mix)하고 일치(match)시킬 수 있습니다.

User.active.where(state: 'finished')
# SELECT "users".* FROM "users" WHERE "users"."state" = 'active' AND "users"."state" = 'finished'

 

SQL에서 마지막에 where 절을 사용하길 원하면 Relation#merge를 사용할 수 있습니다.

User.active.merge(User.inactive)
# SELECT "users".* FROM "users" WHERE "users"."state" = 'inactive'

 

한 가지 중요한 주의 사항default_scope가 제일 먼저 실행(scope 및 where 앞에 추가) 된다는 것입니다.

class User < ApplicationRecord
  default_scope { where state: 'pending' }
  scope :active, -> { where state: 'active' }
  scope :inactive, -> { where state: 'inactive' }
end
 
User.all
# SELECT "users".* FROM "users" WHERE "users"."state" = 'pending'
 
User.active
# SELECT "users".* FROM "users" WHERE "users"."state" = 'pending' AND "users"."state" = 'active'
 
User.where(state: 'inactive')
# SELECT "users".* FROM "users" WHERE "users"."state" = 'pending' AND "users"."state" = 'inactive'

위에서 볼 수 있듯, default_scope는 기존의 scope와 where 조건이 합쳐집니다.

 

4. Removing All Scoping

어떤 이유로 든 scope를 제거하려면 이를 위해 unscope method을 사용할 수 있습니다.

이미 default_scope가 지정되어 있고, 이 특정 Query에 적용해서는 안되는 경우에 특히 유용합니다.

Client.unscoped.load

unscope 메소드는 사전에 정의된 모든 scope를 제거하고 테이블에서는 일반 Query로 작업을 수행합니다.

Client.unscoped.all
# SELECT "clients".* FROM "clients"
 
Client.where(published: false).unscoped.all
# SELECT "clients".* FROM "clients"

unscoped는 블록을 허용 할 수도 있습니다.

Client.unscoped {
  Client.created_before(Time.zone.now)
}

 

 

  • Dynamic Finders

테이블에 정의한 모든 필드(attribute 라고도 함)에 대해 Active Record는 finder 메소드를 제공합니다. 예를 들어 Client 모델에 first_name이라는 필드가 있으면 Active Record에서 find_by_first_name 라는 메소드를 사용할 수 있습니다. Client Model에 lock 필드가 있으면 find_by_locked 메소드도 얻을 수 있습니다. 만약 어떤 컬럼을 통해 데이터를 찾아야 할 때 다이나믹하게 탐색을 할 수 있습니다.

 

Client.find_by_name!("Ryan") 과 같은 레코드를 반환하지 않으면 동적 finder 끝에 느낌표(!)를 지정하여 ActiveRecord::RecordNotFound Raise error를 발생시킬 수 있습니다.

 

name과 locked으로 모두 찾으려면 필드 사이에 "and"를 입력하여 이러한 finder를 서로 연결(chain)할 수 있습니다.

Client.find_by_first_name_and_locked ("Ryan", true)

 

 

 

  • Enums

열거 형 매크로(Enums)는 정수 열을 집합에 매핑합니다.

class Book < ApplicationRecord
  enum availability: [:available, :unavailable]
end

그러면 모델을 Query 할 scope가 자동으로 생성됩니다. state를 전환하고 현재 state를 Query하는 method도 추가됩니다.

# Both examples below query just available books.
Book.available
# or
Book.where(availability: :available)
 
book = Book.new(availability: :available)
book.available?   # => true
book.unavailable! # => true
book.available?   # => false

 

 간단 활용예시  Enums

Bulletin 모델에서 status 컬럼(:integer)이 있다 했을 때, status 컬럼에 값에 어떤 숫자가 쓰여졌냐에 따라 입력된 데이터가 변환되서 작성이 됩니다.

# Bulletin(id: integer, title: string, content: text, pwd: string, condition: boolean, company: boolean, status: integer, created_at: datetime, updated_at: datetime, comments_count: integer)

class Bulletin < ApplicationRecord
  enum status: [:great, :good, :not_so_good]
end

만약에 status에 1이라는 데이터를 넣을 경우, 'good' 이라는 값이 :status 컬럼에 저장이 됩니다.

이는 status 컬럼의 순서는 기본적으로 다음과 같이 맵핑이 되었기 때문입니다.

class Bulletin < ApplicationRecord
  enum status: { great: 0, good: 1, not_so_good: 2 }
end

만약 enum과 문자의 매칭에 대해 다시 수정을 하고 싶을 경우, 위의 코드의 숫자와 문자값을 수정을 해주면 됩니다.

enum status: { great: 2, good: 3, not_so_good: 6 }

# 위 2, 3, 6 숫자 외로 표현 시, ArgumentError 에러 raise

 

Enums에 대해 더 궁금하다면, Rails API Documents를 참고해주세요.

 

 

  • Understanding The Method Chaining

Active Record 패턴은 여러 메소드를 섞어서 사용할 수 있게 합니다.

 

호출 된 이전 메소드가 all, where, join과 같이 ActiveRecord::Relation을 반환 할 때 메소드를 연결(chain)할 수 있습니다. 단일 객체를 반환하는 메소드(Retrieving a Single Object Section 문서 참조)는 명령어의 끝(Model 이름 뒤)에 있어야합니다.

 

이전에 배워왔던 all, joins, where 등 단일 객체를 반환하는 메소드를 사용하기 위해선 명령어의 끝(Model 이름 뒤)에 있어야 합니다.

 

아래에 몇 가지 예가 있습니다. 이 문서는 모든 가능성을 생각하지는 않고, 몇 가지 예를 들어 설명합니다.

Active Record 메소드가 호출되면 Query가 즉시 생성되어 Database로 전송되지 않으며, 이는 실제로 데이터가 필요할 때 발생합니다. 따라서 아래의 각 예는 단일 쿼리를 생성합니다.


1. Retrieving filtered data from multiple tables

Person
  .select('people.id, people.name, comments.text')
  .joins(:comments)
  .where('comments.created_at > ?', 1.week.ago)

위 ORM 표현은 아래와 같은 SQL 결과가 나옵니다.

SELECT people.id, people.name, comments.text
FROM people
INNER JOIN comments
  ON comments.person_id = people.id
WHERE comments.created_at = '2015-01-01'

 

 

2. Retrieving specific data from multiple tables

Person
  .select('people.id, people.name, companies.name')
  .joins(:company)
  .find_by('people.name' => 'John') # this should be the last

위 ORM 표현은 아래와 같은 SQL 결과가 나옵니다.

SELECT people.id, people.name, companies.name
FROM people
INNER JOIN companies
  ON companies.person_id = people.id
WHERE people.name = 'John'
LIMIT 1

 참고  find_by로 탐색에 있어 일치하는 데이터가 많을 경우, 첫 번째 레코드 만 가져오고 다른 레코드는 무시합니다.

 

 

  • Find or Build a New Object
find_or_create_by
find_or_create_by!

레코드를 탐색했으나, 존재하지 않을 경우 사용되는 메소드입니다.

 

1. find_or_create_by

조건에 맞는 데이터가 있는지 탐색한 후 있을 경우 데이터 결과를, 없을 경우 새로운 데이터를 생성(create)합니다.

Client.find_or_create_by(first_name: 'Andy')
# => #<Client id: 1, first_name: "Andy", orders_count: 0, locked: true, created_at: "2011-08-30 06:09:27", updated_at: "2011-08-30 06:09:27">

만약에 first_name 조건에 일치하는 데이터가 있었더라면 위와같이 반환되겠지만,

없을 경우에는 조건문에 명시됐던 value를 토대로 새로운 데이터를 생성합니다.

 

만약 데이터 검색결과 아무것도 없으나, 새 데이터가 저장되지 않았다면 이는 validation을 제대로 통과했는지 살펴봐야 할 필요가 있습니다. (이를테면 create 액션이 작동된다면?)

 

만약 새로운 데이터를 find_or_create_by 함에 있어, 검색 조건에는 넣진 않겠으나 새로 데이터 생성 시, 컬럼에 내용을 넣고 싶은 경우 다음과 같이도 사용할 수 있습니다.

# 처음에 검색할 때 있어, first_name 에 대한 조건을 가지고 검색을 하나
# 데이터가 없을 시 새로 생성될 때에는 locked: false 가 된 채로 데이터 생성

Client.create_with(locked: false).find_or_create_by(first_name: 'Andy')
Client.find_or_create_by(first_name: 'Andy') do |c|
  c.locked = false
end

 

2. find_or_create_by!

find_or_create_by 메소드와 동작 원리는 비슷하나, create 가 아닌 new 메소드로서 데이터가 초기화가 됩니다. 해당 메소드를 통해 생성된 새로운 Model 인스턴스가 메모리에 할당은 되겠으나, 데이터베이스 서버까지 저장이 되진 않습니다.

validates :orders_count, presence: true

아래와 같은 ORM 형식으로 Client 모델에 새 데이터를 작성하려고 하면 record가 유효하지 않으며(orders_count 누락) 예외가 발생합니다.

Client.find_or_create_by!(first_name: 'Andy')
# => ActiveRecord::RecordInvalid: Validation failed: Orders count can't be blank

 

3. find_or_initialize_by

find_or_initialize_by 메소드는 find_or_create_by와 동일하게 작동하지만 create 대신 new를 호출합니다. 이는 새 모델 인스턴스가 메모리에 생성되지만 데이터베이스에는 저장되지 않습니다.

nick = Client.find_or_initialize_by(first_name: 'Nick')
# => #<Client id: nil, first_name: "Nick", orders_count: 0, locked: true, created_at: "2011-08-30 06:09:27", updated_at: "2011-08-30 06:09:27">
 
nick.persisted?
# => false
 
nick.new_record?
# => true

find_or_initialize_by 후에 데이터베이스에 데이터를 저장하고 싶다면, save 메소드를 call해주면 됩니다.

nick.save

 

 

  • Finding by SQL

SQL을 사용하여 테이블에서 데이터를 찾고자 한다면, find_by_sql을 사용할 수 있습니다.

find_by_sql 메소드는 (기본 Query가 단일 레코드만 반환하더라도) 객체 배열을 반환합니다.

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

find_by_sql은 데이터베이스에 대한 사용자 정의 호출을 수행하고 instance화 된 객체를 탐색하는 간단한 방법을 제공합니다.

 

1. select_all

find_by_sql에는 connection#select_all과 비슷한 관계가 있습니다. select_all은 find_by_sql과 같이 사용자 정의 SQL을 사용하여 데이터베이스에서 객체를 검색하지만 instance화 하지는 않습니다. 이 메소드는 ActiveRecord::Result Class의 instance를 반환하고, 이 객체에서 to_a를 호출하면 각 Hash가 레코드를 나타내는 Hash Array을 반환합니다.

Client.connection.select_all("SELECT first_name, created_at FROM clients 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"}
# ]

 

2. pluck

pluck은 모델의 기본 테이블에서 단일 또는 여러 컬럼을 탐색하는데 사용됩니다. 컬럼 이름 목록을 인수(argument)로 허용하고 해당 데이터 유형(data type)을 가진 지정된 컬럼 value를 배열 형태로 반환합니다.

Client.where(active: true).pluck(:id)
# SELECT id FROM clients WHERE active = 1
# => [1, 2, 3]
 
Client.distinct.pluck(:role)
# SELECT DISTINCT role FROM clients
# => ['admin', 'member', 'guest']
 
Client.pluck(:id, :name)
# SELECT clients.id, clients.name FROM clients
# => [[1, 'David'], [2, 'Jeremy'], [3, 'Jose']]

pluck은 아래와 같은 코드로 사용할 있습니다.

Client.select(:id).map { |c| c.id }
# or
Client.select(:id).map(&:id)
# or
Client.select(:id, :name).map { |c| [c.id, c.name] }

## 위 코드를 아래와 같이 변형 가능
Client.pluck(:id)
# or
Client.pluck(:id, :name)

select와 달리 pluck는 ActiveRecord 객체를 생성하지 않고 데이터베이스 결과를 Ruby 배열로 직접 변환합니다. 이는 대규모 또는 자주 실행되는 쿼리의 성능이 향상 될 수 있음을 의미합니다. 그러나 Model method override는 할 수 없습니다.

class Client < ApplicationRecord
  def name
    "I am #{super}"
  end
end
 
Client.select(:name).map &:name
# => ["I am David", "I am Jeremy", "I am Jose"]
 
Client.pluck(:name)
# => ["David", "Jeremy", "Jose"]

또한 select 및 기타 Relations 범위와 달리 pluck은 즉시 Query를 trigger 하므로 이전에 이미 구성된 scope에서 작동 할 수 있지만, 추가적인 scope 연결(chain)할 수 없습니다.

Client.pluck(:name).limit(1)
# => NoMethodError: undefined method `limit' for #<Array:0x007ff34d3ad6d8>
 
Client.limit(1).pluck(:name)
# => ["David"]

 

3. ids

id는 테이블의 기본키를 사용하여 관계에 대한 모든 ID를 가져 오는 데 사용할 수 있습니다.

Bulletin.ids

(0.8ms)  SELECT "bulletins"."id" FROM "bulletins"
=> [1, 2, 3, 4, 5, 6, 7, 8, 9]
## Person 모델에서 primary key에 대해 override 적용 예제

class Person < ApplicationRecord
  self.primary_key = "person_id"
end
 
Person.ids
# SELECT person_id FROM people

 

 

  • Existence of Objects

단순히 객체의 존재를 확인하고 싶다면 exist? 라는 메소드가 있습니다. 이 메소드는 find와 동일한 조회를 사용하여 데이터베이스를 조회하지만 객체 또는 객체 collection을 리턴하는 대신, boolean(true 또는 false)를 반환합니다.

* find 메소드는 데이터가 없을 경우 오류 반환

Model.exists?(n)

exists? 메소드는 아래와 같이 여러 데이터 값에 대해서도 조회를 해볼 수 있습니다.

만약 데이터가 존재한다면 true를 반환합니다.

Client.exists?(id: [1,2,3])
# or
Client.exists?(name: ['John', 'Sergei'])

 참고  위의 방법과 같이 여러개의 값에 대해 조회함에 있어, 만약 일부만 존재하더라도 true를 반환, 모든 값들이 존재하지 않아야 false를 반환합니다.

 

exists? 메소드에 들어가는 argument value가 없어도 아래와 같이도 사용 가능합니다.

만약 clients 테이블에서 first_name 컬럼 내 데이터 중, Ryan 이라는 값이 들어있다면 true를 반환합니다.

Client.where(first_name: 'Ryan').exists?

 

테이블 내 데이터 자체가 존재하는지에 대해서도 아래와 같이 판별해볼 수 있습니다.

데이터가 존재할 경우 true를, 존재하지 않을 경우 false를 반환합니다.

Model.exists?

 

이 외에도 아래와 같이 데이터가 존재하는지를 알아봐 주는 다양한 메소드가 존재합니다.

# via a model
Article.any?
Article.many?
 
# via a named scope
Article.recent.any?
Article.recent.many?
 
# via a relation
Article.where(published: true).any?
Article.where(published: true).many?
 
# via an association
Article.first.categories.any?
Article.first.categories.many?

 

 

  • Calculations

SQL 문법 활용에 있어 갯수를 세는 Count 문법, 평균을 계산하는 Average이 있습니다. 레일즈에서는 이와같은 계산을 해내주는 메소드가 존재합니다.

 

계산을 해내는 모든 메소드는 Model에 직접 작동됩니다.

Client.count
# SELECT count(*) AS count_all FROM clients

 

relation과도 함께 사용할 수 있습니다.

Client.where(first_name: 'Ryan').count
# SELECT COUNT(*) FROM clients WHERE (first_name = 'Ryan')

 

복잡한 계산을 수행하기 위해 relation에 다양한 finder 메소드를 사용할 수도 있습니다.

## ORM
Client.includes("orders").where(first_name: 'Ryan', orders: { status: 'received' }).count

## SQL
SELECT COUNT(DISTINCT clients.id) FROM clients
  LEFT OUTER JOIN orders ON orders.client_id = clients.id
  WHERE (clients.first_name = 'Ryan' AND orders.status = 'received')

 

1. Count

Model 테이블에 몇 개의 레코드가 있는지 세고자 할 때 count 메소드를 활용하면 숫자(size)가 반환됩니다.

보다 구체적이고 데이터베이스에 연령이있는 모든 클라이언트를 찾으려면 Client.count (:age)를 사용할 수 있습니다.

Bulletin.count

# (0.1ms)  SELECT COUNT(*) FROM "bulletins"
#  => 63

 

2. Average

테이블 중 하나에서 특정 숫자의 평균을 보려면 테이블과 관련된 Class의 평균 메서드를 호출하면됩니다.

Client.average("orders_count")

field의 평균값을 나타내는 숫자 (3.14159265와 같은 부동 소수점 숫자)를 반환합니다. (float 형으로도 반환 가능)

 

3. Maximum

테이블에서 field최대값을 찾으려면 테이블과 관련된 Class에서 maximum 메소드를 호출 할 수 있습니다.

Client.maximum("age")

 

4. Minimum

테이블에서 field최소값을 찾으려면 테이블과 관련된 Class에서 minimum 메소드를 호출 할 수 있습니다.

Client.minimum("age")

 

5. Sum

테이블의 모든 레코드에 대한 field 합계를 찾으려면 테이블과 관련된 Class에서 sum 메소드를 호출 할 수 있습니다.

Client.sum("orders_count")

 

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

 

 

  • Running EXPLAIN

테이블의 쿼리 탐색 과정에대해 살펴볼 수 있는 메소드로서, explain 메소드가 존재합니다.

만약 아래와 같이 메소드를 활용할 경우 다음과 같이 정의됩니다.

User.where(id: 1).joins(:articles).explain
## PostgreSQL

EXPLAIN for: SELECT "users".* FROM "users" INNER JOIN "articles" ON "articles"."user_id" = "users"."id" WHERE "users"."id" = 1
                                  QUERY PLAN
------------------------------------------------------------------------------
 Nested Loop Left Join  (cost=0.00..37.24 rows=8 width=0)
   Join Filter: (articles.user_id = users.id)
   ->  Index Scan using users_pkey on users  (cost=0.00..8.27 rows=1 width=4)
         Index Cond: (id = 1)
   ->  Seq Scan on articles  (cost=0.00..28.88 rows=8 width=4)
         Filter: (articles.user_id = 1)
(6 rows)

위 결과는 MySQL 및 MariaDB Database에서 보여지는 형식입니다.

(어떤 데이터베이스를 기반으로 explain 메소드를 쓰느냐에 따라 보여지는 형식이 달라질 수 있습니다.)

 

Active Record는 해당 database shell의 인쇄를 모방하는 예쁜 인쇄(pretty printing)를 수행합니다. 따라서 PostgreSQL Adapter로 실행되는 동일한 Query가 대신 생성됩니다.

EXPLAIN for: SELECT "users".* FROM "users" INNER JOIN "articles" ON "articles"."user_id" = "users"."id" WHERE "users"."id" = 1
                                  QUERY PLAN
------------------------------------------------------------------------------
 Nested Loop Left Join  (cost=0.00..37.24 rows=8 width=0)
   Join Filter: (articles.user_id = users.id)
   ->  Index Scan using users_pkey on users  (cost=0.00..8.27 rows=1 width=4)
         Index Cond: (id = 1)
   ->  Seq Scan on articles  (cost=0.00..28.88 rows=8 width=4)
         Filter: (articles.user_id = 1)
(6 rows)

Eager loading 시, hood에서 둘 이상의 Query가 trigger 될 수 있으며 일부 Query는 이전 Query의 결과가 필요할 수 있습니다. 따라서 Explain은 실제로 쿼리를 실행 후, query plans을 요청합니다.

User.where(id: 1).includes(:articles).explain
EXPLAIN for: SELECT `users`.* FROM `users`  WHERE `users`.`id` = 1
+----+-------------+-------+-------+---------------+
| id | select_type | table | type  | possible_keys |
+----+-------------+-------+-------+---------------+
|  1 | SIMPLE      | users | const | PRIMARY       |
+----+-------------+-------+-------+---------------+
+---------+---------+-------+------+-------+
| key     | key_len | ref   | rows | Extra |
+---------+---------+-------+------+-------+
| PRIMARY | 4       | const |    1 |       |
+---------+---------+-------+------+-------+
 
1 row in set (0.00 sec)
 
EXPLAIN for: SELECT `articles`.* FROM `articles`  WHERE `articles`.`user_id` IN (1)
+----+-------------+----------+------+---------------+
| id | select_type | table    | type | possible_keys |
+----+-------------+----------+------+---------------+
|  1 | SIMPLE      | articles | ALL  | NULL          |
+----+-------------+----------+------+---------------+
+------+---------+------+------+-------------+
| key  | key_len | ref  | rows | Extra       |
+------+---------+------+------+-------------+
| NULL | NULL    | NULL |    1 | Using where |
+------+---------+------+------+-------------+
 
 
1 row in set (0.00 sec)

위 결과는 MySQL 및 MariaDB Database에서 보여지는 형식입니다.

(어떤 데이터베이스를 기반으로 explain 메소드를 쓰느냐에 따라 보여지는 형식이 달라질 수 있습니다.)

 

 

  • 자료참고

1. SQL Injection Prevention Techniques for Ruby on Rails Web Applications

2. A Visual Guide to Using :includes in Rails

3. Enums with Rails & ActiveRecord: an improved way

4. ActiveRecord::Enum 데이터형의 활용

5. Benchmark: preload vs. eager_load

 

댓글
댓글쓰기 폼
공지사항
Total
37,630
Today
22
Yesterday
231
링크
«   2020/08   »
            1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31          
글 보관함