見出し画像

【40】【Rails】Acts As List公式ドキュメントの翻訳記事

この記事はgem Acts As List公式ドキュメントの翻訳記事です。
(注意:noteは埋め込みコード内のコメントが非常に見ずらいです。
よってコード内のコメントを一部直書きしています。ご了承下さい。)

Acts As List

Description
This acts_as extension provides the capabilities for sorting and reordering a number of objects in a list. The class that has this specified needs to have a position column defined as an integer on the mapped database table.
0.8.0 Upgrade Notes

このacts_asエクステンションは、リスト内の複数のオブジェクトをソートしたり並べ替えたりする機能を提供します。
これを指定したクラスは、マッピングされたデータベース・テーブル上に整数として定義された位置カラムを持つ必要があります。

There are a couple of changes of behaviour from 0.8.0 onwards:
If you specify add_new_at: :top, new items will be added to the top of the list like always. But now, if you specify a position at insert time: .create(position: 3), the position will be respected. In this example, the item will end up at position 3 and will move other items further down the list. Before 0.8.0 the position would be ignored and the item would still be added to the top of the list. #220
acts_as_list now copes with disparate position integers (i.e. gaps between the numbers). There has been a change in behaviour for the higher_items method. It now returns items with the first item in the collection being the closest item to the reference item, and the last item in the collection being the furthest from the reference item (a.k.a. the first item in the list). #223

0.8.0 アップグレードについて
0.8.0以降では、いくつかの動作の変更があります。
(訳注:この箇所は後で読んだ方が理解しやすいと思います)
add_new_at: :top を指定した場合、新しいアイテムはいつものようにリストの先頭に追加されます。しかし、挿入時に位置を指定した場合、.create(position: 3)とすると、その位置が尊重されるようになりました。この例では、アイテムは3の位置で終了し、他のアイテムをリストのさらに下に移動させます。0.8.0より前のバージョンでは、位置は無視され、アイテムはリストの一番上に追加されたままでした。#220
acts_as_list が位置の異なる整数に対応するようになりました (数字の間にギャップがある場合など)。higher_items メソッドの動作が変更されました。コレクションの最初のアイテムが参照アイテムに最も近いアイテムで、 コレクションの最後のアイテムが参照アイテムから最も遠いアイテム (別名、リストの最初のアイテム) となるアイテムを返すようになりました。#223

Installation

In your Gemfile:

gem 'acts_as_list'

Or, from the command line:

gem install acts_as_list

Example
At first, you need to add a position column to desired table:
(まず、目的のテーブルに位置の列を追加する必要があります。)

rails g migration AddPositionToTodoItem position:integer
rake db:migrate

After that you can use acts_as_list method in the model:

class TodoList < ActiveRecord::Base
 has_many :todo_items, -> { order(position: :asc) }
end

class TodoItem < ActiveRecord::Base
 belongs_to :todo_list
 acts_as_list scope: :todo_list
end

todo_list = TodoList.find(...)
todo_list.todo_items.first.move_to_bottom
todo_list.todo_items.last.move_higher

Instance Methods Added To ActiveRecord Models


You'll have a number of methods added to each instance of the ActiveRecord model that to which acts_as_list is added.
In acts_as_list, "higher" means further up the list (a lower position), and "lower" means further down the list (a higher position). That can be confusing, so it might make sense to add tests that validate that you're using the right method given your context.

ActiveRecordモデルに追加されるインスタンス・メソッド
acts_as_listが追加されたActiveRecordモデルの各インスタンスには、いくつかのメソッドが追加されます。

acts_as_listでは、
"higher "はリストの上の方(下の方)、
"lower "はリストの下の方(上の方)を意味しています。
これでは混乱してしまうので、文脈に応じて正しい方法を使っているかどうかを検証するテストを追加するとよいでしょう。

Methods That Change Position and Reorder List

