Rails:一意の交換可能なインデックス制約を設定する方法

Railsで一意性の検証を設定することは、非常に頻繁に行うことになります。おそらく、あなたはすでにそれらをほとんどのアプリに追加しているでしょう。ただし、この検証では、優れたユーザーインターフェイスとエクスペリエンスしか提供されません。これは、データがデータベースに永続化されるのを妨げるエラーをユーザーに通知します。

一意性の検証だけでは不十分な理由

一意性の検証を行っても、不要なデータがデータベースに保存されることがあります。わかりやすくするために、以下に示すユーザーモデルを見てみましょう。

class User validates :username, presence: true, uniqueness: true end 

ユーザー名の列を検証するために、railsはSELECTを使用してデータベースにクエリを実行し、ユーザー名がすでに存在するかどうかを確認します。含まれている場合は、「ユーザー名は既に存在します」と出力されます。そうでない場合は、INSERTクエリを実行して、データベースに新しいユーザー名を保持します。

2人のユーザーが同時に同じプロセスを実行している場合、データベースは検証制約に関係なくデータを保存できることがあり、そこでデータベース制約(一意のインデックス)が使用されます。

ユーザーAとユーザーBの両方が同時に同じユーザー名をデータベースに保持しようとしている場合、railsはSELECTクエリを実行します。ユーザー名がすでに存在する場合は、両方のユーザーに通知します。ただし、ユーザー名がデータベースに存在しない場合は、次の画像に示すように、両方のユーザーに対して同時にINSERTクエリを実行します。

データベースの一意のインデックス(データベースの制約)が重要である理由がわかったので、それを設定する方法を見てみましょう。Railsの任意の列または列のセットにデータベース固有のインデックスを設定するのは非常に簡単です。ただし、レールのデータベースの制約には注意が必要です。

1つ以上の列に一意のインデックスを設定する方法の概要

これは、移行を実行するのと同じくらい簡単です。ユーザー名の列を持つusersテーブルがあり、各ユーザーが一意のユーザー名を持っていることを確認したいとします。移行を作成し、次のコードを入力するだけです。

add_index :users, :username, unique: true 

次に、移行を実行すると、それだけです。データベースにより、同様のユーザー名がテーブルに保存されないことが保証されます。

複数の関連する列について、sender_id列とreceiver_id列を持つrequestsテーブルがあると仮定します。同様に、移行を作成して次のコードを入力するだけです。

add_index :requests, [:sender_id, :receiver_id], unique: true 

以上です?ええと、それほど速くはありません。

上記の複数列の移行に関する問題

問題は、この場合、IDが交換可能であるということです。つまり、sender_idが1でreceiver_idが2の場合、リクエストテーブルには、保留中のリクエストが既にある場合でも、sender_idが2、receiver_idが1で保存できます。

この問題は、自己参照の関連付けでよく発生します。これは、送信者と受信者の両方がユーザーであり、sender_idまたはreceiver_idがuser_idから参照されることを意味します。user_id(sender_id)が1のユーザーは、user_id(receiver_id)が2のユーザーにリクエストを送信します。

受信者が別のリクエストを再度送信し、それをデータベースに保存できるようにすると、リクエストテーブルに同じ2人のユーザー(送信者と受信者||受信者と送信者)からの2つの同様のリクエストがあります。

これを下の画像に示します。

一般的な修正

この問題は、多くの場合、以下の擬似コードで修正されます。

def force_record_conflict # 1. Return if there is an already existing request from the sender to receiver # 2. If not then swap the sender and receiver end 

このソリューションの問題は、データベースに保存する前に、receiver_idとsender_idが毎回スワップされることです。したがって、receiver_id列はsender_idを保存する必要があり、その逆も同様です。

たとえば、sender_idが1のユーザーが、receiver_idが2のユーザーにリクエストを送信した場合、リクエストテーブルは次のようになります。

これは問題のようには聞こえないかもしれませんが、列に保存したいデータを正確に保存している方がよいでしょう。これには多くの利点があります。たとえば、receiver_idを介して受信者に通知を送信する必要がある場合は、receiver_id列から正確なIDをデータベースに照会します。これは、リクエストテーブルに保存されているデータの切り替えを開始した瞬間にすでに混乱を招きました。

適切な修正

This problem can be entirely resolved by talking to the database directly. In this case, I’ll explain using PostgreSQL. When running the migration, you must ensure that the unique constraint checks for both (1,2) and (2,1) in the request table before saving.

You can do that by running a migration with the code below:

class AddInterchangableUniqueIndexToRequests < ActiveRecord::Migration[5.2] def change reversible do |dir| dir.up do connection.execute(%q( create unique index index_requests_on_interchangable_sender_id_and_receiver_id on requests(greatest(sender_id,receiver_id), least(sender_id,receiver_id)); create unique index index_requests_on_interchangable_receiver_id_and_sender_id on requests(least(sender_id,receiver_id), greatest(sender_id,receiver_id)); )) end dir.down do connection.execute(%q( drop index index_requests_on_interchangable_sender_id_and_receiver_id; drop index index_requests_on_interchangable_receiver_id_and_sender_id; )) end end end end 

Code explanation

After creating the migration file, the reversible is to ensure that we can revert our database whenever we must. The dir.up is the code to run when we migrate our database and dir.down will run when we migrate down or revert our database.

connection.execute(%q(...)) is to tell rails that our code is PostgreSQL. This helps rails to run our code as PostgreSQL.

Since our “ids” are integers, before saving into the database, we check if the greatest and least (2 and 1) are already in the database using the code below:

requests(greatest(sender_id,receiver_id), least(sender_id,receiver_id)) 

Then we also check if the least and greatest (1 and 2) are in the database using:

requests(least(sender_id,receiver_id), greatest(sender_id,receiver_id)) 

The request table will then be exactly how we intend as shown in the image below:

And that’s it. Happy coding!

References:

Edgeguides | Thoughtbot