Mongoid の timeless というメソッドはとても扱いが難しいという話
以下の挙動は、 gem mongoid のバージョン 9.0.6 での確認内容です。
端的に
述べると、以下の 2点です。
- Mongoid の
timeless
はグローバル [1] に状態を持っており直接使うのは難しいのでやめたほうがいいです。可能な限り直接使用するのは避けたいところです。 - Mongoid の情報を調べる中で
timeless
を先に発見した方がハマりがちと思われる罠ですが、ActiveRecord と同様にsave(touch: false)
が使えます。こっちで十分です。
Mongoid の timeless について
MongoDB を Rails で扱うためのライブラリに Mongoid があります。
操作感はかなり ActiveRecord および ActiveRecord に寄せている感じがあります。
timeless
とは、この Mongoid で定義されるメソッドです。
Mongoid::Timestamps モジュール
Mongoid::Timestamps
は、これを include すると created_at
および updated_at
がフィールドとして定義され、かつ登録時や更新時にタイムスタンプをセットしてくれます。
class Foo
include Mongoid::Document
include Mongoid::Timestamps
field :bar, type: String
field :baz, type: Integer
validates :bar, :baz, presence: true
end
Foo.new.update!(bar: "HOGE", baz: 18)
f1 = Foo.new
f1.created_at #=> nil
f1.updated_at #=> nil
# 新規登録により、 created_at, updated_at がセットされる
f1.update!(bar: "HOGE", baz: 18)
f1.created_at #=> 2025-06-01 07:34:45.715231132 UTC +00:00
f1.updated_at #=> 2025-06-01 07:34:45.715231132 UTC +00:00
# 更新により、 updated_at がセットされる
f1.update!(baz: 20)
f1.created_at #=> 2025-06-01 07:34:45.715231132 UTC +00:00
f1.updated_at #=> 2025-06-01 07:35:31.438567291 UTC +00:00
Mongoid::Timestamps::Timeless
Mongoid::Timestamps::Timeless
は Mongoid::Timestamps
モジュールをインクルードすると自動的についてくるモジュールです。
このモジュールにより #timeless
メソッドなどが生えます。
#timeless
を宣言すると、保存時に created_at
および updated_at
が更新されなくなります。
# timeless すると、保存してもタイムスタンプが埋まらない
# 新規登録時
f1 = Foo.new
f1.timeless
f1.update!(bar: "HOGE", baz: 18)
f1.created_at #=> nil
f1.updated_at #=> nil
# 更新時
f2 = Foo.new
f2.update!(bar: "HOGE", baz: 18)
f2.created_at #=> 2025-06-01 07:38:33.727927708 UTC +00:00
f2.updated_at #=> 2025-06-01 07:38:33.727927708 UTC +00:00
f2.timeless
f2.update!(baz: 20)
f2.created_at #=> 2025-06-01 07:38:33.727927708 UTC +00:00
f2.updated_at #=> 2025-06-01 07:38:33.727927708 UTC +00:00 # 変わっていない
#timeless
のふるまいについての注意点
#timeless
の影響範囲
シチュエーションによっては使い所がありそうですが、 #timeless
は非常に扱いが難しいメソッドです。なぜかというと、#timeless
を呼び出したインスタンスだけでなくクラスそのものにも影響があるためです。
Foo.timeless? #=> false
Foo.new.update!(bar: "HOGE", baz: 18)
f1 = Foo.last
f1.timeless
f1.timeless? #=> true
Foo.timeless? #=> true
そして、この影響は別のインスタンスに対しても同様に生じてしまいます。
個人的にはかなりびっくりする挙動だと思っています。
Foo.timeless? #=> false
f1 = Foo.new
f1.update!(bar: "HOGE", baz: 18)
# f1 が実行した timeless の影響を。。。
f1.timeless
f1.timeless? #=> true
# 別オブジェクトの f2 が受けてしまっている
f2 = Foo.new
f2.update!(bar: "FUGA", baz: 30)
f2.created_at #=> nil
f2.updated_at #=> nil
なお、「じゃあこの timeless 状態、いつ元に戻っているのか?」という話になるわけですが #clear_timeless_option
というメソッドを呼び出すことで元の状態に戻ります。
そして、 created_at
や updated_at
をセットするコールバック処理の中で #clear_timeless_option
は呼び出されています。
つまり、 #save
されればもとに戻るということになります。
# timeless を呼び出して直後に save する分には困ることは(あまり)ない(!?)
foo.timeless.save!
# といいますか、素直に touch: false のほうが良いです
# 厳密には save!(touch: false) の内部で timeless が使われていますが、
# 自分で timeless のステートを理解するよりも良い選択です。
foo.save!(touch: false)
具体的に困るシチュエーション例
では実際にどんなときに困るか? という話ですが例えば以下のようなアクションがあったとします。
通常の Foo の更新処理のようですが、 params[:preserve_timestamps]
パラメータがあったときは更新時刻をキープするような処理です。
class FoosController < ApplicationController
before_action :set_foo, only: %i[update]
def create
@foo = Foo.new(foo_params)
if @foo.save
# 成功時の処理
else
# 失敗時の処理 (エラー内容の描画など)
end
end
def update
# 特定パラメータがあるときには timeless にする
if params.expect(:preserve_timestamps).present?
@foo.timeless
end
if @foo.update(foo_params)
# 成功時の処理
else
# 失敗時の処理 (エラー内容の描画など)
end
end
private
def set_foo
@foo = Foo.find(params.expect(:id))
end
def foo_params = params.expect(foo: %i[bar baz])
end
このアクションに対して、 params[:preserve_timestamps]
を指定した形で更新リクエストします。
ただし、 params[:foo][:bar]
がブランクなのでバリデーションエラーとなるような形です。
# request update action
patch foo_path(@foo), params: { preserve_timestamps: 1, foo: { bar: "", baz: 18 } }
上記リクエストにより、バリデーションエラーなので @foo
は更新されません。
しかしながら、この処理が実施されたあとには Foo.timeless?
が真の状態のままです。
つづいて、今度は Foo の新規登録リクエストを実施します。今度は成功する形でリクエストします。
# request create action
post foos_path, params: { foo: { bar: "HOGE", baz: 18 } }
今度は成功するわけですが、その作成した Foo オブジェクトの created_at
や updated_at
は nil
のままになります。
なぜならば、以前のリクエスト処理により timeless 状態だったからです。
というような形で、タイムスタンプを正しくセットして欲しい場合にセットされない、という問題が再現できます。
回避策
- 更新時に
updated_at
の更新を抑止したい- すなおに
save(touch: false)
を使うのが良い選択です。大抵問題ないです。
- すなおに
- 登録時に
created_at
やupdated_at
を任意の値にしたい- 明示的に、それぞれにアサインするだけで OK です。
- 登録時に
created_at
およびupdated_at
をnil
のままにしたい- これは
timeless
を使う必要があるケースです。
。。。けど、その挙動を実際に要するケースって果たして存在するのかは少々疑問です。
- これは
-
厳密にはスレッドローカルです ↩︎
Discussion
そもそもなぜクラスレベルでの状態管理をしているのか? は疑問を覚えていて、 DeepWiki と会話したのでそれの share を置きます。
また、
save(touch: false)
についても結局内部で timeless を使うのでその途中で失敗した場合の状態が気になります。というわけで実際には以下のようなモジュールを用意して適用していたりします。