(位置を変更するメソッドとリストを並べ替えるソッド)
list_item.insert_at(2)
list_item.move_lower
will do nothing if the item is the lowest item
(そのアイテムが最も低いアイテムである場合、何もしません。)
list_item.move_higher
will do nothing if the item is the highest item
(そのアイテムが最も高いアイテムである場合、何もしません。)
list_item.move_to_bottom
list_item.move_to_top
list_item.remove_from_list

Methods That Change Position Without Reordering List

(順番を変えずにポジションを変える方法)
list_item.increment_position
list_item.decrement_position
list_item.set_list_position(3)
Methods That Return Attributes of the Item's List Position
(アイテムのリストポジションの属性を返すメソッド)
list_item.first?
list_item.last?
list_item.in_list?
list_item.not_in_list?
list_item.default_position?
list_item.higher_item
list_item.higher_items
will return all the items above list_item in the list (ordered by the position, ascending)
(リストのlist_itemより上にあるすべてのアイテムを返します(位置の昇順で並びます))
list_item.lower_item
list_item.lower_items
will return all the items below list_item in the list (ordered by the position, ascending)
(リストのlist_itemより下にあるすべてのアイテムを返します(位置の昇順で並びます))

Adding acts_as_list To An Existing Model

As it stands acts_as_list requires position values to be set on the model before the instance methods above will work. Adding something like the below to your migration will set the default position. Change the parameters to order if you want a different initial ordering.

acts_as_listの既存モデルへの追加
acts_as_listでは、上記のインスタンスメソッドが動作する前に、モデルにポジション値が設定されている必要があります。
以下のようなものをマイグレーションに追加すると、デフォルトの位置が設定されます。
初期の順序を変えたい場合は、パラメータをorderに変更してください。

class AddPositionToTodoItem < ActiveRecord::Migration
 def change
   add_column :todo_items, :position, :integer
   TodoItem.order(:updated_at).each.with_index(1) do |todo_item, index|
     todo_item.update_column :position, index
   end
 end
end

scopeオプションを使用している場合は、少し複雑になります。例えばacts_as_list scope: :todo_listとした場合、代わりに以下のようなものが必要になります。

TodoList.all.each do |todo_list|
 todo_list.todo_items.order(:updated_at).each.with_index(1) do |todo_item, index|
   todo_item.update_column :position, index
 end
end
When using PostgreSQL, it is also possible to leave this migration up to the database layer. Inside of the change block you could write:
(PostgreSQLを使用している場合は、この移行をデータベース層に任せることも可能です。変更ブロックの中に、次のように書くことができます。)
execute <<~SQL.squish
  UPDATE todo_items
  SET position = mapping.new_position
  FROM (
    SELECT
      id,
      ROW_NUMBER() OVER (
        PARTITION BY todo_list_id
        ORDER BY updated_at
      ) as new_position
    FROM todo_items
  ) AS mapping
  WHERE todo_items.id = mapping.id;
SQL

Notes

All position queries (select, update, etc.) inside gem methods are executed without the default scope (i.e. Model.unscoped), this will prevent nasty issues when the default scope is different from acts_as_list scope.
The position column is set after validations are called, so you should not put a presence validation on the position column.
If you need a scope by a non-association field you should pass an array, containing field name, to a scope:

gemメソッド内のすべてのポジション・クエリ(select, updateなど)は、デフォルト・スコープ(すなわちModel.unscoped)を無視して実行されます。これにより、デフォルト・スコープがacts_as_listのスコープと異なる場合に起こる厄介な問題を防ぐことができます。
positionカラムはバリデーションが呼ばれた後に設定されるので、positionカラムにpresenceバリデーションをかけてはいけません

関連付けされていないフィールドによるスコープが必要な場合は、フィールド名を含む配列をスコープに渡す必要があります。

 class TodoItem < ActiveRecord::Base
   `kind` is a plain text field (e.g. 'work', 'shopping', 'meeting'), not an association
   `kind` はプレーンテキストのフィールド(例:'work''shopping''meeting')であり、関連性はありません。)
   acts_as_list scope: [:kind]
 end

You can also add multiple scopes in this fashion:
(また、この方法で複数のスコープを追加することもできます。)

class TodoItem < ActiveRecord::Base
 acts_as_list scope: [:kind, :owner_id]
end

Furthermore, you can optionally include a hash of fixed parameters that will be included in all queries:
(さらに、オプションとして、すべてのクエリに含まれる固定パラメータのハッシュを含めることができます。)

class TodoItem < ActiveRecord::Base
 acts_as_list scope: [:kind, :owner_id, deleted_at: nil]
end

This is useful when using this gem in conjunction with the popular acts_as_paranoid gem.
(これは、このgemを人気の高いacts_as_paranoid gemと組み合わせて使うときに便利です。)

More Options

column default: position. Use this option if the column name in your database is different from position.
top_of_list default: 1. Use this option to define the top of the list. Use 0 to make the collection act more like an array in its indexing.
add_new_at default: :bottom. Use this option to specify whether objects get added to the :top or :bottom of the list. nil will result in new items not being added to the list on create, i.e, position will be kept nil after create.
touch_on_update default: true. Use touch_on_update: false if you don't want to update the timestamps of the associated records.
sequential_updates Specifies whether insert_at should update objects positions during shuffling one by one to respect position column unique not null constraint. Defaults to true if position column has unique index, otherwise false. If constraint is deferrable initially deferred (PostgreSQL), overriding it with false will speed up insert_at.

その他のオプション
column default: position
データベースのカラム名がpositionと異なる場合は、このオプションを使用します。

top_of_list default: 1
リストの先頭を定義するには、このオプションを使用します。0を使用すると、コレクションのインデックス作成が配列のようになります。

add_new_at default: :bottom
このオプションは、オブジェクトをリストの :top と :bottom のどちらに追加するかを指定します。
nil を指定すると、作成時に新しいアイテムがリストに追加されません。

touch_on_update default: true
関連するレコードのタイムスタンプを更新したくない場合は、touch_on_update: falseを使用してください。

sequential_updates
insert_at がシャッフル中のオブジェクトの位置を 1 つずつ更新して、position カラムの unique not null 制約を守るかどうかを指定します。
デフォルトは、位置カラムにユニークインデックスがある場合はtrue、そうでない場合はfalseです。制約が遅延可能な場合、最初は遅延されます(PostgreSQL)。

Disabling temporarily

If you need to temporarily disable acts_as_list during specific operations such as mass-update or imports:
一時的な無効化
一括更新やインポートなどの特定の操作時にacts_as_listを一時的に無効にする必要がある場合。
TodoItem.acts_as_list_no_update do
 perform_mass_update
end
In an acts_as_list_no_update block, all callbacks are disabled, and positions are not updated. New records will be created with the default value from the database. It is your responsibility to correctly manage positions values.
You can also pass an array of classes as an argument to disable database updates on just those classes. It can be any ActiveRecord class that has acts_as_list enabled.

acts_as_list_no_updateブロックでは、すべてのコールバックが無効になり、ポジションも更新されません。
新しいレコードは、データベースのデフォルト値で作成されます。ポジションの値を正しく管理するのはお客様の責任です。
また、引数にクラスの配列を渡して、そのクラスだけデータベースの更新を無効にすることもできます。act_as_listが有効になっているActiveRecordのクラスであれば、どのクラスでも構いません。

class TodoList < ActiveRecord::Base
 has_many :todo_items, -> { order(position: :asc) }
 acts_as_list
end
class TodoItem < ActiveRecord::Base
 belongs_to :todo_list
 has_many :todo_attachments, -> { order(position: :asc) }
 acts_as_list scope: :todo_list
end
class TodoAttachment < ActiveRecord::Base
 belongs_to :todo_list
 acts_as_list scope: :todo_item
end
TodoItem.acts_as_list_no_update([TodoAttachment]) do
 TodoItem.find(10).update(position: 2)
 TodoAttachment.find(10).update(position: 1)
 TodoAttachment.find(11).update(position: 2)
 TodoList.find(2).update(position: 3) 
 For this instance the callbacks will be called because we haven't passed the class as an argument
 ( この例では、クラスを引数として渡していないため、コールバックが呼び出されます。)
end

Troubleshooting Database Deadlock Errors

When using this gem in an app with a high amount of concurrency, you may see "deadlock" errors raised by your database server. It's difficult for the gem to provide a solution that fits every app. Here are some steps you can take to mitigate and handle these kinds of errors.

このgemを並行性の高いアプリで使用していると、データベース・サーバで「デッドロック」エラーが発生することがあります。このgemがすべてのアプリケーションに適合するソリューションを提供することは困難です。ここでは、このようなエラーを軽減し、対処するための手順をご紹介します。

1) Use the Most Concise API
One easy way to reduce deadlocks is to use the most concise gem API available for what you want to accomplish. In this specific example, the more concise API for creating a list item at a position results in one transaction instead of two, and it issues fewer SQL statements. Issuing fewer statements tends to lead to faster transactions. Faster transactions are less likely to deadlock.

1) 簡潔なAPIを使う
デッドロックを減らす簡単な方法の1つは、達成したいことに対して最も簡潔なgemのAPIを使用することです。
この例では、ある位置にリスト・アイテムを作成するAPIを簡潔にすることで、トランザクションが2回ではなく1回になり、SQL文の数も少なくなります。
少ないステートメントを発行すると、トランザクションが速くなる傾向があります。
高速なトランザクションは、デッドロックの可能性も低くなります。

Example:

 Good
TodoItem.create(todo_list: todo_list, position: 1)
 Bad
item = TodoItem.create(todo_list: todo_list)
item.insert_at(1)
2) Rescue then Retry
Deadlocks are always a possibility when updating tables rows concurrently. The general advice from MySQL documentation is to catch these errors and simply retry the transaction; it will probably succeed on another attempt. (see How to Minimize and Handle Deadlocks) Retrying transactions sounds simple, but there are many details that need to be chosen on a per-app basis: How many retry attempts should be made? Should there be a wait time between attempts? What other statements were in the transaction that got rolled back?

Here a simple example of rescuing from deadlock and retrying the operation:

ActiveRecord::Deadlocked is available in Rails >= 5.1.0.
If you have Rails < 5.1.0, you will need to rescue ActiveRecord::StatementInvalid and check #cause.

2) レスキューとリトライ
テーブルの行を同時に更新する場合、デッドロックの可能性は常にあります。
MySQLのドキュメントにある一般的なアドバイスは、これらのエラーをキャッチして、単純にトランザクションを再試行することです。
(トランザクションの再試行は簡単なようですが、アプリケーションごとに選択しなければならない多くの詳細があります。
何回再試行を行うべきか?再試行の間に待ち時間を設けるべきか?
ロールバックされたトランザクションには他にどのようなステートメントがあったか?

ここでは、デッドロックから救出して操作を再試行する簡単な例を示します。
ActiveRecord::Deadlockedは、Rails >= 5.1.0で利用可能です。
Rails < 5.1.0の場合は、ActiveRecord::StatementInvalidを救出して#causeを確認する必要があります。

 attempts_left = 2
 while attempts_left > 0
   attempts_left -= 1
   begin
     TodoItem.transaction do
       TodoItem.create(todo_list: todo_list, position: 1)
     end
     attempts_left = 0
   rescue ActiveRecord::Deadlocked
     raise unless attempts_left > 0
   end
 end

You can also use the approach suggested in this StackOverflow post: https://stackoverflow.com/questions/4027659/activerecord3-deadlock-retry
(このStackOverflowの記事で提案されている方法を使うこともできます。)

3) Lock Parent Record
In addition to reacting to deadlocks, it is possible to reduce their frequency with more pessimistic locking. This approach uses the parent record as a mutex for the entire list. This kind of locking is very effective at reducing the frequency of deadlocks while updating list items. However, there are some things to keep in mind:
This locking pattern needs to be used around every call that modifies the list; even if it does not reorder list items.
This locking pattern effectively serializes operations on the list. The throughput of operations on the list will decrease.
Locking the parent record may lead to deadlock elsewhere if some other code also locks the parent table.

3) 親レコードのロック
デッドロックに対応するだけでなく、より悲観的なロックによってデッドロックの頻度を減らすことも可能です。
この方法では、親レコードをリスト全体のミューテックスとして使用します。
このようなロッキングは、リストアイテムの更新時のデッドロックの頻度を減らすのに非常に効果的です。
ただし、いくつか注意すべき点があります。

このロックパターンは、リストアイテムの順番を変更しない場合でも、リストを変更するすべての呼び出しに使用する必要があります。
このロックパターンは、リストに対する操作を効果的にシリアライズします。そのため、リストに対する操作のスループットが低下します。
親レコードをロックすると、他のコードが親テーブルをロックしている場合、他の場所でデッドロックが発生する可能性があります。

Example:

todo_list = TodoList.create(name: "The List")
todo_list.with_lock do
 item = TodoItem.create(description: "Buy Groceries", todo_list: todo_list, position: 1)
end

A note about data integrity

We often hear complaints that position values are repeated, incorrect etc. For example, #254. To ensure data integrity, you should rely on your database. There are two things you can do:
Use constraints. If you model Item that belongs_to an Order, and it has a position column, then add a unique constraint on items with [:order_id, :position]. Think of it as a list invariant. What are the properties of your list that don't change no matter how many items you have in it? One such propery is that each item has a distinct position. Another could be that position is always greater than 0. It is strongly recommended that you rely on your database to enforce these invariants or constraints. Here are the docs for PostgreSQL and MySQL.
Use mutexes or row level locks. At its heart the duplicate problem is that of handling concurrency. Adding a contention resolution mechanism like locks will solve it to some extent. But it is not a solution or replacement for constraints. Locks are also prone to deadlocks.
As a library, acts_as_list may not always have all the context needed to apply these tools. They are much better suited at the application level.

データの整合性についての注意点
ポジションの値が繰り返されている、間違っているなどの苦情をよく耳にします。
例えば、#254です。データの整合性を確保するには、データベースに頼るしかありません。できることは2つあります。

制約を使う。
Item that belongs_to an Orderをモデル化し、それがpositionカラムを持っている場合、[:order_id, :position]を持つアイテムにユニークな制約を追加します。これは、リストの不変性と考えてください。リストの中にアイテムがいくつあっても変化しないプロパティは何でしょうか?そのようなプロパティの1つは、各アイテムが明確なポジションを持っていることです。他には、位置が常に0より大きいことなどが考えられます。これらの不変性や制約を実施するためには、データベースに依存することが強く推奨されます。PostgreSQLとMySQLのドキュメントをご覧ください。

ミューテックスまたはロウレベルロックを使用する。
重複した問題の核心は、同時実行の処理にあります。ロックのような競合解決メカニズムを追加することで、ある程度は解決します。しかし、それは制約の解決策や代替にはなりません。また、ロックはデッドロックの原因になります。
ライブラリとしてのacts_as_listは、これらのツールを適用するのに必要なすべてのコンテキストを持っているとは限りません。これらは、アプリケーションレベルでの使用に適しています。

公式ドキュメントのリンク

使用したツール

DeepL翻訳

 最後に
私がブログを書く目的は、素晴らしい本や、素晴らしい方々の技術記事を知って頂きたいからです。ぜひ、上記の参考文献を見て下さい。(noteなので広告とかは一切ありません。)

現在、株式会社grabssに行くために最後の悪あがきをしています!!
現在の進行状況
この記事は40件目の投稿。目標まで後10件。

よろしければ、スキボタン及びサポートお願いします。勉強の励みになります。

この記事が気に入ったらサポートをしてみませんか?